开发笔记:游戏逻辑模块组织及数据同步
一個游戲根據功能可以劃分為多個不同的模塊,如金錢、背包、裝備、技能、任務、成就等。按照軟件工程的思想,我們希望分而治之單獨實現不同的模塊,再將這些模塊組合在一起成為一份完整的游戲。但現實是殘酷的,不同模塊之間往往有千絲萬縷的聯系,比如購買背包物品會需要扣金幣、打一個副本會完成任務,完成任務又會獎勵金幣和物品,金幣的增加又導致一個成就達成。于是我們雖然在不同的類或不同的文件中來實現各個模塊,卻免不了模塊間的交叉引用和互相調用,最后混雜不堪,任何一點小修改都可以導致牽一發而動全身。
為了后面說明方便,我們考慮這樣一個小型游戲系統:總共有3個模塊,分別是金錢、背包、任務。購買背包物品需要消耗金幣,賣出背包物品可得到金幣,金幣增加到一定數額后會導致某個任務的狀態變為完成,完成任務可獲得物品和金幣。這3個模塊的調用關系如圖。
?
首先我們把模塊的數據和邏輯分離,借鑒經典的MVC模式,數據部分叫作Model,邏輯部分叫作Controller。如此一來,游戲功能部分就被劃分出來了兩個不同的層次,Controller處于較高的層次上,可以引用一個或者多個Model。Model層專心處理數據,對上層無感知。每個Model都是完全獨立的模塊,不引用任何Controller或Model,不依賴于其他任何對象,可以單拿出來進行單元測試。
?
對于我們的例子,每個模塊提供的接口列舉如下:
BagModel:獲取物品數量,增加物品,扣除物品
MoneyModel:獲取金幣數量,增加金幣,扣除金幣
TaskModel:增加任務,刪除任務,標記任務為完成
BagController:購買物品,賣出物品
TaskController:完成任務
購買或賣出物品時,由BagController進行或操作校驗,隨后調用BagModel和MoneyModel完成數據修改。完成任務時,由TaskController調用各個模塊。
現在唯一的問題是,既然MoneyModel不引用其他模塊,那么在金幣增加時如何告知任務模塊去完成任務呢?這里我們需要引入一個管理依賴的利器:觀察者模式。
具體使用方式是把Model實現為一個Subject,對某個Model的數據變化感興趣的Controller實現為對應的Observer。我們的例子中,MoneyModel是Subject,在金幣數量變化時通知所有已注冊的Observer;TaskController是MoneyModel的一個Observer,在初始化時向MoneyModel注冊。
?
注意圖中由MoneyModel指向TaskController的虛線箭頭,代表MoneyModel數據變化時會去通知TaskController,用虛線是因為MoneyModel并不依賴于TaskController(只依賴于Observer接口)。同樣BagModel也可以提供背包物品變化的Subject,如果新加一個任務是要求某物品的數量達某個值,那么TaskController可向BagModel注冊,這樣在物品變化時就能得到通知了,圖中也畫出了這條虛線。
對觀察者模式不熟悉的讀者朋友可以自行查閱資料, 本文的重點并不是介紹設計模式。這里簡單提示一下觀察者模式的精髓:當某模塊調用其他模塊時就產生了依賴,這時可以不直接去調用,而是轉而實現一個機制,這個機制就是讓其他模塊告訴自己他們需要被調用。最后調用的流程沒變,變化的是依賴關系。
在客戶端情況要更復雜一些,實際上加入UI后,我們的模塊設計就成經典的MVC,這也是我們為什么把數據模塊和邏輯模塊分別叫Model和Controller的原因。
?
這里只畫出了背包模塊。這里的System API指與游戲運行平臺相關的一些接口,可能是操作系統API、引擎API、圖形庫API等等。View模塊和Model模塊地位相當,只處理顯示而不管游戲功能,需要顯示的數據都是由Controller提供的。對于能輸入的View同樣采用觀察者模式,點擊等事件發生時通知其他模塊(而不是直接調用),注意圖中由BagView指向BagController的虛線箭頭。
下面介紹數據同步的設計。
首先對于網絡游戲,客戶端所展示的數據是服務器傳送過來的。當玩家操作導致數據發生變化時,最好也由服務器更新給客戶端。曾經接手過一個項目,賣QQ號很多操作的結果都是客戶端先算出來的,于是各種邏輯都是服務器和客戶端各實現一遍,很容易兩邊的數據就不一致了,很讓人頭疼。
所以我們的同步思路是當客戶端向服務器發起一個請求時,服務器將所有變化的數據同步給客戶端,客戶端收到服務器的返回后再更新數據,絕不私自改動數據。在這個指導思想下,我們消息包結構是這樣的(以物品賣出舉例):
復制代碼
服務器向客戶端返回的消息幾乎總是包含3個字段。result為操作結果可能是0或者錯誤碼,sync中包含了所有的數據更新,postback將客戶端的請求消息原封不動返回去,便于客戶端進行界面更新或友好提示。
sync是一個比較復雜的message,包含了所有需要更新的Model的數據。感謝Protocol Buffer的optional選項,大多數情況下我們發送的數據只是其中很小的一部分。
先來看服務器端消息處理和同步的設計。
?
如圖所示,我們在Model和Controller之上新加了一個Handler接口層。Handler負責解析消息包,調用Controller處理消息包,在必要的時候調用SyncController構建同步數據,最后打包成消息返回給客戶端。
每個Model在管理數據的基礎上會維護變化數據的集合,對于簡單的Model比如MoneyModel就是一個bool臟標記,而BagModel則維護變化物品id的集合。變化數據列表在同步之后清除。
客戶端的結構是類似的。
?
與服務器的區別就在于SyncController是負責調用Model更新數據,每個Model都實現數據更新接口。注意除SyncController之外,其他Controller只能讀取Model而不能改變其數據,這樣就保證了所有數據一定是從服務器同步的。
最后我想以出售物品為例子完整走一遍流程。從客戶端進行操作開始,到請求發到服務器,最后再返回客戶端更新數據和界面。完整的圖比較復雜,混在一起基本上沒法看了,只好刪掉了客戶端的任務模塊……
?
BagView界面產生一個點擊,因為BagController是BagView的觀察者,所以BagController能得到點擊事件的通知。
BagController識別出此點擊是要出售物品,于是構建好消息包發往服務器。
服務器識別出消息類型是Sell,于是消息被派發給SellHandler。
SellHandler調用BagController執行邏輯。
BagController取出BagModel和MoneyModel的數據進行條件檢查,如果無法執行操作則生成錯誤碼返回給SellHandler,否則調用Model修改數據,此時BagModel會記錄下變化物品的id,MoneyModel會做一個臟標記。
MoneyModel數據發生變化,通知自己的觀察者(TaskController)。
TaskController判斷任務完成,調用TaskModel更新數據。TaskModel會記錄發生變化的任務。
SellHandler對BagController的調用返回后,如果出錯則直接返回消息包給客戶端。否則調用SyncController收集同步數據。
SyncController調用各個模塊收集同步數據,各個模塊提交同步數據后清除自己維護的標記。
SellHandler將操作結果和同步數據打包后發往客戶端。
客戶端識別出消息類型是Sell,消息被派發給SellHandler。
BagHandler將消息處理結果發給BagController。
BagController根據消息處理結果,通知BagView進行必要的提示。
SellHandler將消息包中的數據同步部分發給SyncController。
SyncController將同步數據同步給各個模塊。
BagModel和MoneyModel的數據發生了變化,通知觀察者,即對應的Controller。
Controller調用View進行界面更新。
Q&A
返回客戶端提交的postback對于網絡傳輸來說太過重量級, 可以嘗試改為客戶端保存一個rid-postback的鍵值對, id由客戶端自增, 請求數據時把rid一起發送給服務器。
支持這個方案。
但我的想法不是出于數據量的考慮,因為一般網游客戶端發往服務器的消息都是比較小的,服務器返回的消息會比較大。 原因是后來我們考慮到消息可能丟包的問題,當丟包發生時,客戶端需要重發請求,這樣一來rid檢驗及保存之前發送的請求就是必須的了。而保存下來的請求正好又可以用來替代上文的postback,所以你的方案非常合理。
我使用了背包里一個物品,在返回的sync中是返回使用掉的物品信息, 還是背包的全部物品信息?
因為我們背包里的物品會比較多,所以同步全部物品是不合適的。
我們的做法是刪除物品后記錄物品id,生成同步數據時如果發現對應id的物品不存在,則同步一個數量為0的物品信息,客戶端收到數量為0的物品后做刪除操作。 有的模塊沒有一個代表刪除的特殊“零值”,比如任務。我們的做法是將新增/更新與刪除分開同步:
?
總結
以上是生活随笔為你收集整理的开发笔记:游戏逻辑模块组织及数据同步的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 游戏编程中的数学——随机数字生成(RNG
- 下一篇: 为游戏开发者总结的20个 Unity 建