在這一章節,我想談談關于服務器端的腳本的相關設計。因為在上一章節里面,談NPC智能相關的時候已經接觸到一些腳本相關的東東了。還是先來談談腳本的作用吧。
在基于編譯的服務器端程序中,是無法在程序的運行過程中構建一些東西的,那么這個時候就需要腳本語言的支持了,由于腳本語言涉及到邏輯判斷,所以光提供一些函數接口是沒用的,還需要提供一些簡單的語法和文法解析的功能。其實說到底,任何的事件都可以看成兩個部分:第一是對自身,或者別的物件的數值的改變,另外一個就是將該事件以文字或者圖形的方式廣播出去。那么,這里牽扯到一個很重要的話題,就是對某一物件進行尋址。恩,談到這,我想將本章節分為三個部分來談,首先是服務器如何來管理動態創建出來的物件(服務器內存管理),第二是如何對某一物件進行尋址,第三則是腳本語言的組織和解釋。其實之所以到第四章再來談服務器的內存管理是因為在前幾章談這個的話,大家對其沒有一個感性的認識,可能不知道服務器的內存管理究竟有什么用。
4.1、服務器內存管理
對于服務器內存管理我們將采用內存池的方法,也稱為靜態內存管理。其概念為在服務器初始化的時候,申請一塊非常大的內存,稱為內存池(Memory pool),同時也申請一小塊內存空間,稱為垃圾回收站(Garbage recollecting station)。其大體思路如下:當程序需要申請內存的時候,首先檢查垃圾回收站是否為空,如果不為空的話,則從垃圾回收站中找一塊可用的內存地址,在內存池中根據地址找到相應的空間,分配給程序用,如果垃圾回收站是空的話,則直接從內存池的當前指針位置申請一塊內存;當程序釋放空間的時候,給那塊內存打上已經釋放掉的標記,然后把那塊內存的地址放入垃圾回收站。
下面具體談談該方法的詳細設計,首先,我們將采用類似于操作系統的段頁式系統來管理內存,這樣的好處是可以充分的利用內存池,其缺點是管理起來比較麻煩。嗯,下面來具體看看我們怎么樣來定義頁和段的結構:
typedef struct m_segment_s
{
struct m_segment_s *next; /* 雙線鏈表 + 靜態內存可以達到隨機訪問和順序訪問的目的,
真正的想怎么訪問,就怎么訪問。 */
struct m_segment_s *pre; int flags; // 該段的一些標記。
int start; // 相對于該頁的首地址。
int size; // 長度。
struct m_page_s *my_owner; // 我是屬于哪一頁的。
char *data; // 內容指針。
}m_segment_t;
typedef struct m_page_s
{
unsigned int flags; /* 使用標記,是否完全使用,是否還有空余 */
int size; /* 該頁的大小,一般都是統一的,最后一頁除外 */
int end; /* 使用到什么地方了 */
int my_index; /* 提供隨機訪問的索引 */
m_segment_t *segments; // 頁內段的頭指針。
}m_page_t;
那么內存池和垃圾回收站怎么構建呢?下面也給出一些構建相關的偽代碼:
static m_page_t *all_pages;
// total_size是總共要申請的內存數,num_pages是總共打算創建多少個頁面。
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 );
// 初始化該頁面的標記。
all_pages[i].flags |= NEVER_USED;
// 除了最后一個頁面,其他的大小都是page_size 大小。
all_pages[i].size = page_size;
// 初始化隨機訪問的索引。
all_pages[i].my_index = i;
// 由于沒有用過,所以大小都是0
all_pages[i].end = 0;
}
// 設置最后一個頁面的大小。
if ( (last_size = total_size % num_pages) != 0 )
all_pages[i].size = last_size;
}
下面看看垃圾回收站怎么設計:
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] ));
}
}
也許這樣的貼代碼會讓大家覺得很不明白,嗯,我的代碼水平確實不怎么樣,那么下面我來用文字方式來敘說一下大體的概念吧。對于段頁式內存管理,首先分成N個頁面,這個是固定的,而對于每個頁面內的段則是動態的,段的大小事先是不知道的,那么我們需要回收的不僅僅是頁面的內存,還包括段的內存,那么我們就需要一個二維數組來保存是哪個頁面的那塊段的地址被釋放了。然后對于申請內存的時候,則首先檢查需要申請內存的大小,如果不夠一個頁面大小的話,則在垃圾回收站里面尋找可用的段空間分配,如果找不到,則申請一個新的頁面空間。
這樣用內存池的方法來管理整個游戲世界的內存可以有效的減少內存碎片,一定程度的提高游戲運行的穩定性和效率。
4.2、游戲中物件的尋址
第一個問題,我們為什么要尋址?加入了腳本語言的概念之后,游戲中的一些邏輯物件,比如說NPC,某個ITEM之類的都是由腳本語言在游戲運行的過程中動態生成的,那么我們通過什么樣的方法來對這些物件進行索引呢?說得簡單一點,就是如何找到他們呢?有個很簡單的方法,全部遍歷一次。當然,這是個簡單而有效的方法,但是效率上的消耗是任何一臺服務器都吃不消的,特別是在游戲的規模比較大之后。
那么,我們怎么來在游戲世界中很快的尋找這些物件呢?我想在談這個之前,說一下Hash Table這個數據結構,它叫哈希表,也有人叫它散列表,其工作原理是不是順序訪問,也不是隨機訪問,而是通過一個散列函數對其key進行計算,算出在內存中這個key對應的value的地址,而對其進行訪問。好處是不管面對多大的數據,只需要一次計算就能找到其地址,非常的快捷,那么弊端是什么呢?當兩個key通過散列函數計算出來的地址是同一個地址的時候,麻煩就來了,會產生碰撞,其的解決方法非常的麻煩,這里就不詳細談其解決方法了,否則估計再寫個四,五章也未必談得清楚,不過如果大家對其感興趣的話,歡迎討論。
嗯,我們將用散列表來對游戲中的物件進行索引,具體怎么做呢?首先,在內存池中申請一塊兩倍大于游戲中物件總數的內存,為什么是兩倍大呢?防止散列表碰撞。然后我們選用物件的名稱作為散列表的索引key,然后就可以開始設計散列函數了。下面來看個例子:
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是需要進行索引的字符串指針,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的鬼子寫的論文中介紹的算法,據說速度非常的快。有了這個散列函數,我們就可以通過它來對世界里面的任意物件進行非常快的尋址了。
4.3、腳本語言解釋
在設計腳本語言之前,我們首先需要明白,我們的腳本語言要實現什么樣的功能?否則隨心所欲的做下去寫出個C的解釋器之類的也說不定。我們要實現的功能只是簡單的邏輯判斷和循環,其他所有的功能都可以由事先提供好的函數來完成。嗯,這樣我們就可以列出一張工作量的表單:設計物件在底層的保存結構,提供腳本和底層間的訪問接口,設計支持邏輯判斷和循環的解釋器。
下面先來談談物件在底層的保存結構。具體到每種不同屬性的物件,需要采用不同的結構,當然,如果你愿意的話,你可以所有的物件都采同同樣的結構,然后在結構里面設計一個散列表來保存各種不同的屬性。但這并不是一個好方法,過分的依賴散列表會讓你的游戲的邏輯變得繁雜不清。所以,盡量的區分每種不同的物件采用不同的結構來設計。但是有一點值得注意的是,不管是什么結構,有一些東西是統一的,就是我們所說的物件頭,那么我們怎么來設計這樣一個物件頭呢?
typedef struct object_head_s
{
char* name;
char* prog;
}object_head_t;
其中name是在散列表中這個物件的索引號,prog則是腳本解釋器需要解釋的程序內容。下面我們就以NPC為例來設計一個結構:
typedef struct npc_s
{
object_head_t header; // 物件頭
int hp; // NPC的hp值。
int level; // NPC的等級。
struct position_s position; // 當前的位置信息。
unsigned int personality; // NPC的個性,一個unsigned int可以保存24種個性。
}npc_t;
OK,結構設計完成,那么我們怎么來設計腳本解釋器呢?這里有兩種法,一種是用虛擬機的模式來解析腳本語言,另外一中則是用類似匯編語言的那種結構來設計,設置一些條件跳轉和循環就可以實現邏輯判斷和循環了,比如:
set name, "路人甲";
CHOOSE: random_choose_personality; // 隨機選擇NPC的個性
compare hp, 100; // 比較氣血,比較出的值可以放在一個固定的變量里面
ifless LESS; // hp < 100的話,則返回。
jump CHOOSE; // 否則繼續選擇,只到選到一個hp < 100的。
LESS: return success;
這種腳本結構就類似CPU的指令的結構,一條一條指令按照順序執行,對于腳本程序員(Script Programmer)也可以培養他們匯編能力的說。
那么怎么來模仿這種結構呢?我們拿CPU的指令做參照,首先得設置一些寄存器,CPU的寄存器的大小和數量是受硬件影響的,但我們是用內存來模擬寄存器,所以想要多大,就可以有多大。然后提供一些指令,包括四則運算,尋址,判斷,循環等等。接下來針對不同的腳本用不同的解析方法,比如說對NPC就用NPC固定的腳本,對ITEM就用ITEM固定的腳本,解析完以后就把結果生成底層該物件的結構用于使用。
而如果要用虛擬機來實現腳本語言的話呢,則會將工程變得無比之巨大,強烈不推薦使用,不過如果你想做一個通用的網絡游戲底層的話,則可以考慮設計一個虛擬機。虛擬機大體的解釋過程就是進行兩次編譯,第一次對關鍵字進行編譯,第二次生成匯編語言,然后虛擬機在根據編譯生成的匯編語言進行逐行解釋,如果大家對這個感興趣的話,可以去www.mudos.org上下載一份MudOS的原碼來研究研究。 大體的思路講到這里已經差不多了,下面將用unreal(虛幻)為實例,談一談網絡游戲服務器的設計。