轉自:https://cowlevel.net/article/2007725
本期日志將選擇使用狀態同步的方式制作io類游戲。依舊是客戶端CocosCreator(以下簡稱ccc)引擎+服務器端Colyseus。
狀態同步需要將游戲邏輯再服務器編寫,客戶端只做展示部分。因此需要大量的服務器端的開發,這里用到的是基於Nodejs的Colyseus,編程語言是TypeScript。
先上截圖
github:
服務器端:https://github.com/cyclegtx/colyseus-iog-state-sync
客戶端:https://github.com/cyclegtx/cocos2dx-iog-state-sync
狀態同步
Colyseus對狀態同步支持非常好,有整套的狀態同步機制,可以省下很多功夫。具體的可以參考官方文檔:
簡單來說Colyseus將服務器端邏輯都放到Room類之中,Room擁有一個函數setState用於將一個狀態結構賦給Room,每當狀態發生改變時,Room將改變的數據分發給所有房間中的玩家,在客戶端只需監聽發生變化的屬性即可。
我在Colyseus的基礎上抽象出GameRoom,GameState,Entity三個類。
- Entity為游戲中的實體,擁有自己的狀態屬性。實體可以是出現在游戲畫面中的人物,子彈等具體的事物,也可以是玩家名字,積分等不出現在畫布中的屬性。Entity有唯一的id用於在客戶端和服務器端索引,這里使用shortid生成以免重復。
- GameState為房間狀態和合集,只有一個變量。entities:用來存儲所有的Entity實體,索引為Entity的id。
- GameRoom用於處理游戲邏輯,其中變量state用來存儲GameState,每當新建實體的時候只需交將實體實例加入到 state.entities之中。這樣Colyseus就會自動處理狀態變化並分發到客戶端。
客戶端監聽代碼:
//Entity新建,刪除等
this.room.listen("entities/:id", this.onEntityChange.bind(this));
//Entity屬性發生變化
this.room.listen("entities/:id/:attribute", this.onEntityAttributeChange.bind(this));
其中entities/:id/:attribute entities為GameState的成員變量名稱,這里只有entities,:id為Entity的id,:attribute為變化的屬性,即Entity的成員變量。我們可以根據id在客戶端找到服務器端對應的Entity然后再修改其變化的屬性attribute。
這里為服務器端的Entity加一個type變量,用於客戶端辨別Entity的類型,為了方便規定客戶端將所有的Entity的Prefab放到resources/Entities目錄下。當服務器端新建了一個Entity比如玩家發射出的子彈,並將子彈Entity加入到state.entites中;客戶端就會收到消息,並根據Entity的type,Bullet去找resources/Entities/Bullet.prefab 如果沒有就用默認的resources/Entities/Entity.prefab實例化(cc.instantiate)。客戶端抽象了CyEntity類,對服務器端發送的數據做最基礎的插值,渲染等處理。
服務器端實體Entity
在服務器端Entity作為實體的抽象,不僅需要存儲實體狀態,還要處理實體的邏輯。這里為Entity加入了update(dt)函數,在每一幀調用,用於處理實體邏輯。
既然要處理邏輯就免不了使用變量保存一些引用,比如玩家發射的子彈需要用owner變量存儲發射者的實體引用。這里需要特別注意,Entity的實例是要加入GameState中,Entity中的成員變量都會被同步到客戶端,引用類型的變量很容易造成無限循環,比如玩家類中引用了子彈類,子彈類的owner又引用了玩家類。如果遇到服務器端報錯Maximum call stack size exceeded不要慌張檢查下實體類中是否存在這種引用變量。如果存在就使用@nosync 將其標記為不進行同步,Colyseus就會忽略此函數。
import { nosync } from "colyseus";
...
export class Bullet extends Entity{
@nosync
owner:Character = null;
}
這里十分建議為每一個成員變量都默認加上@nosync ,然后再選出有必要同步的變量去掉@nosync ,需要同步的變量盡量為基礎類型,引用等類型最好不要設為同步。同步過多的無用變量會浪費寶貴的帶寬。
服務器端游戲循環
為了實現邏輯需要在服務器端維護游戲的主循環,可以使用setInterval,但是Colyseus提供了更穩定方法
//以16.6ms (60fps)的間隔訪問,update函數
this.setSimulationInterval(this.update.bind(this),16.6);
update() {
//遍歷並運行每個entity的update函數
for (let k in this.state.entities) {
this.state.entities[k].update(this.clock.deltaTime);
}
//更新物理引擎
Engine.update(this.engine, this.clock.deltaTime);
}
服務器端物理引擎
服務器端的物理引擎我選擇了Matter.js(http://brm.io/matter-js/)。官方文檔和案例也比較豐富。使用起來也很簡單,只需要在Entity中加入body(Matter.Body),就可以為實體賦予物理效果。當然不是所有實體都需要物理效果,因此派生出PhysicsEntity類用於創建具有物理效果的實體。
Entity //基礎實體類
->PhysicsEntity //物理實體類
->RectBodyEntity //矩形物理實體類
在物理實體類中加入了兩個函數用於處理碰撞
//當碰撞開始
onCollisionStart(entityA: PhysicsEntity, entityB: PhysicsEntity) {}
//當碰撞結束
onCollisionEnd(entityA: PhysicsEntity, entityB: PhysicsEntity) {}
body實例過於龐大,不適合網絡同步,因此body需要標記@nosync 然后在update函數中將body中需要同步的部分賦值到Entity的變量上,例如位置變量。entity.x = body.position.x
客戶端
客戶端的工作就簡單多了,只需要將實體的外觀,動畫,聲音等,按照服務器端同步過來的狀態進行顯示就可以了(這里實體的狀態用變量action存放,常用的state被Colyseus占用了),跟普通的狀態機一樣。
客戶端發送用戶輸入到服務器端,只需要發送CMD到服務器,服務器端就會根據用戶的sessionId找到用戶的控制的Entity,將指令交由Entity處理。
CyStateEngine.room.send({ CMD: "指令名稱", value: "指令內容" });
坐標系:
cocos2dx的坐標系跟Matter.js的坐標系(標准屏幕坐標系)不太一樣,y軸相反,因此在收到服務器傳來的坐標之后要將其y值乘以-1。同樣在傳給服務器指令的時候,y值也要取反。
為了方便客戶端調試,我在CyEntity加入了debug變量,開啟后會在客戶端顯示所有的Entity的大小,位置,狀態。白色為靜態物理實體,紅色為動態物理實體,黃色為非物理實體。
傳輸優化
狀態同步一大劣勢就是過於占用帶寬,為了減少帶寬的消耗,做了以下處理。
服務器端設置同步頻率:
在Room設置同步間隔,設置成50ms雖然只相當於20fps,但是在客戶端進行插值之后依然可以平滑的移動,達到60fps的效果。如果游戲體驗允許的情況下可以設置更大的間隔,以減小同步頻率,節省帶寬。
this.setPatchRate(50);
服務器端設置同步閾值:
具有物理效果的實體經常會發生微小的位移,是由物理引擎引起的,這種位移小到無法辨識,也沒有必要進行同步,因此在update函數中進行賦值的時候可以加上閾值。
//當位移超過閾值時,進行同步,增大閾值以減小頻繁同步帶來的額流量壓力
if(Math.abs(this.x - this.body.position.x) > 0.1){
this.x = this.body.position.x;
}
if(Math.abs(this.y - this.body.position.y) > 0.1){
this.y = this.body.position.y;
}
if(Math.abs(this.angle - this.body.angle) > 0.01){
this.angle = this.body.angle;
}
客戶端減少上傳頻率:
客戶端在上傳某些用戶指令的時候,比如鼠標移動,不要在mousemove的事件回調之中上傳指令,過於頻繁。可以將鼠標位置記錄到變量中,然后以固定時間間隔判斷是否有變化然后再上傳。
除了以上幾點,還應在設計時盡量減少移動的物體,比如游戲中撿拾的經驗豆等,可以將body類型設置成靜態,以免與其他物體碰撞導致移動占用同步帶寬。
Colyseus文檔中說明同步的數據通過MessagePack編碼成二進制,並使用Fossil's Delta algorithm算法傳輸,我沒有仔細研究是否還可以繼續優化傳輸數據的大小,看起來傳輸數據沒有使用gzip壓縮,不知道gzip壓縮對這種小的二進制包壓縮效果怎么樣。
總結
缺點:
- 根據游戲截圖來看,狀態同步還是十分流暢的,但是資源消耗還是比幀同步要高出幾倍。我使用1核1g,1m帶寬的阿里雲ECS來運行,跑起4個房間,cpu就占用了15%,帶寬就達到150kbps。同等情況下的幀同步,cpu 5% ,70kbps,而且增加房間幾乎不會增加cpu占用。顯然狀態同步需要在服務器端進行游戲循環,而且每增加一個房間就多一個循環,一個服務器可以承載多少玩家變成了需要重點考慮的問題。當然我的代碼也沒有進行優化,優化過后應該會好很多。
優點:
- 玩家可以隨時加入,無需等待匹配其他玩家。
- 一套服務器代碼可以在多種終端使用,可以多平台聯機。客戶端可以是cocos2dx也可以是Unity3D,可以是手機也可以是網頁。