原文鏈接:http://codemacro.com/2012/04/23/game-server-info-1/
我們的邏輯服務器(Game Server,以下簡稱GS)主要邏輯大概是從去年夏天開始寫的。因為很多基礎模塊,包括整體結構沿用了上個項目的代碼,所以算不上從頭開始做。轉眼又快一年,我覺得回頭總結下對于經驗的積累太有必要。
整體架構
GS的架構很大程度取決于游戲的功能需求,當然更受限于上個項目的基礎架構。基礎架構包括場景、對象的關系管理,消息廣播等。
需求
這一回,程序員其實已經不需要太過關心需求。因為我們決定大量使用腳本。到目前為止整個項目主要還是集中在技能開發上。而這個使用腳本的度,就是技能全部由策劃使用腳本制作,程序員不會去編寫某個具體技能,也不會提供某種配置方式去讓策劃通過配置來開發技能。這真是一個好消息,不管對于程序員而言,還是對于策劃而言。但后來,我覺得對于這一點還是帶來了很多問題。
實現
基于以上需求,程序員所做的就是開發框架,制定功能實現方案。腳本為了與整個游戲框架交互,我們制定了“觸發器“這個概念,大概就是一種事件系統。
這個觸發器系統,簡單來說,就是提供一種“關心“、”通知“的交互方式,也就是一般意義上的事件機制。例如,腳本中告訴程序它關心某個對象的移動,那么當程序中該對象產生移動時,就通知腳本。腳本中可以關心很多東西,包括對象屬性,其關心的方式包括屬性值改變、變大、變小,各種變化形式;對象開始移動,移動停止;對象碰撞,這個會單獨談談;定時器等。
除了觸發器系統外,還有個較大的系統是游戲對象的屬性系統。對象的屬性必然是游戲邏輯中策劃最關心最容易改動的模塊。既然我們程序的大方向是盡可能地不關心策劃需求,所以對象屬性在設計上就不可能去編寫某個具體屬性,更不會編寫這個屬性相關的邏輯功能。簡單來說,程序為每個對象維護一個key-value表,也就是屬性名、屬性值表。該表的內容由腳本填入,腳本享有存取權限。然后腳本中就可以圍繞某個屬性來編寫功能,而程序僅起存儲作用。
第三,怪物AI模塊。AI模塊的設計在開發周期上靠后。同樣,程序不會去編寫某類AI的實現。程序提供了另一種簡單的事件系統,這個系統其實就是一個調用腳本的方案。當關于某個怪物發生了某個事件時,程序調用腳本,傳入事件類型和事件參數。這個事件分為兩類:程序類和腳本類。腳本類程序不需關心,僅提供事件觸發API。程序類事件非常有限:怪物創建、出生、刪除。
除了以上三塊之外,還有很多零散的腳本交互。例如游戲對象屬性初始化,角色進入游戲,角色進入場景等。這些都無關痛癢。
接下來談一些關鍵模塊的實現。
定時器
整個GS的很多邏輯模塊都基于這個定時器來實現。這個定時器接收邏輯模塊的注冊,在主循環中傳入系統時間,定時器模塊檢查哪些定時器實例超時,然后觸發調用之。這個主循環以每幀5ms的速率運行,也即幀率1000/5。
這個定時器是基于操作系統的時間。隨著幀率的不同,它在觸發邏輯功能時,就必然不精確。游戲客戶端(包括單機游戲)在幀率這塊的實現上,一般邏輯功能的計算都會考慮到一個dt(也就是一幀的時間差),例如移動更新,一般都是x = last_x + speed * dt。但,我們這里并沒有這樣做。我們的幾乎所有邏輯功能,都沒有考慮這個時間差。
例如,我們的移動模塊注冊了一個固定時間值的定時器,假設是200ms。理想情況下,定時器模塊每200ms回調移動模塊更新坐標。但現實情況肯定是大于200ms的更新頻率,悲劇的是,移動模塊每次更新坐標都更新一個固定偏移。這顯然是不夠精確的。
更悲劇的是,定時器的實現中,還可能出現跳過一些更新幀。例如,理論情況下,定時器會在系統時間點t1/t2/t3/t4分別回調某個邏輯模塊。某一幀里,定時器大概在t1回調了某邏輯模塊,而當該幀耗時嚴重時,下一幀定時器模塊在計算時,其時間值為t,而t大于t4,此時定時器模塊跳過t2/t3。相當于該邏輯模塊少了2次更新。這對于移動模塊而言,相當于某個對象本來在1秒的時間里該走5格,但實際情況卻走了1格。
當然,當游戲幀率無法保證時,邏輯模塊運行不理想也是情有可原的。但,不理想并不包含BUG。而我覺得,這里面是可能出現BUG的。如何改善這塊,目前為止我也沒什么方案。
移動
有很多更上層的模塊依賴移動。我們的移動采用了一種分別模擬的實現。客戶端將復雜的移動路徑拆分為一條一條的線段,然后每個線段請求服務器移動。然后服務器上使用定時器來模擬在該線段上的移動。因為服務器上的阻擋是二維格子,這樣服務器的模擬也很簡單。當然,這個模塊在具體實現上復雜很多,這里不細談。
碰撞檢測
我們的技能要求有碰撞檢測,這主要包括對象與對象之間的碰撞。在最早的實現中,當腳本關心某個對象的碰撞情況時,程序就為該對象注冊定時器,然后周期觸發檢測與周圍對象的距離關系,這個周期低于100ms。這個實現很簡單,維護起來也就很簡單。但它是有問題的。因為它基于了一個不精確的定時器,和一個不精確的移動模塊。
首先,這個檢測是基于對象的當前坐標。前面分析過在幀率掉到移動更新幀都掉幀的情況下,服務器的對象坐標和理論情況差距會很大,而客戶端基本上是接近正確情況的,這個時候做的距離檢測,就不可能正確。另一方面,就算移動精確了,這個碰撞檢測還是會帶來BUG。例如現在檢測到了碰撞,觸發了腳本,腳本中注冊了關心離開的事件。但不幸的是,在這個定時器開始檢測前,這兩個對象已經經歷了碰撞、離開、再碰撞的過程,而定時器開始檢測的時候,因為它基于了當前的對象坐標,它依然看到的是兩個對象處于碰撞狀態。
最開始,我們直覺這樣的實現是費時的,是不精確的。然后有了第二種實現。這個實現基于了移動的實現。因為對象的移動是基于直線的(服務器上)。我們就在對象開始移動時,根據移動方向、速度預測兩個對象會在未來的某個時間點發生碰撞。當然,對于頻繁的小距離移動而言,這個預測從直覺上來說也是費時的。然后實現代碼寫了出來,一看,挺復雜,維護難度不小。如果效果好這個維護成本也就算了,但是,它依然是不精確的。因為,它也依賴了這個定時器。
例如,在某個對象開始移動時,我們預測到在200ms會與對象B發生碰撞。然后注冊了一個200ms的定時器。但定時器不會精確地在未來200ms觸發,隨著幀率的下降,400ms觸發都有可能。即便不考慮幀率下降的情況,它還是有問題。前面說過,我們游戲幀保證每幀至少5ms,本來這是一個限幀手段,目的當然是避免busy-loop。這導致定時器最多出現5ms的延遲。如果策劃使用這個碰撞檢測去做飛行道具的實現,例如一個快速飛出去的火球,當這個飛行速度很快的時候,這5ms相對于這個預測碰撞時間就不再是個小數目。真悲劇。
技能
雖然具體的技能不是程序寫的,但正如把幾乎所有具體邏輯交給策劃寫帶來的悲劇一樣:這事不是你干的,但你得負責它的性能。所以有必要談談技能的實現。
技能的實現里,只有一個技能使用入口,程序只需要在客戶端發出使用技能的消息時,調用這個入口腳本函數。然后腳本中會通過注冊一些觸發器來驅動整個技能運作。程序員一直希望策劃能把技能用一個統一的、具體的框架統一起來,所謂的變動都是基于這個框架來變的。但策劃也一直堅持,他們心目中的技能是無法統一的。
我們的技能確實很復雜。一個技能的整個過程中,服務器可能會和客戶端發生多次消息交互。在最初的實現中,服務器甚至會控制客戶端的技能特效、釋放動作等各種細節;甚至于服務器會在這個過程中依賴客戶端的若干次輸入。
下一篇我將談談一些遇到的問題。