大多數的網絡遊戲的服務器都會選擇非阻塞select這種結構,爲何呢?由於網絡遊戲的服務器須要處理的鏈接很是之多,而且大部分會選擇在Linux/Unix下運行,那麼爲每一個用戶開一個線程其實是很不划算的,一方面由於在Linux/Unix下的線程是用進程這麼一個概念模擬出來的,比較消耗系統資源,另外除了I/O以外,每一個線程基本上沒有什麼多餘的須要並行的任務,並且網絡遊戲是互交性很是強的,因此線程間的同步會成爲很麻煩的問題。由此一來,對於這種含有大量網絡鏈接的單線程服務器,用阻塞顯然是不現實的。對於網絡鏈接,須要用一個結構來儲存,其中須要包含一個向客戶端寫消息的緩衝,還須要一個從客戶端讀消息的緩衝,具體的大小根據具體的消息結構來定了。另外對於同步,須要一些時間校對的值,還須要一些各類不一樣的值來記錄當前狀態,下面給出一個初步的鏈接的結構:
typedef connection_s {
user_t *ob; /* 指向處理服務器端邏輯的結構 */
int fd; /* socket鏈接 */
struct sockaddr_in addr; /* 鏈接的地址信息 */
char text[MAX_TEXT]; /* 接收的消息緩衝 */
int text_end; /* 接收消息緩衝的尾指針 */
int text_start; /* 接收消息緩衝的頭指針 */
int last_time; /* 上一條消息是何時接收到的 */
struct timeval latency; /* 客戶端本地時間和服務器本地時間的差值 */
struct timeval last_confirm_time; /* 上一次驗證的時間 */
short is_confirmed; /* 該鏈接是否經過驗證過 */
int ping_num; /* 該客戶端到服務器端的ping值 */
int ping_ticker; /* 多少個IO週期處理更新一次ping值 */
int message_length; /* 發送緩衝消息長度 */
char message_buf[MAX_TEXT]; /* 發送緩衝區 */
int iflags; /* 該鏈接的狀態 */
} connection_t;
服務器循環的處理全部鏈接,是一個死循環過程,每次循環都用select檢查是否有新鏈接到達,而後循環全部鏈接,看哪一個鏈接能夠寫或者能夠讀,就處理該鏈接的讀寫。因爲全部的處理都是非阻塞的,因此全部的Socket IO均可以用一個線程來完成。
因爲網絡傳輸的關係,每次recv()到的數據可能不止包含一條消息,或者不到一條消息,那麼怎麼處理呢?因此對於接收消息緩衝用了兩個指針,每次接收都從text_start開始讀起,由於裏面殘留的多是上次接收到的多餘的半條消息,而後text_end指向消息緩衝的結尾。這樣用兩個指針就能夠很方便的處理這種狀況,另外有一點值得注意的是:解析消息的過程是一個循環的過程,可能一次接收到兩條以上的消息在消息緩衝裏面,這個時候就應該執行到消息緩衝裏面只有一條都不到的消息爲止,大致流程以下:
while ( text_end – text_start > 一條完整的消息長度 )
{
從text_start處開始處理;
text_start += 該消息長度;
}
memcpy ( text, text + text_start, text_end – text_start );
對於消息的處理,這裏首先就須要知道你的遊戲總共有哪些消息,全部的消息都有哪些,才能設計出比較合理的消息頭。通常來講,消息大概可分爲主角消息,場景消息,同步消息和界面消息四個部分。其中主角消息包括客戶端所控制的角色的全部動做,包括走路,跑步,戰鬥之類的。場景消息包括天氣變化,必定的時間在場景裏出現一些東西等等之類的,這類消息的特色是全部消息的發起者都是服務器,廣播對象則是場景裏的全部玩家。而同步消息則是針對發起對象是某個玩家,通過服務器廣播給全部看得見他的玩家,該消息也是包括全部的動做,和主角消息不一樣的是該種消息是服務器廣播給客戶端的,而主角消息通常是客戶端主動發給服務器的。最後是界面消息,界面消息包括是服務器發給客戶端的聊天消息和各類屬性及狀態信息。
下面來談談消息的組成。通常來講,一個消息由消息頭和消息體兩部分組成,其中消息頭的長度是不變的,而消息體的長度是可變的,在消息體中須要保存消息體的長度。因爲要給每條消息一個很明顯的區分,因此須要定義一個消息頭特有的標誌,而後須要消息的類型以及消息ID。消息頭大致結構以下:
type struct message_s {
unsigned short message_sign;
unsigned char message_type;
unsigned short message_id
unsigned char message_len
}message_t;
2 服務器的廣播
服務器的廣播的重點就在於如何計算出廣播的對象。很顯然,在一張很大的地圖裏面,某個玩家在最東邊的一個動做,一個在最西邊的玩家是應該看不到的,那麼怎麼來計算廣播的對象呢?最簡單的辦法,就是把地圖分塊,分紅大小合適的小塊,而後每次只象周圍幾個小塊的玩家進行廣播。那麼究竟切到多大比較合適呢?通常來講,切得塊大了,內存的消耗會增大,切得塊小了,CPU的消耗會增大(緣由會在後面提到)。我的以爲切成一屏左右的小塊比較合適,每次廣播廣播周圍九個小塊的玩家,因爲廣播的操做很是頻繁,那麼遍利周圍九塊的操做就會變得至關的頻繁,因此若是塊分得小了,那麼遍利的範圍就會擴大,CPU的資源會很快的被吃完。
切好塊之後,怎麼讓玩家在各個塊之間走來走去呢?讓咱們來想一想在切換一次塊的時候要作哪些工做。首先,要算出下個塊的周圍九塊的玩家有哪些是如今當前塊沒有的,把本身的信息廣播給那些玩家,同時也要算出下個塊周圍九塊裏面有哪些物件是如今沒有的,把那些物件的信息廣播給本身,而後把下個塊的周圍九快裏沒有的,而如今的塊周圍九塊裏面有的物件的消失信息廣播給本身,同時也把本身消失的消息廣播給那些物件。這個操做不只煩瑣並且會吃掉很多CPU資源,那麼有什麼辦法能夠很快的算出這些物件呢?一個個作比較?顯然看起來就不是個好辦法,這裏能夠參照二維矩陣碰撞檢測的一些思路,以本身周圍九塊爲一個矩陣,目標塊周圍九塊爲另外一個矩陣,檢測這兩個矩陣是否碰撞,若是兩個矩陣相交,那麼沒相交的那些塊怎麼算。這裏能夠把相交的塊的座標轉換成內部座標,而後再進行運算。
對於廣播還有另一種解決方法,實施起來不如切塊來的簡單,這種方法須要客戶端來協助進行運算。首先在服務器端的鏈接結構裏面須要增長一個廣播對象的隊列,該隊列在客戶端登錄服務器的時候由服務器傳給客戶端,而後客戶端本身來維護這個隊列,當有人走出客戶端視野的時候,由客戶端主動要求服務器給那個物件發送消失的消息。而對於有人總進視野的狀況,則比較麻煩了。
首先須要客戶端在每次給服務器發送update position的消息的時候,服務器都給該鏈接算出一個視野範圍,而後在須要廣播的時候,循環整張地圖上的玩家,找到座標在其視野範圍內的玩家。使用這種方法的好處在於不存在轉換塊的時候須要一次性廣播大量的消息,缺點就是在計算廣播對象的時候須要遍歷整個地圖上的玩家,若是當一個地圖上的玩家多得比較離譜的時候,該操做就會比較的慢。
3 服務器的同步
同步在網絡遊戲中是很是重要的,它保證了每一個玩家在屏幕上看到的東西大致是同樣的。其實呢,解決同步問題的最簡單的方法就是把每一個玩家的動做都向其餘玩家廣播一遍,這裏其實就存在兩個問題:1,向哪些玩家廣播,廣播哪些消息。2,若是網絡延遲怎麼辦。事實上呢,第一個問題是個很是簡單的問題,不過之因此我提出這個問題來,是提醒你們在設計本身的消息結構的時候,須要把這個因素考慮進去。而對於第二個問題,則是一個挺麻煩的問題,你們能夠來看這麼個例子:
好比有一個玩家A向服務器發了條指令,說我如今在P1點,要去P2點。指令發出的時間是T0,服務器收到指令的時間是T1,而後向周圍的玩家廣播這條消息,消息的內容是「玩家A從P1到P2」有一個在A附近的玩家B,收到服務器的這則廣播的消息的時間是T2,而後開始在客戶端上畫圖,A從P1到P2點。這個時候就存在一個不一樣步的問題,玩家A和玩家B的屏幕上顯示的畫面相差了T2-T1的時間。這個時候怎麼辦呢?
有個解決方案,我給它取名叫 預測拉扯,雖然有些怪異了點,不過基本上你們也能從字面上來理解它的意思。要解決這個問題,首先要定義一個值叫:預測偏差。而後須要在服務器端每一個玩家鏈接的類裏面加一項屬性,叫latency,而後在玩家登錄的時候,對客戶端的時間和服務器的時間進行比較,得出來的差值保存在latency裏面。仍是上面的那個例子,服務器廣播消息的時候,就根據要廣播對象的latency,計算出一個客戶端的CurrentTime,而後在消息頭裏麪包含這個CurrentTime,而後再進行廣播。而且同時在玩家A的客戶端本地創建一個隊列,保存該條消息,只到得到服務器驗證就從未被驗證的消息隊列裏面將該消息刪除,若是驗證失敗,則會被拉扯回P1點。而後當玩家B收到了服務器發過來的消息「玩家A從P1到P2」這個時候就檢查消息裏面服務器發出的時間和本地時間作比較,若是大於定義的預測偏差,就算出在T2這個時間,玩家A的屏幕上走到的地點P3,而後把玩家B屏幕上的玩家A直接拉扯到P3,再繼續走下去,這樣就能保證同步。更進一步,爲了保證客戶端運行起來更加smooth,我並不推薦直接把玩家拉扯過去,而是算出P3偏後的一點P4,而後用(P4-P1)/T(P4-P3)來算出一個很快的速度S,而後讓玩家A用速度S快速移動到P4,這樣的處理方法是比較合理的,這種解決方案的原形在國際上被稱爲(Full plesiochronous),固然,該原形被我篡改了不少來適應網絡遊戲的同步,因此而變成所謂的:預測拉扯。
另一個解決方案,我給它取名叫 驗證同步,聽名字也知道,大致的意思就是每條指令在通過服務器驗證經過了之後再執行動做。具體的思路以下:首先也須要在每一個玩家鏈接類型裏面定義一個latency,而後在客戶端響應玩家鼠標行走的同時,客戶端並不會先行走動,而是發一條走路的指令給服務器,而後等待服務器的驗證。服務器接受到這條消息之後,進行邏輯層的驗證,而後計算出須要廣播的範圍,包括玩家A在內,根據各個客戶端不一樣的latency生成不一樣的消息頭,開始廣播,這個時候這個玩家的走路信息就是徹底同步的了。這個方法的優勢是能保證各個客戶端之間絕對的同步,缺點是當網絡延遲比較大的時候,玩家的客戶端的行爲會變得比較不流暢,給玩家帶來很不爽的感受。該種解決方案的原形在國際上被稱爲(Hierarchical master-slave synchronization),80年代之後被普遍應用於網絡的各個領域。
最後一種解決方案是一種理想化的解決方案,在國際上被稱爲Mutual synchronization,是一種對將來網絡的前景的良好預測出來的解決方案。這裏之因此要提這個方案,並非說咱們已經徹底的實現了這種方案,而只是在網絡遊戲領域的某些方面應用到這種方案的某些思想。我對該種方案取名爲:半服務器同步。大致的設計思路以下:
首先客戶端須要在登錄世界的時候創建不少張廣播列表,這些列表在客戶端後臺和服務器要進行不及時同步,之因此要創建多張列表,是由於要廣播的類型是不止一種的,好比說有local message,有remote message,還有global message 等等,這些列表都須要在客戶端登錄的時候根據服務器發過來的消息創建好。在創建列表的同時,還須要得到每一個列表中廣播對象的latency,而且要維護一張完整的用戶狀態列表在後臺,也是不及時的和服務器進行同步,根據本地的用戶狀態表,能夠作到一部分決策由客戶端本身來決定,當客戶端發送這部分決策的時候,則直接將最終決策發送到各個廣播列表裏面的客戶端,並對其時間進行校對,保證每一個客戶端在收到的消息的時間是和根據本地時間進行校對過的。那麼再採用預測拉扯中提到過的計算提早量,提升速度行走過去的方法,將會使同步變得很是的smooth。該方案的優勢是不經過服務器,客戶端本身之間進行同步,大大的下降了因爲網絡延遲而帶來的偏差,而且因爲大部分決策均可以由客戶端來作,也大大的下降了服務器的資源。由此帶來的弊端就是因爲消息和決策權都放在客戶端本地,因此給外掛提供了很大的可乘之機。
4 NPC問題
下面我想來談談關於服務器上NPC的設計以及NPC智能等一些方面涉及到的問題。首先,咱們須要知道什麼是NPC,NPC須要作什麼。NPC的全稱是(Non-Player Character),很顯然,他是一個character,但不是玩家,那麼從這點上能夠知道,NPC的某些行爲是和玩家相似的,他能夠行走,能夠戰鬥,能夠呼吸(這點將在後面的NPC智能裏面提到),另一點和玩家物件不一樣的是,NPC能夠復生(即NPC被打死之後在必定時間內能夠從新出來)。其實還有最重要的一點,就是玩家物件的全部決策都是玩家作出來的,而NPC的決策則是由計算機作出來的,因此在對NPC作何種決策的時候,須要所謂的NPC智能來進行決策。
下面我將分兩個部分來談談NPC,首先是NPC智能,其次是服務器如何對NPC進行組織。之因此要先談NPC智能是由於只有當咱們瞭解清楚咱們須要NPC作什麼以後,纔好開始設計服務器來對NPC進行組織。
NPC智能
NPC智能分爲兩種,一種是被動觸發的事件,一種是主動觸發的事件。對於被動觸發的事件,處理起來相對來講簡單一些,能夠由事件自己來呼叫NPC身上的函數,好比說NPC的死亡,其實是在NPC的HP小於必定值的時候,來主動呼叫NPC身上的OnDie() 函數,這種由事件來觸發NPC行爲的NPC智能,我稱爲被動觸發。這種類型的觸發每每分爲兩種:
一種是由別的物件致使的NPC的屬性變化,而後屬性變化的同時會致使NPC產生一些行爲。由此一來,NPC物件裏面至少包含如下幾種函數:
class NPC {
public:
// 是誰在什麼地方致使了我哪項屬性改變了多少。
OnChangeAttribute(object_t *who, int which, int how, int where);
Private:
OnDie();
OnEscape();
OnFollow();
OnSleep();
// 一系列的事件。
}
這是一個基本的NPC的結構,這種被動的觸發NPC的事件,我稱它爲NPC的反射。可是,這樣的結構只能讓NPC被動的接收一些信息來作出決策,這樣的NPC是愚蠢的。那麼,怎麼樣讓一個NPC可以主動的作出一些決策呢?這裏有一種方法:呼吸。那麼怎麼樣讓NPC有呼吸呢?
一種很簡單的方法,用一個計時器,定時的觸發全部NPC的呼吸,這樣就可讓一個NPC有呼吸起來。這樣的話會有一個問題,當NPC太多的時候,上一次NPC的呼吸尚未呼吸完,下一次呼吸又來了,那麼怎麼解決這個問題呢。這裏有一種方法,讓NPC異步的進行呼吸,即每一個NPC的呼吸週期是根據NPC出生的時間來定的,這個時候計時器須要作的就是隔一段時間檢查一下,哪些NPC到時間該呼吸了,就來觸發這些NPC的呼吸。
上面提到的是系統如何來觸發NPC的呼吸,那麼NPC自己的呼吸頻率該如何設定呢?這個就好象現實中的人同樣,睡覺的時候和進行激烈運動的時候,呼吸頻率是不同的。一樣,NPC在戰鬥的時候,和日常的時候,呼吸頻率也不同。那麼就須要一個Breath_Ticker來設置NPC當前的呼吸頻率。
那麼在NPC的呼吸事件裏面,咱們怎麼樣來設置NPC的智能呢?大致能夠歸納爲檢查環境和作出決策兩個部分。首先,須要對當前環境進行數字上的統計,好比說是否在戰鬥中,戰鬥有幾個敵人,本身的HP還剩多少,以及附近有沒有敵人等等之類的統計。統計出來的數據傳入自己的決策模塊,決策模塊則根據NPC自身的性格取向來作出一些決策,好比說野蠻型的NPC會在HP比較少的時候仍然猛撲猛打,又好比說智慧型的NPC則會在HP比較少的時候選擇逃跑。等等之類的。
至此,一個能夠呼吸,反射的NPC的結構已經基本構成了,那麼接下來咱們就來談談系統如何組織讓一個NPC出如今世界裏面。
NPC的組織
這裏有兩種方案可供選擇,其一:NPC的位置信息保存在場景裏面,載入場景的時候載入NPC。其二,NPC的位置信息保存在NPC身上,有專門的事件讓全部的NPC登錄場景。這兩種方法有什麼區別呢?又各有什麼好壞呢?
前一種方法好處在於場景載入的時候同時載入了NPC,場景就能夠對NPC進行管理,不須要多餘的處理,而弊端則在於在刷新的時候是同步刷新的,也就是說一個場景裏面的NPC可能會在同一時間內長出來。而對於第二種方法呢,設計起來會稍微麻煩一些,須要一個統一的機制讓NPC登錄到場景,還須要一些比較麻煩的設計,可是這種方案能夠實現NPC異步的刷新,是目前網絡遊戲廣泛採用的方法,下面咱們就來着重談談這種方法的實現:
首先咱們要引入一個「靈魂」的概念,即一個NPC在死後,消失的只是他的肉體,他的靈魂仍然在世界中存在着,沒有呼吸,在死亡的附近漂浮,等着到時間投胎,投胎的時候把以前的全部屬性清零,從新在場景上構建其肉體。那麼,咱們怎麼來設計這樣一個結構呢?首先把一個場景裏面要出現的NPC製做成圖量表,給每一個NPC一個獨一無二的標識符,在載入場景以後,根據圖量表來載入屬於該場景的NPC。在NPC的OnDie() 事件裏面不直接把該物件destroy 掉,而是關閉NPC的呼吸,而後打開一個重生的計時器,最後把該物件設置爲invisable。這樣的設計,能夠實現NPC的異步刷新,在節省服務器資源的同時也讓玩家以爲更加的真實。
補充的談談啓發式搜索(heuristic searching)在NPC智能中的應用。
其主要思路是在廣度優先搜索的同時,將下一層的全部節點通過一個啓發函數進行過濾,必定範圍內縮小搜索範圍。衆所周知的尋路A*算法就是典型的啓發式搜索的應用,其原理是一開始設計一個Judge(point_t* point)函數,來得到point這個一點的代價,而後每次搜索的時候把下一步可能到達的全部點都通過Judge()函數評價一下,獲取兩到三個代價比較小的點,繼續搜索,那些沒被選上的點就不會在繼續搜索下去了,這樣帶來的後果的是可能求出來的不是最優路徑,這也是爲何A*算法在尋路的時候會走到障礙物前面再繞過去,而不是預先就走斜線來繞過該障礙物。若是要尋出最優化的路徑的話,是不能用A*算法的,而是要用動態規劃的方法,其消耗是遠大於A*的。
那麼,除了在尋路以外,還有哪些地方能夠應用到啓發式搜索呢?其實說得大一點,NPC的任何決策均可以用啓發式搜索來作,好比說逃跑吧,若是是一個2D的網絡遊戲,有八個方向,NPC選擇哪一個方向逃跑呢?就能夠設置一個Judge(int direction)來給定每一個點的代價,在Judge裏面算上該點的敵人的強弱,或者該敵人的敏捷如何等等,最後選擇代價最小的地方逃跑。下面,咱們就來談談對於幾種NPC常見的智能的啓發式搜索法的設計:
Target select (選擇目標):
首先得到地圖上離該NPC附近的敵人列表。設計Judge() 函數,根據敵人的強弱,敵人的遠近,算出代價。而後選擇代價最小的敵人進行主動攻擊。
Escape(逃跑):
在呼吸事件裏面檢查本身的HP,若是HP低於某個值的時候,或者若是你是遠程兵種,而敵人近身的話,則觸發逃跑函數,在逃跑函數裏面也是對周圍的全部的敵人組織成列表,而後設計Judge() 函數,先選擇出對你構成威脅最大的敵人,該Judge() 函數須要判斷敵人的速度,戰鬥力強弱,最後得出一個主要敵人,而後針對該主要敵人進行路徑的Judge() 的函數的設計,搜索的範圍只多是和主要敵人相反的方向,而後再根據該幾個方向的敵人的強弱來計算代價,作出最後的選擇。
Random walk(隨機走路):
這個我並不推薦用A*算法,由於NPC一旦多起來,那麼這個對CPU的消耗是很恐怖的,並且NPC大多不須要長距離的尋路,只須要在附近走走便可,那麼,就在附近隨機的給幾個點,而後讓NPC走過去,若是碰到障礙物就停下來,這樣幾乎無任何負擔。
Follow Target(追隨目標):
這裏有兩種方法,一種方法NPC看上去比較愚蠢,一種方法看上去NPC比較聰明,第一種方法就是讓NPC跟着目標的路點走便可,幾乎沒有資源消耗。然後一種則是讓NPC在跟隨的時候,在呼吸事件裏面判斷對方的當前位置,而後走直線,碰上障礙物了用A*繞過去,該種設計會消耗必定量的系統資源,因此不推薦NPC大量的追隨目標,若是須要大量的NPC追隨目標的話,還有一個比較簡單的方法:讓NPC和目標同步移動,即讓他們的速度統一,移動的時候走一樣的路點,固然,這種設計只適合NPC所跟隨的目標不是追殺的關係,只是跟隨着玩家走而已了。html
原文網址:http://www.cnblogs.com/GameDeveloper/archive/2011/05/24/2055880.html算法