引言
游戲開(kāi)發(fā)分為客戶端和服務(wù)端,服務(wù)端和客戶端的邏輯很相似,但是服務(wù)端更多在于數(shù)值計(jì)算和校驗(yàn),客戶端更多在于面向玩家的一些邏輯。
C/S通信設(shè)計(jì)
大多數(shù)的網(wǎng)絡(luò)游戲的服務(wù)器都會(huì)選擇非阻塞select這種結(jié)構(gòu),為什么呢?因?yàn)榫W(wǎng)絡(luò)游戲的服務(wù)器需要處理的連接非常之多,并且大部分會(huì)選擇在Linux/Unix下運(yùn)行,那么為每個(gè)用戶開(kāi)一個(gè)線程實(shí)際上是很不劃算的,一方面因?yàn)樵贚inux/Unix下的線程是用進(jìn)程這么一個(gè)概念模擬出來(lái)的,比較消耗系統(tǒng)資源,另外除了I/O之外,每個(gè)線程基本上沒(méi)有什么多余的需要并行的任務(wù),而且網(wǎng)絡(luò)游戲是互交性非常強(qiáng)的,所以線程間的同步會(huì)成為很麻煩的問(wèn)題。由此一來(lái),對(duì)于這種含有大量網(wǎng)絡(luò)連接的單線程服務(wù)器,用阻塞顯然是不現(xiàn)實(shí)的。對(duì)于網(wǎng)絡(luò)連接,需要用一個(gè)結(jié)構(gòu)來(lái)儲(chǔ)存,其中需要包含一個(gè)向客戶端寫消息的緩沖,還需要一個(gè)從客戶端讀消息的緩沖,具體的大小根據(jù)具體的消息結(jié)構(gòu)來(lái)定了。另外對(duì)于同步,需要一些時(shí)間校對(duì)的值,還需要一些各種不同的值來(lái)記錄當(dāng)前狀態(tài),下面給出一個(gè)初步的連接的結(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; /* 上一條消息是什么時(shí)候接收到的 */
struct timeval latency; /* 客戶端本地時(shí)間和服務(wù)器本地時(shí)間的差值 */
struct timeval last_confirm_time; /* 上一次驗(yàn)證的時(shí)間 */
short is_confirmed; /* 該連接是否通過(guò)驗(yàn)證過(guò) */
int ping_num; /* 該客戶端到服務(wù)器端的ping值 */
int ping_ticker; /* 多少個(gè)IO周期處理更新一次ping值 */
int message_length; /* 發(fā)送緩沖消息長(zhǎng)度 */
char message_buf[MAX_TEXT]; /* 發(fā)送緩沖區(qū) */
int iflags; /* 該連接的狀態(tài) */
} connection_t;
服務(wù)器循環(huán)的處理所有連接,是一個(gè)死循環(huán)過(guò)程,每次循環(huán)都用select檢查是否有新連接到達(dá),然后循環(huán)所有連接,看哪個(gè)連接可以寫或者可以讀,就處理該連接的讀寫。由于所有的處理都是非阻塞的,所以所有的Socket IO都可以用一個(gè)線程來(lái)完成。
由于網(wǎng)絡(luò)傳輸?shù)年P(guān)系,每次recv()到的數(shù)據(jù)可能不止包含一條消息,或者不到一條消息,那么怎么處理呢?所以對(duì)于接收消息緩沖用了兩個(gè)指針,每次接收都從text_start開(kāi)始讀起,因?yàn)槔锩鏆埩舻目赡苁巧洗谓邮盏降亩嘤嗟陌霔l消息,然后text_end指向消息緩沖的結(jié)尾。這樣用兩個(gè)指針就可以很方便的處理這種情況,另外有一點(diǎn)值得注意的是:解析消息的過(guò)程是一個(gè)循環(huán)的過(guò)程,可能一次接收到兩條以上的消息在消息緩沖里面,這個(gè)時(shí)候就應(yīng)該執(zhí)行到消息緩沖里面只有一條都不到的消息為止,大體流程如下:
while ( text_end – text_start > 一條完整的消息長(zhǎng)度 )
{
從text_start處開(kāi)始處理;
text_start += 該消息長(zhǎng)度;
}
memcpy ( text, text + text_start, text_end – text_start );
對(duì)于消息的處理,這里首先就需要知道你的游戲總共有哪些消息,所有的消息都有哪些,才能設(shè)計(jì)出比較合理的消息頭。一般來(lái)說(shuō),消息大概可分為主角消息,場(chǎng)景消息,同步消息和界面消息四個(gè)部分。其中主角消息包括客戶端所控制的角色的所有動(dòng)作,包括走路,跑步,戰(zhàn)斗之類的。場(chǎng)景消息包括天氣變化,一定的時(shí)間在場(chǎng)景里出現(xiàn)一些東西等等之類的,這類消息的特點(diǎn)是所有消息的發(fā)起者都是服務(wù)器,廣播對(duì)象則是場(chǎng)景里的所有玩家。而同步消息則是針對(duì)發(fā)起對(duì)象是某個(gè)玩家,經(jīng)過(guò)服務(wù)器廣播給所有看得見(jiàn)他的玩家,該消息也是包括所有的動(dòng)作,和主角消息不同的是該種消息是服務(wù)器廣播給客戶端的,而主角消息一般是客戶端主動(dòng)發(fā)給服務(wù)器的。最后是界面消息,界面消息包括是服務(wù)器發(fā)給客戶端的聊天消息和各種屬性及狀態(tài)信息。
下面來(lái)談?wù)勏⒌慕M成。一般來(lái)說(shuō),一個(gè)消息由消息頭和消息體兩部分組成,其中消息頭的長(zhǎng)度是不變的,而消息體的長(zhǎng)度是可變的,在消息體中需要保存消息體的長(zhǎng)度。由于要給每條消息一個(gè)很明顯的區(qū)分,所以需要定義一個(gè)消息頭特有的標(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)就在于如何計(jì)算出廣播的對(duì)象。很顯然,在一張很大的地圖里面,某個(gè)玩家在最東邊的一個(gè)動(dòng)作,一個(gè)在最西邊的玩家是應(yīng)該看不到的,那么怎么來(lái)計(jì)算廣播的對(duì)象呢?最簡(jiǎn)單的辦法,就是把地圖分塊,分成大小合適的小塊,然后每次只象周圍幾個(gè)小塊的玩家進(jìn)行廣播。那么究竟切到多大比較合適呢?一般來(lái)說(shuō),切得塊大了,內(nèi)存的消耗會(huì)增大,切得塊小了,CPU的消耗會(huì)增大(原因會(huì)在后面提到)。個(gè)人覺(jué)得切成一屏左右的小塊比較合適,每次廣播廣播周圍九個(gè)小塊的玩家,由于廣播的操作非常頻繁,那么遍利周圍九塊的操作就會(huì)變得相當(dāng)?shù)念l繁,所以如果塊分得小了,那么遍利的范圍就會(huì)擴(kuò)大,CPU的資源會(huì)很快的被吃完。
切好塊以后,怎么讓玩家在各個(gè)塊之間走來(lái)走去呢?讓我們來(lái)想想在切換一次塊的時(shí)候要做哪些工作。首先,要算出下個(gè)塊的周圍九塊的玩家有哪些是現(xiàn)在當(dāng)前塊沒(méi)有的,把自己的信息廣播給那些玩家,同時(shí)也要算出下個(gè)塊周圍九塊里面有哪些物件是現(xiàn)在沒(méi)有的,把那些物件的信息廣播給自己,然后把下個(gè)塊的周圍九快里沒(méi)有的,而現(xiàn)在的塊周圍九塊里面有的物件的消失信息廣播給自己,同時(shí)也把自己消失的消息廣播給那些物件。這個(gè)操作不僅煩瑣而且會(huì)吃掉不少CPU資源,那么有什么辦法可以很快的算出這些物件呢?一個(gè)個(gè)做比較?顯然看起來(lái)就不是個(gè)好辦法,這里可以參照二維矩陣碰撞檢測(cè)的一些思路,以自己周圍九塊為一個(gè)矩陣,目標(biāo)塊周圍九塊為另一個(gè)矩陣,檢測(cè)這兩個(gè)矩陣是否碰撞,如果兩個(gè)矩陣相交,那么沒(méi)相交的那些塊怎么算。這里可以把相交的塊的坐標(biāo)轉(zhuǎn)換成內(nèi)部坐標(biāo),然后再進(jìn)行運(yùn)算。
對(duì)于廣播還有另外一種解決方法,實(shí)施起來(lái)不如切塊來(lái)的簡(jiǎn)單,這種方法需要客戶端來(lái)協(xié)助進(jìn)行運(yùn)算。首先在服務(wù)器端的連接結(jié)構(gòu)里面需要增加一個(gè)廣播對(duì)象的隊(duì)列,該隊(duì)列在客戶端登陸服務(wù)器的時(shí)候由服務(wù)器傳給客戶端,然后客戶端自己來(lái)維護(hù)這個(gè)隊(duì)列,當(dāng)有人走出客戶端視野的時(shí)候,由客戶端主動(dòng)要求服務(wù)器給那個(gè)物件發(fā)送消失的消息。而對(duì)于有人總進(jìn)視野的情況,則比較麻煩了。
首先需要客戶端在每次給服務(wù)器發(fā)送update position的消息的時(shí)候,服務(wù)器都給該連接算出一個(gè)視野范圍,然后在需要廣播的時(shí)候,循環(huán)整張地圖上的玩家,找到坐標(biāo)在其視野范圍內(nèi)的玩家。使用這種方法的好處在于不存在轉(zhuǎn)換塊的時(shí)候需要一次性廣播大量的消息,缺點(diǎn)就是在計(jì)算廣播對(duì)象的時(shí)候需要遍歷整個(gè)地圖上的玩家,如果當(dāng)一個(gè)地圖上的玩家多得比較離譜的時(shí)候,該操作就會(huì)比較的慢。
服務(wù)器的同步
同步在網(wǎng)絡(luò)游戲中是非常重要的,它保證了每個(gè)玩家在屏幕上看到的東西大體是一樣的。其實(shí)呢,解決同步問(wèn)題的最簡(jiǎn)單的方法就是把每個(gè)玩家的動(dòng)作都向其他玩家廣播一遍,這里其實(shí)就存在兩個(gè)問(wèn)題:1,向哪些玩家廣播,廣播哪些消息。2,如果網(wǎng)絡(luò)延遲怎么辦。事實(shí)上呢,第一個(gè)問(wèn)題是個(gè)非常簡(jiǎn)單的問(wèn)題,不過(guò)之所以我提出這個(gè)問(wèn)題來(lái),是提醒大家在設(shè)計(jì)自己的消息結(jié)構(gòu)的時(shí)候,需要把這個(gè)因素考慮進(jìn)去。而對(duì)于第二個(gè)問(wèn)題,則是一個(gè)挺麻煩的問(wèn)題,大家可以來(lái)看這么個(gè)例子:
比如有一個(gè)玩家A向服務(wù)器發(fā)了條指令,說(shuō)我現(xiàn)在在P1點(diǎn),要去P2點(diǎn)。指令發(fā)出的時(shí)間是T0,服務(wù)器收到指令的時(shí)間是T1,然后向周圍的玩家廣播這條消息,消息的內(nèi)容是“玩家A從P1到P2”有一個(gè)在A附近的玩家B,收到服務(wù)器的這則廣播的消息的時(shí)間是T2,然后開(kāi)始在客戶端上畫圖,A從P1到P2點(diǎn)。這個(gè)時(shí)候就存在一個(gè)不同步的問(wèn)題,玩家A和玩家B的屏幕上顯示的畫面相差了T2-T1的時(shí)間。這個(gè)時(shí)候怎么辦呢?
有個(gè)解決方案,我給它取名叫 預(yù)測(cè)拉扯,雖然有些怪異了點(diǎn),不過(guò)基本上大家也能從字面上來(lái)理解它的意思。要解決這個(gè)問(wèn)題,首先要定義一個(gè)值叫:預(yù)測(cè)誤差。然后需要在服務(wù)器端每個(gè)玩家連接的類里面加一項(xiàng)屬性,叫l(wèi)atency,然后在玩家登陸的時(shí)候,對(duì)客戶端的時(shí)間和服務(wù)器的時(shí)間進(jìn)行比較,得出來(lái)的差值保存在latency里面。還是上面的那個(gè)例子,服務(wù)器廣播消息的時(shí)候,就根據(jù)要廣播對(duì)象的latency,計(jì)算出一個(gè)客戶端的CurrentTime,然后在消息頭里面包含這個(gè)CurrentTime,然后再進(jìn)行廣播。并且同時(shí)在玩家A的客戶端本地建立一個(gè)隊(duì)列,保存該條消息,只到獲得服務(wù)器驗(yàn)證就從未被驗(yàn)證的消息隊(duì)列里面將該消息刪除,如果驗(yàn)證失敗,則會(huì)被拉扯回P1點(diǎn)。然后當(dāng)玩家B收到了服務(wù)器發(fā)過(guò)來(lái)的消息“玩家A從P1到P2”這個(gè)時(shí)候就檢查消息里面服務(wù)器發(fā)出的時(shí)間和本地時(shí)間做比較,如果大于定義的預(yù)測(cè)誤差,就算出在T2這個(gè)時(shí)間,玩家A的屏幕上走到的地點(diǎn)P3,然后把玩家B屏幕上的玩家A直接拉扯到P3,再繼續(xù)走下去,這樣就能保證同步。更進(jìn)一步,為了保證客戶端運(yùn)行起來(lái)更加smooth,我并不推薦直接把玩家拉扯過(guò)去,而是算出P3偏后的一點(diǎn)P4,然后用(P4-P1)/T(P4-P3)來(lái)算出一個(gè)很快的速度S,然后讓玩家A用速度S快速移動(dòng)到P4,這樣的處理方法是比較合理的,這種解決方案的原形在國(guó)際上被稱為(Full plesiochronous),當(dāng)然,該原形被我篡改了很多來(lái)適應(yīng)網(wǎng)絡(luò)游戲的同步,所以而變成所謂的:預(yù)測(cè)拉扯。
另外一個(gè)解決方案,我給它取名叫 驗(yàn)證同步,聽(tīng)名字也知道,大體的意思就是每條指令在經(jīng)過(guò)服務(wù)器驗(yàn)證通過(guò)了以后再執(zhí)行動(dòng)作。具體的思路如下:首先也需要在每個(gè)玩家連接類型里面定義一個(gè)latency,然后在客戶端響應(yīng)玩家鼠標(biāo)行走的同時(shí),客戶端并不會(huì)先行走動(dòng),而是發(fā)一條走路的指令給服務(wù)器,然后等待服務(wù)器的驗(yàn)證。服務(wù)器接受到這條消息以后,進(jìn)行邏輯層的驗(yàn)證,然后計(jì)算出需要廣播的范圍,包括玩家A在內(nèi),根據(jù)各個(gè)客戶端不同的latency生成不同的消息頭,開(kāi)始廣播,這個(gè)時(shí)候這個(gè)玩家的走路信息就是完全同步的了。這個(gè)方法的優(yōu)點(diǎn)是能保證各個(gè)客戶端之間絕對(duì)的同步,缺點(diǎn)是當(dāng)網(wǎng)絡(luò)延遲比較大的時(shí)候,玩家的客戶端的行為會(huì)變得比較不流暢,給玩家?guī)?lái)很不爽的感覺(jué)。該種解決方案的原形在國(guó)際上被稱為(Hierarchical master-slave synchronization),80年代以后被廣泛應(yīng)用于網(wǎng)絡(luò)的各個(gè)領(lǐng)域。
最后一種解決方案是一種理想化的解決方案,在國(guó)際上被稱為Mutual synchronization,是一種對(duì)未來(lái)網(wǎng)絡(luò)的前景的良好預(yù)測(cè)出來(lái)的解決方案。這里之所以要提這個(gè)方案,并不是說(shuō)我們已經(jīng)完全的實(shí)現(xiàn)了這種方案,而只是在網(wǎng)絡(luò)游戲領(lǐng)域的某些方面應(yīng)用到這種方案的某些思想。我對(duì)該種方案取名為:半服務(wù)器同步。大體的設(shè)計(jì)思路如下:
首先客戶端需要在登陸世界的時(shí)候建立很多張廣播列表,這些列表在客戶端后臺(tái)和服務(wù)器要進(jìn)行不及時(shí)同步,之所以要建立多張列表,是因?yàn)橐獜V播的類型是不止一種的,比如說(shuō)有l(wèi)ocal message,有remote message,還有g(shù)lobal message 等等,這些列表都需要在客戶端登陸的時(shí)候根據(jù)服務(wù)器發(fā)過(guò)來(lái)的消息建立好。在建立列表的同時(shí),還需要獲得每個(gè)列表中廣播對(duì)象的latency,并且要維護(hù)一張完整的用戶狀態(tài)列表在后臺(tái),也是不及時(shí)的和服務(wù)器進(jìn)行同步,根據(jù)本地的用戶狀態(tài)表,可以做到一部分決策由客戶端自己來(lái)決定,當(dāng)客戶端發(fā)送這部分決策的時(shí)候,則直接將最終決策發(fā)送到各個(gè)廣播列表里面的客戶端,并對(duì)其時(shí)間進(jìn)行校對(duì),保證每個(gè)客戶端在收到的消息的時(shí)間是和根據(jù)本地時(shí)間進(jìn)行校對(duì)過(guò)的。那么再采用預(yù)測(cè)拉扯中提到過(guò)的計(jì)算提前量,提高速度行走過(guò)去的方法,將會(huì)使同步變得非常的smooth。該方案的優(yōu)點(diǎn)是不通過(guò)服務(wù)器,客戶端自己之間進(jìn)行同步,大大的降低了由于網(wǎng)絡(luò)延遲而帶來(lái)的誤差,并且由于大部分決策都可以由客戶端來(lái)做,也大大的降低了服務(wù)器的資源。由此帶來(lái)的弊端就是由于消息和決策權(quán)都放在客戶端本地,所以給外掛提供了很大的可乘之機(jī)。
NPC問(wèn)題
下面我想來(lái)談?wù)勱P(guān)于服務(wù)器上NPC的設(shè)計(jì)以及NPC智能等一些方面涉及到的問(wèn)題。首先,我們需要知道什么是NPC,NPC需要做什么。NPC的全稱是(Non-Player Character),很顯然,他是一個(gè)character,但不是玩家,那么從這點(diǎn)上可以知道,NPC的某些行為是和玩家類似的,他可以行走,可以戰(zhàn)斗,可以呼吸(這點(diǎn)將在后面的NPC智能里面提到),另外一點(diǎn)和玩家物件不同的是,NPC可以復(fù)生(即NPC被打死以后在一定時(shí)間內(nèi)可以重新出來(lái))。其實(shí)還有最重要的一點(diǎn),就是玩家物件的所有決策都是玩家做出來(lái)的,而NPC的決策則是由計(jì)算機(jī)做出來(lái)的,所以在對(duì)NPC做何種決策的時(shí)候,需要所謂的NPC智能來(lái)進(jìn)行決策。
下面我將分兩個(gè)部分來(lái)談?wù)凬PC,首先是NPC智能,其次是服務(wù)器如何對(duì)NPC進(jìn)行組織。之所以要先談NPC智能是因?yàn)橹挥挟?dāng)我們了解清楚我們需要NPC做什么之后,才好開(kāi)始設(shè)計(jì)服務(wù)器來(lái)對(duì)NPC進(jìn)行組織。
NPC智能
NPC智能分為兩種,一種是被動(dòng)觸發(fā)的事件,一種是主動(dòng)觸發(fā)的事件。對(duì)于被動(dòng)觸發(fā)的事件,處理起來(lái)相對(duì)來(lái)說(shuō)簡(jiǎn)單一些,可以由事件本身來(lái)呼叫NPC身上的函數(shù),比如說(shuō)NPC的死亡,實(shí)際上是在NPC的HP小于一定值的時(shí)候,來(lái)主動(dòng)呼叫NPC身上的OnDie() 函數(shù),這種由事件來(lái)觸發(fā)NPC行為的NPC智能,我稱為被動(dòng)觸發(fā)。這種類型的觸發(fā)往往分為兩種:
一種是由別的物件導(dǎo)致的NPC的屬性變化,然后屬性變化的同時(shí)會(huì)導(dǎo)致NPC產(chǎn)生一些行為。由此一來(lái),NPC物件里面至少包含以下幾種函數(shù):
class NPC {
public:
// 是誰(shuí)在什么地方導(dǎo)致了我哪項(xiàng)屬性改變了多少。
OnChangeAttribute(object_t who, int which, int how, int where);
Private:
OnDie();
OnEscape();
OnFollow();
OnSleep();
// 一系列的事件。
}
這是一個(gè)基本的NPC的結(jié)構(gòu),這種被動(dòng)的觸發(fā)NPC的事件,我稱它為NPC的反射。但是,這樣的結(jié)構(gòu)只能讓NPC被動(dòng)的接收一些信息來(lái)做出決策,這樣的NPC是愚蠢的。那么,怎么樣讓一個(gè)NPC能夠主動(dòng)的做出一些決策呢?這里有一種方法:呼吸。那么怎么樣讓NPC有呼吸呢?
一種很簡(jiǎn)單的方法,用一個(gè)計(jì)時(shí)器,定時(shí)的觸發(fā)所有NPC的呼吸,這樣就可以讓一個(gè)NPC有呼吸起來(lái)。這樣的話會(huì)有一個(gè)問(wèn)題,當(dāng)NPC太多的時(shí)候,上一次NPC的呼吸還沒(méi)有呼吸完,下一次呼吸又來(lái)了,那么怎么解決這個(gè)問(wèn)題呢。這里有一種方法,讓NPC異步的進(jìn)行呼吸,即每個(gè)NPC的呼吸周期是根據(jù)NPC出生的時(shí)間來(lái)定的,這個(gè)時(shí)候計(jì)時(shí)器需要做的就是隔一段時(shí)間檢查一下,哪些NPC到時(shí)間該呼吸了,就來(lái)觸發(fā)這些NPC的呼吸。
上面提到的是系統(tǒng)如何來(lái)觸發(fā)NPC的呼吸,那么NPC本身的呼吸頻率該如何設(shè)定呢?這個(gè)就好象現(xiàn)實(shí)中的人一樣,睡覺(jué)的時(shí)候和進(jìn)行激烈運(yùn)動(dòng)的時(shí)候,呼吸頻率是不一樣的。同樣,NPC在戰(zhàn)斗的時(shí)候,和平常的時(shí)候,呼吸頻率也不一樣。那么就需要一個(gè)Breath_Ticker來(lái)設(shè)置NPC當(dāng)前的呼吸頻率。
那么在NPC的呼吸事件里面,我們?cè)趺礃觼?lái)設(shè)置NPC的智能呢?大體可以概括為檢查環(huán)境和做出決策兩個(gè)部分。首先,需要對(duì)當(dāng)前環(huán)境進(jìn)行數(shù)字上的統(tǒng)計(jì),比如說(shuō)是否在戰(zhàn)斗中,戰(zhàn)斗有幾個(gè)敵人,自己的HP還剩多少,以及附近有沒(méi)有敵人等等之類的統(tǒng)計(jì)。統(tǒng)計(jì)出來(lái)的數(shù)據(jù)傳入本身的決策模塊,決策模塊則根據(jù)NPC自身的性格取向來(lái)做出一些決策,比如說(shuō)野蠻型的NPC會(huì)在HP比較少的時(shí)候仍然猛撲猛打,又比如說(shuō)智慧型的NPC則會(huì)在HP比較少的時(shí)候選擇逃跑。等等之類的。
至此,一個(gè)可以呼吸,反射的NPC的結(jié)構(gòu)已經(jīng)基本構(gòu)成了,那么接下來(lái)我們就來(lái)談?wù)勏到y(tǒng)如何組織讓一個(gè)NPC出現(xiàn)在世界里面。
NPC的組織
這里有兩種方案可供選擇,其一:NPC的位置信息保存在場(chǎng)景里面,載入場(chǎng)景的時(shí)候載入NPC。其二,NPC的位置信息保存在NPC身上,有專門的事件讓所有的NPC登陸場(chǎng)景。這兩種方法有什么區(qū)別呢?又各有什么好壞呢?
前一種方法好處在于場(chǎng)景載入的時(shí)候同時(shí)載入了NPC,場(chǎng)景就可以對(duì)NPC進(jìn)行管理,不需要多余的處理,而弊端則在于在刷新的時(shí)候是同步刷新的,也就是說(shuō)一個(gè)場(chǎng)景里面的NPC可能會(huì)在同一時(shí)間內(nèi)長(zhǎng)出來(lái)。而對(duì)于第二種方法呢,設(shè)計(jì)起來(lái)會(huì)稍微麻煩一些,需要一個(gè)統(tǒng)一的機(jī)制讓NPC登陸到場(chǎng)景,還需要一些比較麻煩的設(shè)計(jì),但是這種方案可以實(shí)現(xiàn)NPC異步的刷新,是目前網(wǎng)絡(luò)游戲普遍采用的方法,下面我們就來(lái)著重談?wù)勥@種方法的實(shí)現(xiàn):
首先我們要引入一個(gè)“靈魂”的概念,即一個(gè)NPC在死后,消失的只是他的肉體,他的靈魂仍然在世界中存在著,沒(méi)有呼吸,在死亡的附近漂浮,等著到時(shí)間投胎,投胎的時(shí)候把之前的所有屬性清零,重新在場(chǎng)景上構(gòu)建其肉體。那么,我們?cè)趺磥?lái)設(shè)計(jì)這樣一個(gè)結(jié)構(gòu)呢?首先把一個(gè)場(chǎng)景里面要出現(xiàn)的NPC制作成圖量表,給每個(gè)NPC一個(gè)獨(dú)一無(wú)二的標(biāo)識(shí)符,在載入場(chǎng)景之后,根據(jù)圖量表來(lái)載入屬于該場(chǎng)景的NPC。在NPC的OnDie() 事件里面不直接把該物件destroy 掉,而是關(guān)閉NPC的呼吸,然后打開(kāi)一個(gè)重生的計(jì)時(shí)器,最后把該物件設(shè)置為invisable。這樣的設(shè)計(jì),可以實(shí)現(xiàn)NPC的異步刷新,在節(jié)省服務(wù)器資源的同時(shí)也讓玩家覺(jué)得更加的真實(shí)。
補(bǔ)充的談?wù)剢l(fā)式搜索(heuristic searching)在NPC智能中的應(yīng)用。
其主要思路是在廣度優(yōu)先搜索的同時(shí),將下一層的所有節(jié)點(diǎn)經(jīng)過(guò)一個(gè)啟發(fā)函數(shù)進(jìn)行過(guò)濾,一定范圍內(nèi)縮小搜索范圍。眾所周知的尋路A算法就是典型的啟發(fā)式搜索的應(yīng)用,其原理是一開(kāi)始設(shè)計(jì)一個(gè)Judge(point_t* point)函數(shù),來(lái)獲得point這個(gè)一點(diǎn)的代價(jià),然后每次搜索的時(shí)候把下一步可能到達(dá)的所有點(diǎn)都經(jīng)過(guò)Judge()函數(shù)評(píng)價(jià)一下,獲取兩到三個(gè)代價(jià)比較小的點(diǎn),繼續(xù)搜索,那些沒(méi)被選上的點(diǎn)就不會(huì)在繼續(xù)搜索下去了,這樣帶來(lái)的后果的是可能求出來(lái)的不是最優(yōu)路徑,這也是為什么A算法在尋路的時(shí)候會(huì)走到障礙物前面再繞過(guò)去,而不是預(yù)先就走斜線來(lái)繞過(guò)該障礙物。如果要尋出最優(yōu)化的路徑的話,是不能用A算法的,而是要用動(dòng)態(tài)規(guī)劃的方法,其消耗是遠(yuǎn)大于A的。
那么,除了在尋路之外,還有哪些地方可以應(yīng)用到啟發(fā)式搜索呢?其實(shí)說(shuō)得大一點(diǎn),NPC的任何決策都可以用啟發(fā)式搜索來(lái)做,比如說(shuō)逃跑吧,如果是一個(gè)2D的網(wǎng)絡(luò)游戲,有八個(gè)方向,NPC選擇哪個(gè)方向逃跑呢?就可以設(shè)置一個(gè)Judge(int direction)來(lái)給定每個(gè)點(diǎn)的代價(jià),在Judge里面算上該點(diǎn)的敵人的強(qiáng)弱,或者該敵人的敏捷如何等等,最后選擇代價(jià)最小的地方逃跑。下面,我們就來(lái)談?wù)剬?duì)于幾種NPC常見(jiàn)的智能的啟發(fā)式搜索法的設(shè)計(jì):
Target select (選擇目標(biāo)):
首先獲得地圖上離該NPC附近的敵人列表。設(shè)計(jì)Judge() 函數(shù),根據(jù)敵人的強(qiáng)弱,敵人的遠(yuǎn)近,算出代價(jià)。然后選擇代價(jià)最小的敵人進(jìn)行主動(dòng)攻擊。
Escape(逃跑):
在呼吸事件里面檢查自己的HP,如果HP低于某個(gè)值的時(shí)候,或者如果你是遠(yuǎn)程兵種,而敵人近身的話,則觸發(fā)逃跑函數(shù),在逃跑函數(shù)里面也是對(duì)周圍的所有的敵人組織成列表,然后設(shè)計(jì)Judge() 函數(shù),先選擇出對(duì)你構(gòu)成威脅最大的敵人,該Judge() 函數(shù)需要判斷敵人的速度,戰(zhàn)斗力強(qiáng)弱,最后得出一個(gè)主要敵人,然后針對(duì)該主要敵人進(jìn)行路徑的Judge() 的函數(shù)的設(shè)計(jì),搜索的范圍只可能是和主要敵人相反的方向,然后再根據(jù)該幾個(gè)方向的敵人的強(qiáng)弱來(lái)計(jì)算代價(jià),做出最后的選擇。
Random walk(隨機(jī)走路):
這個(gè)我并不推薦用A算法,因?yàn)镹PC一旦多起來(lái),那么這個(gè)對(duì)CPU的消耗是很恐怖的,而且NPC大多不需要長(zhǎng)距離的尋路,只需要在附近走走即可,那么,就在附近隨機(jī)的給幾個(gè)點(diǎn),然后讓NPC走過(guò)去,如果碰到障礙物就停下來(lái),這樣幾乎無(wú)任何負(fù)擔(dān)。
Follow Target(追隨目標(biāo)):
這里有兩種方法,一種方法NPC看上去比較愚蠢,一種方法看上去NPC比較聰明,第一種方法就是讓NPC跟著目標(biāo)的路點(diǎn)走即可,幾乎沒(méi)有資源消耗。而后一種則是讓NPC在跟隨的時(shí)候,在呼吸事件里面判斷對(duì)方的當(dāng)前位置,然后走直線,碰上障礙物了用A*繞過(guò)去,該種設(shè)計(jì)會(huì)消耗一定量的系統(tǒng)資源,所以不推薦NPC大量的追隨目標(biāo),如果需要大量的NPC追隨目標(biāo)的話,還有一個(gè)比較簡(jiǎn)單的方法:讓NPC和目標(biāo)同步移動(dòng),即讓他們的速度統(tǒng)一,移動(dòng)的時(shí)候走同樣的路點(diǎn),當(dāng)然,這種設(shè)計(jì)只適合NPC所跟隨的目標(biāo)不是追殺的關(guān)系,只是跟隨著玩家走而已了。