io類游戲快速開發 3 狀態同步


轉自:https://cowlevel.net/article/2007725

 

本期日志將選擇使用狀態同步的方式制作io類游戲。依舊是客戶端CocosCreator(以下簡稱ccc)引擎+服務器端Colyseus。

狀態同步需要將游戲邏輯再服務器編寫,客戶端只做展示部分。因此需要大量的服務器端的開發,這里用到的是基於Nodejs的Colyseus,編程語言是TypeScript。

先上截圖

9e9a337bc12e7df3ef0d02d7987f963f.gif

github:

服務器端:https://github.com/cyclegtx/colyseus-iog-state-sync

客戶端:https://github.com/cyclegtx/cocos2dx-iog-state-sync

狀態同步

Colyseus對狀態同步支持非常好,有整套的狀態同步機制,可以省下很多功夫。具體的可以參考官方文檔:

服務器端:http://colyseus.io/docs/api-room-state/

客戶端:http://colyseus.io/docs/client-state-synchronization/

簡單來說Colyseus將服務器端邏輯都放到Room類之中,Room擁有一個函數setState用於將一個狀態結構賦給Room,每當狀態發生改變時,Room將改變的數據分發給所有房間中的玩家,在客戶端只需監聽發生變化的屬性即可。

我在Colyseus的基礎上抽象出GameRoomGameStateEntity三個類。

  • 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: "指令內容" });

坐標系:

ff291308f0bfafc57f58f0eeb80c3e3e.jpg

cocos2dx的坐標系跟Matter.js的坐標系(標准屏幕坐標系)不太一樣,y軸相反,因此在收到服務器傳來的坐標之后要將其y值乘以-1。同樣在傳給服務器指令的時候,y值也要取反。

為了方便客戶端調試,我在CyEntity加入了debug變量,開啟后會在客戶端顯示所有的Entity的大小,位置,狀態。白色為靜態物理實體,紅色為動態物理實體,黃色為非物理實體。

6e07011272c5e4ba90b4b05768548ef0.jpg

傳輸優化

狀態同步一大劣勢就是過於占用帶寬,為了減少帶寬的消耗,做了以下處理。

服務器端設置同步頻率:

在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,可以是手機也可以是網頁。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM