網(wǎng)絡(luò)游戲服務(wù)器設(shè)計
上一篇 / 下一篇 2008-02-22 17:34:18 / 個人分類:回收站
談這個話題之前,首先要讓大家知道,什么是服務(wù)器。在網(wǎng)絡(luò)游戲中,服務(wù)器所扮演的角色是同步,廣播和服務(wù)器主動的一些行為,比如說天氣,NPC AI之類的,之所以現(xiàn)在的很多網(wǎng)絡(luò)游戲服務(wù)器都需要負(fù)擔(dān)一些游戲邏輯上的運(yùn)算是因?yàn)闉榱朔乐箍蛻舳说?a onclick="javascript:tagshow(event, '%D7%F7%B1%D7');" href="javascript:;" target=_self>作弊行為。了解到這一點(diǎn),那么本系列的文章將分為兩部分來談?wù)劸W(wǎng)絡(luò)游戲服務(wù)器的設(shè)計,一部分是講如何做好服務(wù)器的網(wǎng)絡(luò)連接,同步,廣播以及NPC的設(shè)置,另一部分則將著重談?wù)勀男┻壿嫹旁诜?wù)器比較合適,并且用什么樣的結(jié)構(gòu)來安排這些邏輯。
服務(wù)器的網(wǎng)絡(luò)連接
大多數(shù)的網(wǎng)絡(luò)游戲的服務(wù)器都會選擇非阻塞select這種結(jié)構(gòu),為什么呢?因?yàn)榫W(wǎng)絡(luò)游戲的服務(wù)器需要處理的連接非常之多,并且大部分會選擇在Linux/Unix下運(yùn)行,那么為每個用戶開一個線程實(shí)際上是很不劃算的,一方面因?yàn)樵贚inux/Unix下的線程是用進(jìn)程這么一個概念模擬出來的,比較消耗系統(tǒng)資源,另外除了I/O之外,每個線程基本上沒有什么多余的需要并行的任務(wù),而且網(wǎng)絡(luò)游戲是互交性非常強(qiáng)的,所以線程間的同步會成為很麻煩的問題。由此一來,對于這種含有大量網(wǎng)絡(luò)連接的單線程服務(wù)器,用阻塞顯然是不現(xiàn)實(shí)的。對于網(wǎng)絡(luò)連接,需要用一個結(jié)構(gòu)來儲存,其中需要包含一個向客戶端寫消息的緩沖,還需要一個從客戶端讀消息的緩沖,具體的大小根據(jù)具體的消息結(jié)構(gòu)來定了。另外對于同步,需要一些時間校對的值,還需要一些各種不同的值來記錄當(dāng)前狀態(tài),下面給出一個初步的連接的結(jié)構(gòu):
typedef connection_s {
user_t *ob; /* 指向處理服務(wù)器端邏輯的結(jié)構(gòu) */
int fd; /* socket連接 */
struct sockaddr_in addr; /* 連接的地址信息 */
char text[MAX_TEXT]; /* 接收的消息緩沖 */
int text_end; /* 接收消息緩沖的尾指針 */
int text_start; /* 接收消息緩沖的頭指針 */
int last_time; /* 上一條消息是什么時候接收到的 */
struct timeval latency; /* 客戶端本地時間和服務(wù)器本地時間的差值 */
struct timeval last_confirm_time; /* 上一次驗(yàn)證的時間 */
short is_confirmed; /* 該連接是否通過驗(yàn)證過 */
int ping_num; /* 該客戶端到服務(wù)器端的ping值 */
int ping_ticker; /* 多少個IO周期處理更新一次ping值 */
int message_length; /* 發(fā)送緩沖消息長度 */
char message_buf[MAX_TEXT]; /* 發(fā)送緩沖區(qū) */
int iflags; /* 該連接的狀態(tài) */
} connection_t;
服務(wù)器循環(huán)的處理所有連接,是一個死循環(huán)過程,每次循環(huán)都用select檢查是否有新連接到達(dá),然后循環(huán)所有連接,看哪個連接可以寫或者可以讀,就處理該連接的讀寫。由于所有的處理都是非阻塞的,所以所有的Socket IO都可以用一個線程來完成。
由于網(wǎng)絡(luò)傳輸?shù)年P(guān)系,每次recv()到的數(shù)據(jù)可能不止包含一條消息,或者不到一條消息,那么怎么處理呢?所以對于接收消息緩沖用了兩個指針,每次接收都從text_start開始讀起,因?yàn)槔锩鏆埩舻目赡苁巧洗谓邮盏降亩嘤嗟陌霔l消息,然后text_end指向消息緩沖的結(jié)尾。這樣用兩個指針就可以很方便的處理這種情況,另外有一點(diǎn)值得注意的是:解析消息的過程是一個循環(huán)的過程,可能一次接收到兩條以上的消息在消息緩沖里面,這個時候就應(yīng)該執(zhí)行到消息緩沖里面只有一條都不到的消息為止,大體流程如下:
while ( text_end – text_start > 一條完整的消息長度 )
{
從text_start處開始處理;
text_start += 該消息長度;
}
memcpy ( text, text + text_start, text_end – text_start );
對于消息的處理,這里首先就需要知道你的游戲總共有哪些消息,所有的消息都有哪些,才能設(shè)計出比較合理的消息頭。一般來說,消息大概可分為主角消息,場景消息,同步消息和界面消息四個部分。其中主角消息包括客戶端所控制的角色的所有動作,包括走路,跑步,戰(zhàn)斗之類的。場景消息包括天氣變化,一定的時間在場景里出現(xiàn)一些東西等等之類的,這類消息的特點(diǎn)是所有消息的發(fā)起者都是服務(wù)器,廣播對象則是場景里的所有玩家。而同步消息則是針對發(fā)起對象是某個玩家,經(jīng)過服務(wù)器廣播給所有看得見他的玩家,該消息也是包括所有的動作,和主角消息不同的是該種消息是服務(wù)器廣播給客戶端的,而主角消息一般是客戶端主動發(fā)給服務(wù)器的。最后是界面消息,界面消息包括是服務(wù)器發(fā)給客戶端的聊天消息和各種屬性及狀態(tài)信息。
下面來談?wù)勏⒌慕M成。一般來說,一個消息由消息頭和消息體兩部分組成,其中消息頭的長度是不變的,而消息體的長度是可變的,在消息體中需要保存消息體的長度。由于要給每條消息一個很明顯的區(qū)分,所以需要定義一個消息頭特有的標(biāo)志,然后需要消息的類型以及消息ID。消息頭大體結(jié)構(gòu)如下:
type struct message_s {
unsigned short message_sign;
unsigned char message_type;
unsigned short message_id
unsigned char message_len
}message_t;
服務(wù)器的廣播
服務(wù)器的廣播的重點(diǎn)就在于如何計算出廣播的對象。很顯然,在一張很大的地圖里面,某個玩家在最東邊的一個動作,一個在最西邊的玩家是應(yīng)該看不到的,那么怎么來計算廣播的對象呢?最簡單的辦法,就是把地圖分塊,分成大小合適的小塊,然后每次只象周圍幾個小塊的玩家進(jìn)行廣播。那么究竟切到多大比較合適呢?一般來說,切得塊大了,內(nèi)存的消耗會增大,切得塊小了,CPU的消耗會增大(原因會在后面提到)。個人覺得切成一屏左右的小塊比較合適,每次廣播廣播周圍九個小塊的玩家,由于廣播的操作非常頻繁,那么遍利周圍九塊的操作就會變得相當(dāng)?shù)念l繁,所以如果塊分得小了,那么遍利的范圍就會擴(kuò)大,CPU的資源會很快的被吃完。
切好塊以后,怎么讓玩家在各個塊之間走來走去呢?讓我們來想想在切換一次塊的時候要做哪些工作。首先,要算出下個塊的周圍九塊的玩家有哪些是現(xiàn)在當(dāng)前塊沒有的,把自己的信息廣播給那些玩家,同時也要算出下個塊周圍九塊里面有哪些物件是現(xiàn)在沒有的,把那些物件的信息廣播給自己,然后把下個塊的周圍九快里沒有的,而現(xiàn)在的塊周圍九塊里面有的物件的消失信息廣播給自己,同時也把自己消失的消息廣播給那些物件。這個操作不僅煩瑣而且會吃掉不少CPU資源,那么有什么辦法可以很快的算出這些物件呢?一個個做比較?顯然看起來就不是個好辦法,這里可以參照二維矩陣碰撞檢測的一些思路,以自己周圍九塊為一個矩陣,目標(biāo)塊周圍九塊為另一個矩陣,檢測這兩個矩陣是否碰撞,如果兩個矩陣相交,那么沒相交的那些塊怎么算。這里可以把相交的塊的坐標(biāo)轉(zhuǎn)換成內(nèi)部坐標(biāo),然后再進(jìn)行運(yùn)算。
對于廣播還有另外一種解決方法,實(shí)施起來不如切塊來的簡單,這種方法需要客戶端來協(xié)助進(jìn)行運(yùn)算。首先在服務(wù)器端的連接結(jié)構(gòu)里面需要增加一個廣播對象的隊(duì)列,該隊(duì)列在客戶端登陸服務(wù)器的時候由服務(wù)器傳給客戶端,然后客戶端自己來維護(hù)這個隊(duì)列,當(dāng)有人走出客戶端視野的時候,由客戶端主動要求服務(wù)器給那個物件發(fā)送消失的消息。而對于有人總進(jìn)視野的情況,則比較麻煩了。
首先需要客戶端在每次給服務(wù)器發(fā)送update position的消息的時候,服務(wù)器都給該連接算出一個視野范圍,然后在需要廣播的時候,循環(huán)整張地圖上的玩家,找到坐標(biāo)在其視野范圍內(nèi)的玩家。使用這種方法的好處在于不存在轉(zhuǎn)換塊的時候需要一次性廣播大量的消息,缺點(diǎn)就是在計算廣播對象的時候需要遍歷整個地圖上的玩家,如果當(dāng)一個地圖上的玩家多得比較離譜的時候,該操作就會比較的慢。
服務(wù)器的同步
同步在網(wǎng)絡(luò)游戲中是非常重要的,它保證了每個玩家在屏幕上看到的東西大體是一樣的。其實(shí)呢,解決同步問題的最簡單的方法就是把每個玩家的動作都向其他玩家廣播一遍,這里其實(shí)就存在兩個問題:1,向哪些玩家廣播,廣播哪些消息。2,如果網(wǎng)絡(luò)延遲怎么辦。事實(shí)上呢,第一個問題是個非常簡單的問題,不過之所以我提出這個問題來,是提醒大家在設(shè)計自己的消息結(jié)構(gòu)的時候,需要把這個因素考慮進(jìn)去。而對于第二個問題,則是一個挺麻煩的問題,大家可以來看這么個例子:
比如有一個玩家A向服務(wù)器發(fā)了條指令,說我現(xiàn)在在P1點(diǎn),要去P2點(diǎn)。指令發(fā)出的時間是T0,服務(wù)器收到指令的時間是T1,然后向周圍的玩家廣播這條消息,消息的內(nèi)容是“玩家A從P1到P2”有一個在A附近的玩家B,收到服務(wù)器的這則廣播的消息的時間是T2,然后開始在客戶端上畫圖,A從P1到P2點(diǎn)。這個時候就存在一個不同步的問題,玩家A和玩家B的屏幕上顯示的畫面相差了T2-T1的時間。這個時候怎么辦呢?
有個解決方案,我給它取名叫 預(yù)測拉扯,雖然有些怪異了點(diǎn),不過基本上大家也能從字面上來理解它的意思。要解決這個問題,首先要定義一個值叫:預(yù)測誤差。然后需要在服務(wù)器端每個玩家連接的類里面加一項(xiàng)屬性,叫l(wèi)atency,然后在玩家登陸的時候,對客戶端的時間和服務(wù)器的時間進(jìn)行比較,得出來的差值保存在latency里面。還是上面的那個例子,服務(wù)器廣播消息的時候,就根據(jù)要廣播對象的latency,計算出一個客戶端的CurrentTime,然后在消息頭里面包含這個CurrentTime,然后再進(jìn)行廣播。并且同時在玩家A的客戶端本地建立一個隊(duì)列,保存該條消息,只到獲得服務(wù)器驗(yàn)證就從未被驗(yàn)證的消息隊(duì)列里面將該消息刪除,如果驗(yàn)證失敗,則會被拉扯回P1點(diǎn)。然后當(dāng)玩家B收到了服務(wù)器發(fā)過來的消息“玩家A從P1到P2”這個時候就檢查消息里面服務(wù)器發(fā)出的時間和本地時間做比較,如果大于定義的預(yù)測誤差,就算出在T2這個時間,玩家A的屏幕上走到的地點(diǎn)P3,然后把玩家B屏幕上的玩家A直接拉扯到P3,再繼續(xù)走下去,這樣就能保證同步。更進(jìn)一步,為了保證客戶端運(yùn)行起來更加smooth,我并不推薦直接把玩家拉扯過去,而是算出P3偏后的一點(diǎn)P4,然后用(P4-P1)/T(P4-P3)來算出一個很快的速度S,然后讓玩家A用速度S快速移動到P4,這樣的處理方法是比較合理的,這種解決方案的原形在國際上被稱為(Full plesiochronous),當(dāng)然,該原形被我篡改了很多來適應(yīng)網(wǎng)絡(luò)游戲的同步,所以而變成所謂的:預(yù)測拉扯。
另外一個解決方案,我給它取名叫 驗(yàn)證同步,聽名字也知道,大體的意思就是每條指令在經(jīng)過服務(wù)器驗(yàn)證通過了以后再執(zhí)行動作。具體的思路如下:首先也需要在每個玩家連接類型里面定義一個latency,然后在客戶端響應(yīng)玩家鼠標(biāo)行走的同時,客戶端并不會先行走動,而是發(fā)一條走路的指令給服務(wù)器,然后等待服務(wù)器的驗(yàn)證。服務(wù)器接受到這條消息以后,進(jìn)行邏輯層的驗(yàn)證,然后計算出需要廣播的范圍,包括玩家A在內(nèi),根據(jù)各個客戶端不同的latency生成不同的消息頭,開始廣播,這個時候這個玩家的走路信息就是完全同步的了。這個方法的優(yōu)點(diǎn)是能保證各個客戶端之間絕對的同步,缺點(diǎn)是當(dāng)網(wǎng)絡(luò)延遲比較大的時候,玩家的客戶端的行為會變得比較不流暢,給玩家?guī)砗懿凰母杏X。該種解決方案的原形在國際上被稱為(Hierarchical master-slave synchronization),80年代以后被廣泛應(yīng)用于網(wǎng)絡(luò)的各個領(lǐng)域。
最后一種解決方案是一種理想化的解決方案,在國際上被稱為Mutual synchronization,是一種對未來網(wǎng)絡(luò)的前景的良好預(yù)測出來的解決方案。這里之所以要提這個方案,并不是說我們已經(jīng)完全的實(shí)現(xiàn)了這種方案,而只是在網(wǎng)絡(luò)游戲領(lǐng)域的某些方面應(yīng)用到這種方案的某些思想。我對該種方案取名為:半服務(wù)器同步。大體的設(shè)計思路如下:
首先客戶端需要在登陸世界的時候建立很多張廣播列表,這些列表在客戶端后臺和服務(wù)器要進(jìn)行不及時同步,之所以要建立多張列表,是因?yàn)橐獜V播的類型是不止一種的,比如說有l(wèi)ocal message,有remote message,還有g(shù)lobal message 等等,這些列表都需要在客戶端登陸的時候根據(jù)服務(wù)器發(fā)過來的消息建立好。在建立列表的同時,還需要獲得每個列表中廣播對象的latency,并且要維護(hù)一張完整的用戶狀態(tài)列表在后臺,也是不及時的和服務(wù)器進(jìn)行同步,根據(jù)本地的用戶狀態(tài)表,可以做到一部分決策由客戶端自己來決定,當(dāng)客戶端發(fā)送這部分決策的時候,則直接將最終決策發(fā)送到各個廣播列表里面的客戶端,并對其時間進(jìn)行校對,保證每個客戶端在收到的消息的時間是和根據(jù)本地時間進(jìn)行校對過的。那么再采用預(yù)測拉扯中提到過的計算提前量,提高速度行走過去的方法,將會使同步變得非常的smooth。該方案的優(yōu)點(diǎn)是不通過服務(wù)器,客戶端自己之間進(jìn)行同步,大大的降低了由于網(wǎng)絡(luò)延遲而帶來的誤差,并且由于大部分決策都可以由客戶端來做,也大大的降低了服務(wù)器的資源。由此帶來的弊端就是由于消息和決策權(quán)都放在客戶端本地,所以給外掛提供了很大的可乘之機(jī)。
下面我想來談?wù)勱P(guān)于服務(wù)器上NPC的設(shè)計以及NPC智能等一些方面涉及到的問題。首先,我們需要知道什么是NPC,NPC需要做什么。NPC的全稱是(Non-Player Character),很顯然,他是一個character,但不是玩家,那么從這點(diǎn)上可以知道,NPC的某些行為是和玩家類似的,他可以行走,可以戰(zhàn)斗,可以呼吸(這點(diǎn)將在后面的NPC智能里面提到),另外一點(diǎn)和玩家物件不同的是,NPC可以復(fù)生(即NPC被打死以后在一定時間內(nèi)可以重新出來)。其實(shí)還有最重要的一點(diǎn),就是玩家物件的所有決策都是玩家做出來的,而NPC的決策則是由計算機(jī)做出來的,所以在對NPC做何種決策的時候,需要所謂的NPC智能來進(jìn)行決策。
下面我將分兩個部分來談?wù)凬PC,首先是NPC智能,其次是服務(wù)器如何對NPC進(jìn)行組織。之所以要先談NPC智能是因?yàn)橹挥挟?dāng)我們了解清楚我們需要NPC做什么之后,才好開始設(shè)計服務(wù)器來對NPC進(jìn)行組織。
NPC智能
NPC智能分為兩種,一種是被動觸發(fā)的事件,一種是主動觸發(fā)的事件。對于被動觸發(fā)的事件,處理起來相對來說簡單一些,可以由事件本身來呼叫NPC身上的函數(shù),比如說NPC的死亡,實(shí)際上是在NPC的HP小于一定值的時候,來主動呼叫NPC身上的OnDie() 函數(shù),這種由事件來觸發(fā)NPC行為的NPC智能,我稱為被動觸發(fā)。這種類型的觸發(fā)往往分為兩種:
一種是由別的物件導(dǎo)致的NPC的屬性變化,然后屬性變化的同時會導(dǎo)致NPC產(chǎn)生一些行為。由此一來,NPC物件里面至少包含以下幾種函數(shù):
class NPC {
public:
// 是誰在什么地方導(dǎo)致了我哪項(xiàng)屬性改變了多少。
OnChangeAttribute(object_t *who, int which, int how, int where);
Private:
OnDie();
OnEscape();
OnFollow();
OnSleep();
// 一系列的事件。
}
這是一個基本的NPC的結(jié)構(gòu),這種被動的觸發(fā)NPC的事件,我稱它為NPC的反射。但是,這樣的結(jié)構(gòu)只能讓NPC被動的接收一些信息來做出決策,這樣的NPC是愚蠢的。那么,怎么樣讓一個NPC能夠主動的做出一些決策呢?這里有一種方法:呼吸。那么怎么樣讓NPC有呼吸呢?
一種很簡單的方法,用一個計時器,定時的觸發(fā)所有NPC的呼吸,這樣就可以讓一個NPC有呼吸起來。這樣的話會有一個問題,當(dāng)NPC太多的時候,上一次NPC的呼吸還沒有呼吸完,下一次呼吸又來了,那么怎么解決這個問題呢。這里有一種方法,讓NPC異步的進(jìn)行呼吸,即每個NPC的呼吸周期是根據(jù)NPC出生的時間來定的,這個時候計時器需要做的就是隔一段時間檢查一下,哪些NPC到時間該呼吸了,就來觸發(fā)這些NPC的呼吸。
上面提到的是系統(tǒng)如何來觸發(fā)NPC的呼吸,那么NPC本身的呼吸頻率該如何設(shè)定呢?這個就好象現(xiàn)實(shí)中的人一樣,睡覺的時候和進(jìn)行激烈運(yùn)動的時候,呼吸頻率是不一樣的。同樣,NPC在戰(zhàn)斗的時候,和平常的時候,呼吸頻率也不一樣。那么就需要一個Breath_Ticker來設(shè)置NPC當(dāng)前的呼吸頻率。
那么在NPC的呼吸事件里面,我們怎么樣來設(shè)置NPC的智能呢?大體可以概括為檢查環(huán)境和做出決策兩個部分。首先,需要對當(dāng)前環(huán)境進(jìn)行數(shù)字上的統(tǒng)計,比如說是否在戰(zhàn)斗中,戰(zhàn)斗有幾個敵人,自己的HP還剩多少,以及附近有沒有敵人等等之類的統(tǒng)計。統(tǒng)計出來的數(shù)據(jù)傳入本身的決策模塊,決策模塊則根據(jù)NPC自身的性格取向來做出一些決策,比如說野蠻型的NPC會在HP比較少的時候仍然猛撲猛打,又比如說智慧型的NPC則會在HP比較少的時候選擇逃跑。等等之類的。
至此,一個可以呼吸,反射的NPC的結(jié)構(gòu)已經(jīng)基本構(gòu)成了,那么接下來我們就來談?wù)勏到y(tǒng)如何組織讓一個NPC出現(xiàn)在世界里面。
NPC的組織
這里有兩種方案可供選擇,其一:NPC的位置信息保存在場景里面,載入場景的時候載入NPC。其二,NPC的位置信息保存在NPC身上,有專門的事件讓所有的NPC登陸場景。這兩種方法有什么區(qū)別呢?又各有什么好壞呢?
前一種方法好處在于場景載入的時候同時載入了NPC,場景就可以對NPC進(jìn)行管理,不需要多余的處理,而弊端則在于在刷新的時候是同步刷新的,也就是說一個場景里面的NPC可能會在同一時間內(nèi)長出來。而對于第二種方法呢,設(shè)計起來會稍微麻煩一些,需要一個統(tǒng)一的機(jī)制讓NPC登陸到場景,還需要一些比較麻煩的設(shè)計,但是這種方案可以實(shí)現(xiàn)NPC異步的刷新,是目前網(wǎng)絡(luò)游戲普遍采用的方法,下面我們就來著重談?wù)勥@種方法的實(shí)現(xiàn):
首先我們要引入一個“靈魂”的概念,即一個NPC在死后,消失的只是他的肉體,他的靈魂仍然在世界中存在著,沒有呼吸,在死亡的附近漂浮,等著到時間投胎,投胎的時候把之前的所有屬性清零,重新在場景上構(gòu)建其肉體。那么,我們怎么來設(shè)計這樣一個結(jié)構(gòu)呢?首先把一個場景里面要出現(xiàn)的NPC制作成圖量表,給每個NPC一個獨(dú)一無二的標(biāo)識符,在載入場景之后,根據(jù)圖量表來載入屬于該場景的NPC。在NPC的OnDie() 事件里面不直接把該物件destroy 掉,而是關(guān)閉NPC的呼吸,然后打開一個重生的計時器,最后把該物件設(shè)置為invisable。這樣的設(shè)計,可以實(shí)現(xiàn)NPC的異步刷新,在節(jié)省服務(wù)器資源的同時也讓玩家覺得更加的真實(shí)。
(這一章節(jié)已經(jīng)牽扯到一些服務(wù)器腳本相關(guān)的東西,所以下一章節(jié)將談?wù)劮?wù)器腳本相關(guān)的一些設(shè)計)
補(bǔ)充的談?wù)剢l(fā)式搜索(heuristic searching)在NPC智能中的應(yīng)用。
其主要思路是在廣度優(yōu)先搜索的同時,將下一層的所有節(jié)點(diǎn)經(jīng)過一個啟發(fā)函數(shù)進(jìn)行過濾,一定范圍內(nèi)縮小搜索范圍。眾所周知的尋路A*算法就是典型的啟發(fā)式搜索的應(yīng)用,其原理是一開始設(shè)計一個Judge(point_t* point)函數(shù),來獲得point這個一點(diǎn)的代價,然后每次搜索的時候把下一步可能到達(dá)的所有點(diǎn)都經(jīng)過Judge()函數(shù)評價一下,獲取兩到三個代價比較小的點(diǎn),繼續(xù)搜索,那些沒被選上的點(diǎn)就不會在繼續(xù)搜索下去了,這樣帶來的后果的是可能求出來的不是最優(yōu)路徑,這也是為什么A*算法在尋路的時候會走到障礙物前面再繞過去,而不是預(yù)先就走斜線來繞過該障礙物。如果要尋出最優(yōu)化的路徑的話,是不能用A*算法的,而是要用動態(tài)規(guī)劃的方法,其消耗是遠(yuǎn)大于A*的。
那么,除了在尋路之外,還有哪些地方可以應(yīng)用到啟發(fā)式搜索呢?其實(shí)說得大一點(diǎn),NPC的任何決策都可以用啟發(fā)式搜索來做,比如說逃跑吧,如果是一個2D的網(wǎng)絡(luò)游戲,有八個方向,NPC選擇哪個方向逃跑呢?就可以設(shè)置一個Judge(int direction)來給定每個點(diǎn)的代價,在Judge里面算上該點(diǎn)的敵人的強(qiáng)弱,或者該敵人的敏捷如何等等,最后選擇代價最小的地方逃跑。下面,我們就來談?wù)剬τ趲追NNPC常見的智能的啟發(fā)式搜索法的設(shè)計:
Target select (選擇目標(biāo)):
首先獲得地圖上離該NPC附近的敵人列表。設(shè)計Judge() 函數(shù),根據(jù)敵人的強(qiáng)弱,敵人的遠(yuǎn)近,算出代價。然后選擇代價最小的敵人進(jìn)行主動攻擊。
Escape(逃跑):
在呼吸事件里面檢查自己的HP,如果HP低于某個值的時候,或者如果你是遠(yuǎn)程兵種,而敵人近身的話,則觸發(fā)逃跑函數(shù),在逃跑函數(shù)里面也是對周圍的所有的敵人組織成列表,然后設(shè)計Judge() 函數(shù),先選擇出對你構(gòu)成威脅最大的敵人,該Judge() 函數(shù)需要判斷敵人的速度,戰(zhàn)斗力強(qiáng)弱,最后得出一個主要敵人,然后針對該主要敵人進(jìn)行路徑的Judge() 的函數(shù)的設(shè)計,搜索的范圍只可能是和主要敵人相反的方向,然后再根據(jù)該幾個方向的敵人的強(qiáng)弱來計算代價,做出最后的選擇。
Random walk(隨機(jī)走路):
這個我并不推薦用A*算法,因?yàn)镹PC一旦多起來,那么這個對CPU的消耗是很恐怖的,而且NPC大多不需要長距離的尋路,只需要在附近走走即可,那么,就在附近隨機(jī)的給幾個點(diǎn),然后讓NPC走過去,如果碰到障礙物就停下來,這樣幾乎無任何負(fù)擔(dān)。
Follow Target(追隨目標(biāo)):
這里有兩種方法,一種方法NPC看上去比較愚蠢,一種方法看上去NPC比較聰明,第一種方法就是讓NPC跟著目標(biāo)的路點(diǎn)走即可,幾乎沒有資源消耗。而后一種則是讓NPC在跟隨的時候,在呼吸事件里面判斷對方的當(dāng)前位置,然后走直線,碰上障礙物了用A*繞過去,該種設(shè)計會消耗一定量的系統(tǒng)資源,所以不推薦NPC大量的追隨目標(biāo),如果需要大量的NPC追隨目標(biāo)的話,還有一個比較簡單的方法:讓NPC和目標(biāo)同步移動,即讓他們的速度統(tǒng)一,移動的時候走同樣的路點(diǎn),當(dāng)然,這種設(shè)計只適合NPC所跟隨的目標(biāo)不是追殺的關(guān)系,只是跟隨著玩家走而已了。
在這一章節(jié),我想談?wù)勱P(guān)于服務(wù)器端的腳本的相關(guān)設(shè)計。因?yàn)樵谏弦徽鹿?jié)里面,談NPC智能相關(guān)的時候已經(jīng)接觸到一些腳本相關(guān)的東東了。還是先來談?wù)勀_本的作用吧。
在基于編譯的服務(wù)器端程序中,是無法在程序的運(yùn)行過程中構(gòu)建一些東西的,那么這個時候就需要腳本語言的支持了,由于腳本語言涉及到邏輯判斷,所以光提供一些函數(shù)接口是沒用的,還需要提供一些簡單的語法和文法解析的功能。其實(shí)說到底,任何的事件都可以看成兩個部分:第一是對自身,或者別的物件的數(shù)值的改變,另外一個就是將該事件以文字或者圖形的方式廣播出去。那么,這里牽扯到一個很重要的話題,就是對某一物件進(jìn)行尋址。恩,談到這,我想將本章節(jié)分為三個部分來談,首先是服務(wù)器如何來管理動態(tài)創(chuàng)建出來的物件(服務(wù)器內(nèi)存管理),第二是如何對某一物件進(jìn)行尋址,第三則是腳本語言的組織和解釋。其實(shí)之所以到第四章再來談服務(wù)器的內(nèi)存管理是因?yàn)樵谇皫渍抡勥@個的話,大家對其沒有一個感性的認(rèn)識,可能不知道服務(wù)器的內(nèi)存管理究竟有什么用。
4.1、服務(wù)器內(nèi)存管理
對于服務(wù)器內(nèi)存管理我們將采用內(nèi)存池的方法,也稱為靜態(tài)內(nèi)存管理。其概念為在服務(wù)器初始化的時候,申請一塊非常大的內(nèi)存,稱為內(nèi)存池(Memory pool),同時也申請一小塊內(nèi)存空間,稱為垃圾回收站(Garbage recollecting station)。其大體思路如下:當(dāng)程序需要申請內(nèi)存的時候,首先檢查垃圾回收站是否為空,如果不為空的話,則從垃圾回收站中找一塊可用的內(nèi)存地址,在內(nèi)存池中根據(jù)地址找到相應(yīng)的空間,分配給程序用,如果垃圾回收站是空的話,則直接從內(nèi)存池的當(dāng)前指針位置申請一塊內(nèi)存;當(dāng)程序釋放空間的時候,給那塊內(nèi)存打上已經(jīng)釋放掉的標(biāo)記,然后把那塊內(nèi)存的地址放入垃圾回收站。
下面具體談?wù)勗摲椒ǖ脑敿?xì)設(shè)計,首先,我們將采用類似于操作系統(tǒng)的段頁式系統(tǒng)來管理內(nèi)存,這樣的好處是可以充分的利用內(nèi)存池,其缺點(diǎn)是管理起來比較麻煩。嗯,下面來具體看看我們怎么樣來定義頁和段的結(jié)構(gòu):
typedef struct m_segment_s
{
struct m_segment_s *next; /* 雙線鏈表 + 靜態(tài)內(nèi)存可以達(dá)到隨機(jī)訪問和順序訪問的目的,
真正的想怎么訪問,就怎么訪問。 */
struct m_segment_s *pre; int flags; // 該段的一些標(biāo)記。
int start; // 相對于該頁的首地址。
int size; // 長度。
struct m_page_s *my_owner; // 我是屬于哪一頁的。
char *data; // 內(nèi)容指針。
}m_segment_t;
typedef struct m_page_s
{
unsigned int flags; /* 使用標(biāo)記,是否完全使用,是否還有空余 */
int size; /* 該頁的大小,一般都是統(tǒng)一的,最后一頁除外 */
int end; /* 使用到什么地方了 */
int my_index; /* 提供隨機(jī)訪問的索引 */
m_segment_t *segments; // 頁內(nèi)段的頭指針。
}m_page_t;
那么內(nèi)存池和垃圾回收站怎么構(gòu)建呢?下面也給出一些構(gòu)建相關(guān)的偽代碼:
static m_page_t *all_pages;
// total_size是總共要申請的內(nèi)存數(shù),num_pages是總共打算創(chuàng)建多少個頁面。
void initialize_memory_pool( int total_size, int num_pages )
{
int i, page_size, last_size; // 算出每個頁面的大小。
page_size = total_size / num_pages; // 分配足夠的頁面。
all_pages = (m_page_t*) calloc( num_pages, sizeof(m_page_t*) );
for ( i = 0; i < num_pages; i ++ )
{
// 初始化每個頁面的段指針。
all_pages[i].m_segment_t = (m_segment_t*) malloc( page_size );
// 初始化該頁面的標(biāo)記。
all_pages[i].flags |= NEVER_USED;
// 除了最后一個頁面,其他的大小都是page_size 大小。
all_pages[i].size = page_size;
// 初始化隨機(jī)訪問的索引。
all_pages[i].my_index = i;
// 由于沒有用過,所以大小都是0
all_pages[i].end = 0;
}
// 設(shè)置最后一個頁面的大小。
if ( (last_size = total_size % num_pages) != 0 )
all_pages[i].size = last_size;
}
下面看看垃圾回收站怎么設(shè)計:
int **garbage_station;
void init_garbage_station( int num_pages, int page_size )
{
int i;
garbage_station = (int**) calloc( num_pages, sizeof( int* ) );
for ( i = 0; i < num_pages; i ++)
{
// 這里用unsigned short的高8位來儲存首相對地址,低8位來儲存長度。
garbage_station[i] = (int*) calloc( page_size, sizeof( unsigned short ));
memset( garbage_station[i], 0, sizeof( garbage_station[i] ));
}
}
也許這樣的貼代碼會讓大家覺得很不明白,嗯,我的代碼水平確實(shí)不怎么樣,那么下面我來用文字方式來敘說一下大體的概念吧。對于段頁式內(nèi)存管理,首先分成N個頁面,這個是固定的,而對于每個頁面內(nèi)的段則是動態(tài)的,段的大小事先是不知道的,那么我們需要回收的不僅僅是頁面的內(nèi)存,還包括段的內(nèi)存,那么我們就需要一個二維數(shù)組來保存是哪個頁面的那塊段的地址被釋放了。然后對于申請內(nèi)存的時候,則首先檢查需要申請內(nèi)存的大小,如果不夠一個頁面大小的話,則在垃圾回收站里面尋找可用的段空間分配,如果找不到,則申請一個新的頁面空間。
這樣用內(nèi)存池的方法來管理整個游戲世界的內(nèi)存可以有效的減少內(nèi)存碎片,一定程度的提高游戲運(yùn)行的穩(wěn)定性和效率。
4.2、游戲中物件的尋址
第一個問題,我們?yōu)槭裁匆獙ぶ??加入了腳本語言的概念之后,游戲中的一些邏輯物件,比如說NPC,某個ITEM之類的都是由腳本語言在游戲運(yùn)行的過程中動態(tài)生成的,那么我們通過什么樣的方法來對這些物件進(jìn)行索引呢?說得簡單一點(diǎn),就是如何找到他們呢?有個很簡單的方法,全部遍歷一次。當(dāng)然,這是個簡單而有效的方法,但是效率上的消耗是任何一臺服務(wù)器都吃不消的,特別是在游戲的規(guī)模比較大之后。
那么,我們怎么來在游戲世界中很快的尋找這些物件呢?我想在談這個之前,說一下Hash Table這個數(shù)據(jù)結(jié)構(gòu),它叫哈希表,也有人叫它散列表,其工作原理是不是順序訪問,也不是隨機(jī)訪問,而是通過一個散列函數(shù)對其key進(jìn)行計算,算出在內(nèi)存中這個key對應(yīng)的value的地址,而對其進(jìn)行訪問。好處是不管面對多大的數(shù)據(jù),只需要一次計算就能找到其地址,非常的快捷,那么弊端是什么呢?當(dāng)兩個key通過散列函數(shù)計算出來的地址是同一個地址的時候,麻煩就來了,會產(chǎn)生碰撞,其的解決方法非常的麻煩,這里就不詳細(xì)談其解決方法了,否則估計再寫個四,五章也未必談得清楚,不過如果大家對其感興趣的話,歡迎討論。
嗯,我們將用散列表來對游戲中的物件進(jìn)行索引,具體怎么做呢?首先,在內(nèi)存池中申請一塊兩倍大于游戲中物件總數(shù)的內(nèi)存,為什么是兩倍大呢?防止散列表碰撞。然后我們選用物件的名稱作為散列表的索引key,然后就可以開始設(shè)計散列函數(shù)了。下面來看個例子:
static int T[] =
{
1, 87, 49, 12, 176, 178, 102, 166, 121, 193, 6, 84, 249, 230, 44, 163,
14, 197, 213, 181, 161, 85, 218, 80, 64, 239, 24, 226, 236, 142, 38, 200,
110, 177, 104, 103, 141, 253, 255, 50, 77, 101, 81, 18, 45, 96, 31, 222,
25, 107, 190, 70, 86, 237, 240, 34, 72, 242, 20, 214, 244, 227, 149, 235,
97, 234, 57, 22, 60, 250, 82, 175, 208, 5, 127, 199, 111, 62, 135, 248,
174, 169, 211, 58, 66, 154, 106, 195, 245, 171, 17, 187, 182, 179, 0, 243,
132, 56, 148, 75, 128, 133, 158, 100, 130, 126, 91, 13, 153, 246, 216, 219,
119, 68, 223, 78, 83, 88, 201, 99, 122, 11, 92, 32, 136, 114, 52, 10,
138, 30, 48, 183, 156, 35, 61, 26, 143, 74, 251, 94, 129, 162, 63, 152,
170, 7, 115, 167, 241, 206, 3, 150, 55, 59, 151, 220, 90, 53, 23, 131,
125, 173, 15, 238, 79, 95, 89, 16, 105, 137, 225, 224, 217, 160, 37, 123,
118, 73, 2, 157, 46, 116, 9, 145, 134, 228, 207, 212, 202, 215, 69, 229,
27, 188, 67, 124, 168, 252, 42, 4, 29, 108, 21, 247, 19, 205, 39, 203,
233, 40, 186, 147, 198, 192, 155, 33, 164, 191, 98, 204, 165, 180, 117, 76,
140, 36, 210, 172, 41, 54, 159, 8, 185, 232, 113, 196, 231, 47, 146, 120,
51, 65, 28, 144, 254, 221, 93, 189, 194, 139, 112, 43, 71, 109, 184, 209,
};
// s是需要進(jìn)行索引的字符串指針,maxn是字符串可能的最大長度,返回值是相對地址。
inline int whashstr(char *s, int maxn)
{
register unsigned char oh, h;
register unsigned char *p;
register int i;
if (!*s)
return 0;
p = (unsigned char *) s;
oh = T[*p]; h = (*(p++) + 1) & 0xff;
for (i = maxn - 1; *p && --i >= 0; )
{
oh = T[oh ^ *p]; h = T[h ^ *(p++)];
}
return (oh << 8) + h;
}
具體的算法就不說了,上面的那一大段東西不要問我為什么,這個算法的出處是CACM 33-6中的一個叫Peter K.Pearson的鬼子寫的論文中介紹的算法,據(jù)說速度非常的快。有了這個散列函數(shù),我們就可以通過它來對世界里面的任意物件進(jìn)行非常快的尋址了。
4.3、腳本語言解釋
在設(shè)計腳本語言之前,我們首先需要明白,我們的腳本語言要實(shí)現(xiàn)什么樣的功能?否則隨心所欲的做下去寫出個C的解釋器之類的也說不定。我們要實(shí)現(xiàn)的功能只是簡單的邏輯判斷和循環(huán),其他所有的功能都可以由事先提供好的函數(shù)來完成。嗯,這樣我們就可以列出一張工作量的表單:設(shè)計物件在底層的保存結(jié)構(gòu),提供腳本和底層間的訪問接口,設(shè)計支持邏輯判斷和循環(huán)的解釋器。
下面先來談?wù)勎锛诘讓拥谋4娼Y(jié)構(gòu)。具體到每種不同屬性的物件,需要采用不同的結(jié)構(gòu),當(dāng)然,如果你愿意的話,你可以所有的物件都采同同樣的結(jié)構(gòu),然后在結(jié)構(gòu)里面設(shè)計一個散列表來保存各種不同的屬性。但這并不是一個好方法,過分的依賴散列表會讓你的游戲的邏輯變得繁雜不清。所以,盡量的區(qū)分每種不同的物件采用不同的結(jié)構(gòu)來設(shè)計。但是有一點(diǎn)值得注意的是,不管是什么結(jié)構(gòu),有一些東西是統(tǒng)一的,就是我們所說的物件頭,那么我們怎么來設(shè)計這樣一個物件頭呢?
typedef struct object_head_s
{
char* name;
char* prog;
}object_head_t;
其中name是在散列表中這個物件的索引號,prog則是腳本解釋器需要解釋的程序內(nèi)容。下面我們就以NPC為例來設(shè)計一個結(jié)構(gòu):
typedef struct npc_s
{
object_head_t header; // 物件頭
int hp; // NPC的hp值。
int level; // NPC的等級。
struct position_s position; // 當(dāng)前的位置信息。
unsigned int personality; // NPC的個性,一個unsigned int可以保存24種個性。
}npc_t;
OK,結(jié)構(gòu)設(shè)計完成,那么我們怎么來設(shè)計腳本解釋器呢?這里有兩種法,一種是用虛擬機(jī)的模式來解析腳本語言,另外一中則是用類似匯編語言的那種結(jié)構(gòu)來設(shè)計,設(shè)置一些條件跳轉(zhuǎn)和循環(huán)就可以實(shí)現(xiàn)邏輯判斷和循環(huán)了,比如:
set name, "路人甲";
CHOOSE: random_choose_personality; // 隨機(jī)選擇NPC的個性
compare hp, 100; // 比較氣血,比較出的值可以放在一個固定的變量里面
ifless LESS; // hp < 100的話,則返回。
jump CHOOSE; // 否則繼續(xù)選擇,只到選到一個hp < 100的。
LESS: return success;
這種腳本結(jié)構(gòu)就類似CPU的指令的結(jié)構(gòu),一條一條指令按照順序執(zhí)行,對于腳本程序員(Script. Programmer)也可以培養(yǎng)他們匯編能力的說。
那么怎么來模仿這種結(jié)構(gòu)呢?我們拿CPU的指令做參照,首先得設(shè)置一些寄存器,CPU的寄存器的大小和數(shù)量是受硬件影響的,但我們是用內(nèi)存來模擬寄存器,所以想要多大,就可以有多大。然后提供一些指令,包括四則運(yùn)算,尋址,判斷,循環(huán)等等。接下來針對不同的腳本用不同的解析方法,比如說對NPC就用NPC固定的腳本,對ITEM就用ITEM固定的腳本,解析完以后就把結(jié)果生成底層該物件的結(jié)構(gòu)用于使用。
而如果要用虛擬機(jī)來實(shí)現(xiàn)腳本語言的話呢,則會將工程變得無比之巨大,強(qiáng)烈不推薦使用,不過如果你想做一個通用的網(wǎng)絡(luò)游戲底層的話,則可以考慮設(shè)計一個虛擬機(jī)。虛擬機(jī)大體的解釋過程就是進(jìn)行兩次編譯,第一次對關(guān)鍵字進(jìn)行編譯,第二次生成匯編語言,然后虛擬機(jī)在根據(jù)編譯生成的匯編語言進(jìn)行逐行解釋,如果大家對這個感興趣的話,可以去www.mudos.org上下載一份MudOS的原碼來研究研究。