io类游戏快速开发 2
轉自:https://cowlevel.net/article/2005281
?
服務器端
服務器端比較簡單,可以根據colyseus(https://github.com/gamestdio/colyseus)官方文檔提示,安裝。然后新建rooms/IOGRoom.ts用來處理服務器邏輯。
colyseus已經將房間查詢,玩家匹配之類的常見功能實現,我們只需要在IOGRoom.ts里實現游戲邏輯代碼即可。主要內容就是維護一個frame_list列表,并以一個固定的頻率FRAME_RATE增加當前幀frame_index,并將frame_list中當前幀的數據發送給所有玩家。同時將玩家提交的指令push到frame_list里。
//以固定頻率發送當前幀setInterval(this.tick.bind(this),1000/this.FRAME_RATE);
tick(){
? ?let frames = [];
? ?frames.push([this.frame_index,this.getFrameByIndex(this.frame_index)]);
? ?this.broadcast(["f",frames]);
? ?this.frame_index += this.frame_acc;
}
//接受玩家的輸入指令并存入frame_list
this.frame_list[this.frame_index].push(data);
?
客戶端連接服務器
colyseus提供了js版本的客戶端代碼,根據官方的API可以十分方便的使用。我寫了一個簡易的界面用來創建或者加入房間,代碼比較簡單可以直接看代碼注釋。主要代碼文件如下:
IOG/colyseus/colyseus.js ? ?//colyseus客戶端代碼IOG/colyseus/colyseus.d.ts ? ?//colyseus TypeScript定義文件
IOG/CyEngine.ts????//用來處理服務器鏈接等
IOG/CyPlayer.ts????//用來儲存玩家輸入等數據
在CyEngine.ts中調用colyseus方法進行加入,創建,接收發送消息等操作:
this.client?=?new Colyseus.Client(`ws://${this.ip}:${this.port}`);????//鏈接服務器this.client.getAvailableRooms(this.roomName, function (rooms, err) {});????//獲取可以加入的房間列表
this.room?=?this.client.join(this.roomName);????//加入房間
//接受服務器信息
onMessage(message){
? ?switch(message[0]){
? ? ? ?case "f":
??????? //幀同步信息
? ? ? ? ? ?this.onReceiveServerFrame(message);
? ? ? ? ? ?break;
? ? ? ?case "fs":
? ? ? ? ? ?this.onReceiveServerFrame(message);
? ? ? ? ? ?//把服務器幀同步到本地幀緩存后,讀取并執行本地幀緩存
? ? ? ? ? ?this.nextTick();
? ? ? ? ? ?break;
? ? ? ?default:
? ? ? ? ? ?console.warn("未處理的消息:");
? ? ? ? ? ?console.warn(message);
? ? ? ? ? ?break;
? ?}
}
//發送信息到服務器房間
sendToRoom(data:any){
????this.room.send(data);
}
客戶端幀鎖定
實現幀同步最重要的是保證所有客戶端每一幀計算結果一致,而且當前幀要保證與服務器同步。在服務器端,我們每隔固定時間間隔發送幀信息f,在客戶端的onMessage中收到并處理幀信息。在收到幀信息之前需要停止客戶端渲染,等待網絡接收到新的幀信息之后再進行渲染。
通常的做法是棄用ccc的游戲循環,維護一個新的游戲循環,已達到完全控制游戲循環的目的。這樣就可以在等待新的幀信息的時候停止游戲循環中的邏輯處理(物理引擎等)。但是這樣做就會完全破壞ccc原有的工作流程,比如cc.Component中的onLoad,start,update,都無法使用,ccc自帶的動畫,粒子特效等也沒有辦法繼續使用。
所以為了盡量不改變ccc原有的工作流程,我們需要直接控制ccc的游戲循環。好在ccc提供了cc.game.pause()方法來暫停游戲邏輯,然后在接收到服務器的幀信息之后,調用cc.game.step()來運行下一幀,這樣我們就可以繼續使用Component中的update等回調。
但是這樣做有個嚴重的缺點,就是cc.game.pause()會暫停所有邏輯,包括UI界面等。如果出現網絡卡死,連UI界面(比如彈出窗口提示網絡斷開)都無法顯示。所有這里必須得處理好網絡多開的情況,在斷開網絡時及時的恢復游戲循環,cc.game.resume()?。
客戶端接收并處理幀信息
客戶端接收到服務器端的幀信息之后,將其緩存到frames中,并以服務器設置的時間間隔進行讀取并處理幀信息中的玩家輸入。將當前幀數記錄到frame_index中,每次累加,如果frames[frame_index]為undefined,則等待接受服務器發來的新的幀信息。
注意這里按時間間隔執行的延時執行函數就不能再使用ccc自帶的schedule,scheduleOnce了,因為這兩個函數因為cc.game.pause()被停止了。我們可以使用原始的setTimeout,setInterval。
同樣因為游戲循環暫停而不能使用的還有update(dt)回調中的dt,因為這個時間間隔在不同的客戶端因為網速的原因會有很大差別。我們經常使用dt來計算真實的時間,比如技能CD為10s,在幀同步的情況下就不能以時間為單位了,可以使用幀為單位,技能CD為600幀(每秒60幀)。
正常情況下客戶端應該以服務器端規定的間隔處理幀信息。但是如果遇到了網絡卡頓,客戶端緩存了大量的未處理幀,或者玩家是后加入房間的,需要將之前的歷史幀全部執行一邊,那么使用相同的幀處理速度將會永遠追不上最新的幀進度。這里就需要做追幀處理,即以更快的速度處理幀信息,類似快進。
nextTick() {//處理幀信息
? ? this.runTick();
? ? if (this.frames.length - this.frame_index > 100) {
? ? ? ? //當緩存幀過多時,一次處理多個幀信息
? ? ? ? for (let i = 0; i < 50; i++) {
? ? ? ? ? ? this.runTick();
? ? ? ? }
? ? ? ? this.frame_inv = 0;
? ? }else if (this.frames.length - this.frame_index > this.serverFrameAcc){
? ? ? ? //追幀
? ? ? ? this.frame_inv = 0;
? ? } else {
? ? ? ? if (this.readyToControl == false) {
? ? ? ? ? ? this.readyToControl = true;
? ? ? ? ? ? this.round.onReadyToControl();
? ? ? ? }
? ? ? ? //正常速度
? ? ? ? this.frame_inv = 1000 / (this.serverFrameRate * (this.serverFrameAcc + 1));
? ? }
? ? setTimeout(this.nextTick.bind(this), this.frame_inv)
}
當緩存的幀過多的時(>100)我們還可以一次處理多條(50)幀信息,以提高追幀的效率,但是這里處理的過多的話會導致客戶端卡死,而且可能導致物理引擎計算結果出現誤差,下面會提到。
服務器幀插值
為了減小服務器帶寬的壓力,服務器發送幀信息的時間間隔不易過短,因為幀信息里只有玩家輸入信息,所以50ms(20fps)左右就已經幾乎感覺不到輸入延時了,但是客戶端如果以20fps渲染的話畫面還是有明顯卡頓的。為了客戶端達到60fps,又減小服務器帶寬壓力(20fps的速度進行同步),我們需要對服務器的幀信息進行插值,即服務器發送的幀號每次增加3(0,3,6幀),客戶端接收到后用空數組([])將幀數補充(0,1,2,3,4,5,6幀)。這樣就可以達到節省帶寬的目的,畢竟輸入并不像渲染對延時那么敏感。
發送用戶指令到服務器
在客戶端中,不能直接在本地修改游戲物體的狀態,比如控制人物移動,進行攻擊等。因為沒有狀態同步,所有的本地修改都會導致客戶端之間的結果差異。為了保證同步我們需要把用戶輸入和指令傳到服務器,再由服務器以幀信息的信息分發到本地后再進行響應(用戶命令->服務器幀->本地客戶端響應)。
對于客戶端本地的其他玩家是一樣邏輯,等待服務器幀信息,然后將幀信息中命令映射到對應的玩家類中CyPlayer。這個過程就相當于在每個玩家的客戶端中,維護一個包含所有玩家的輸入代理列表,當某個玩家輸入命令通過服務器幀信息同步到所有的玩家客戶端上時,匹配并映射到本地代理列表中。
runTick() {//處理當前幀信息
...
? ? if (frame.length > 0) {
? ? ? ? frame.forEach((cmd) => {
??????? //將指令映射到函數中 cmd_input
? ? ? ? ? ? if (typeof this["cmd_" + cmd[1][0]] == "function") {
? ? ? ? ? ? ? ? this["cmd_" + cmd[1][0]](cmd);
? ? ? ? ? ? } else {
? ? ? ? ? ? ? ? console.log("服務器處理函數cmd_" + cmd[1][0] + " 不存在");
? ? ? ? ? ? }
? ? ? ? })
? ? }
? ? this.frame_index++;????//下一幀
? ? cc.game.step();????//進行渲染
? ? ...
}
cmd_input(cmd) {
? ? //在players中匹配到玩家,并更新輸入狀態
? ? this.players.forEach((p) => {
? ? ? ? if (p.sessionId == cmd[0]) {
? ? ? ? ? ? p.updateInput(cmd[1][1])
? ? ? ? }
? ? })
}
在幀信息處理函數里,將玩家輸入同步到客戶端的CyPlayer里,然后其他組件里(例如CharacterController)中檢查并響應CyPlayer的變化。
InputManager和CyPlayer中需要同步的屬性可以根據需要隨便設置,而且不需要修改服務器代碼,十分方便。
?
客戶端隨機
為了保證客戶端之間的計算結果一致,我們需要使用偽隨機函數(線性同余生成器)。
seededRandom(max = 1, min = 0) {? ? this.seed = (this.seed * 9301 + 49297) % 233280;
? ? let rnd = this.seed / 233280.0;
? ? return min + rnd * (max - min);
}
里面的this.seed就是隨機種子,在創建房間的時候生成一個種子,分發到玩家手里,就可以保存玩家之間的隨機數返回相同的結果。
客戶端需要用seededRandom()代替原有的隨機函數Math.random()。當然不一定替換所有的,只需替換必要的。比如粒子特效中的隨機,沒有必要保證所有客戶端里的特效都一致,但是玩家出生位置等重要信息就必須使用seededRandom來保證一致性了。
seededRandom返回的結果是有嚴格的順序的,在使用此函數獲取隨機值的時候一定要保證代碼執行的順序,某些函數比如發生碰撞之后的回調,不確定是否是嚴格按照順序執行的,目前在測試比較少沒有發現不一致的情況。
客戶端邏輯
除了上面提到的用戶輸入,隨機函數等,客戶端可以使用ccc自帶的其他組件。比如動畫,Action,粒子特效,物理引擎,碰撞collider等,基本上與單機游戲開發無異。
以下是游戲gif圖,
可以看到左側玩家開始游戲后數秒后,右側玩家才點擊加入進入房間,經過短暫的追幀之后,兩邊實現了幀同步,之后的拾取,攻擊碰撞判定在兩個客戶端中都沒有出現不同步的現象。
物理引擎確定性問題
上面的展示畢竟時間短,情況簡單,為了測試復雜情況下的同步問題,我寫了簡單的AI。
scripts/AIManager.tsscripts/AIController.ts
在執行數秒之后就可以發現肉眼可見的幀不同步的現象
我將追幀的時候一次執行多幀的代碼注釋之后,情況有了好轉
nextTick() {? ? this.runTick();
? ? if (this.frames.length - this.frame_index > 100) {
? ? ? ? //當緩存幀過多時,一次處理多個幀信息
? ? ? ? // for (let i = 0; i < 50; i++) {
? ? ? ? // ? ? this.runTick();
? ? ? ? // }
? ? ? ? this.frame_inv = 0;
? ? }else if (this.frames.length - this.frame_index > this.serverFrameAcc){
? ? ? ? this.frame_inv = 0;
? ? } else {
? ? ? ? if (this.readyToControl == false) {
? ? ? ? ? ? this.readyToControl = true;
? ? ? ? ? ? this.round.onReadyToControl();
? ? ? ? }
? ? ? ? this.frame_inv = 1000 / (this.serverFrameRate * (this.serverFrameAcc + 1));
? ? }
? ? setTimeout(this.nextTick.bind(this), this.frame_inv)
}
猜測是追幀的時候執行過快導致box2d執行結果不一致,畢竟box2d并不是確定性的物理引擎,而且不確定性比我現象的要嚴重的多(也可能是其他原因導致的,畢竟測試的比較少)。
總結
由于ccc自帶物理引擎box2d的不確定性,目前還不能完美的幀同步,除非深入調試box2d以確保計算結果的一致。不過如果設計的游戲不需要物理引擎,比如回合制,或者塔防等,目前的代碼還是可以勝任。
以下是服務器和客戶端的項目github地址,如果有人感興趣歡迎fork。
客戶端項目:https://github.com/cyclegtx/cocos2dx-iog-lockstep-sync
服務器端項目:https://github.com/cyclegtx/colyseus-iog-lockstep-sync
?
總結
以上是生活随笔為你收集整理的io类游戏快速开发 2的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 阿里云Linux的mysql安装,使用y
- 下一篇: Koa与Node.js开发实战(1)——