在這一章節,我想談談關于伺服器端的腳本的相關設計。因為在上一章節裡面,談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(虛幻)為執行個體,談一談網絡遊戲伺服器的設計。