談這個話題之前,首先要讓大家知道,什麼是伺服器。在網路遊戲中,伺服器所扮演的角色是同步,廣播和伺服器主動的一些行為,比如說天氣,NPC AI之類的,之所以現在的很多網路遊戲伺服器都需要負擔一些遊戲邏輯上的運算是因為為了防止用戶端的作弊行為。瞭解到這一點,那麼本系列的文章將分為兩部分來談談網路遊戲伺服器的設計,一部分是講如何做好伺服器的網路連接,同步,廣播以及NPC的設定,另一部分則將著重談談哪些邏輯放在伺服器比較合適,並且用什麼樣的結構來安排這些邏輯。
伺服器的網路連接
大多數的網路遊戲的伺服器都會選擇非阻塞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;
伺服器的廣播
伺服器的廣播的重點就在於如何計算出廣播的對象。很顯然,在一張很大的地圖裡面,某個玩家在最東邊的一個動作,一個在最西邊的玩家是應該看不到的,那麼怎麼來計算廣播的對象呢?最簡單的辦法,就是把地圖分塊,分成大小合適的小塊,然後每次只象周圍幾個小塊的玩家進行廣播。那麼究竟切到多大比較合適呢?一般來說,切得塊大了,記憶體的消耗會增大,切得塊小了,CPU的消耗會增大(原因會在後面提到)。個人覺得切成一屏左右的小塊比較合適,每次廣播廣播周圍九個小塊的玩家,由於廣播的操作非常頻繁,那麼遍利周圍九塊的操作就會變得相當的頻繁,所以如果塊分得小了,那麼遍利的範圍就會擴大,CPU的資源會很快的被吃完。
切好塊以後,怎麼讓玩家在各個塊之間走來走去呢?讓我們來想想在切換一次塊的時候要做哪些工作。首先,要算出下個塊的周圍九塊的玩家有哪些是現在當前塊沒有的,把自己的資訊廣播給那些玩家,同時也要算出下個塊周圍九塊裡面有哪些物件是現在沒有的,把那些物件的資訊廣播給自己,然後把下個塊的周圍九快裡沒有的,而現在的塊周圍九塊裡面有的物件的消失資訊廣播給自己,同時也把自己消失的訊息廣播給那些物件。這個操作不僅煩瑣而且會吃掉不少CPU資源,那麼有什麼辦法可以很快的算出這些物件呢?一個個做比較?顯然看起來就不是個好辦法,這裡可以參照二維矩陣碰撞檢測的一些思路,以自己周圍九塊為一個矩陣,目標塊周圍九塊為另一個矩陣,檢測這兩個矩陣是否碰撞,如果兩個矩陣相交,那麼沒相交的那些塊怎麼算。這裡可以把相交的塊的座標轉換成內部座標,然後再進行運算。
對於廣播還有另外一種解決方案,實施起來不如切塊來的簡單,這種方法需要用戶端來協助進行運算。首先在伺服器端的串連結構裡面需要增加一個廣播對象的隊列,該隊列在用戶端登陸伺服器的時候由伺服器傳給用戶端,然後用戶端自己來維護這個隊列,當有人走出用戶端視野的時候,由用戶端主動要求伺服器給那個物件發送消失的訊息。而對於有人總進視野的情況,則比較麻煩了。
首先需要用戶端在每次給伺服器發送update position的訊息的時候,伺服器都給該串連算出一個視野範圍,然後在需要廣播的時候,迴圈整張地圖上的玩家,找到座標在其視野範圍內的玩家。使用這種方法的好處在於不存在轉換塊的時候需要一次性廣播大量的訊息,缺點就是在計算廣播對象的時候需要遍曆整個地圖上的玩家,如果當一個地圖上的玩家多得比較離譜的時候,該操作就會比較的慢。
伺服器的同步
同步在網路遊戲中是非常重要的,它保證了每個玩家在螢幕上看到的東西大體是一樣的。其實呢,解決同步問題的最簡單的方法就是把每個玩家的動作都向其他玩家廣播一遍,這裡其實就存在兩個問題: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。該方案的優點是不通過伺服器,用戶端自己之間進行同步,大大的降低了由於網路延遲而帶來的誤差,並且由於大部分決策都可以由用戶端來做,也大大的降低了伺服器的資源。由此帶來的弊端就是由於訊息和決策權都放在用戶端本地,所以給外掛提供了很大的可乘之機。