手機網遊實時同步方案
阿新 • • 發佈:2018-12-27
網路延遲是所有實時同步的遊戲都會遇到的問題,下面是關於實時同步問題的一些思考和處理方法。具體的解決方法可能比較特殊,首先這裡的伺服器並不跑定時器(除了一個遊戲結束倒計時的定時器),由前端驅動,延遲的情況下主要是由前端來預測或糾正,伺服器輔助,處理和轉發,據我的瞭解好像沒什麼人這樣子搞吧。所以看完如果覺得我這邊有考慮不周的,或者有更好的思路,歡迎拍磚 / 交流
首先這是一個兩邊出兵對攻的遊戲,只有2個玩家,而戰場上士兵/英雄的數量也不會太多,最多不會超過50個吧,士兵都是有AI的,不被玩家控制。玩家能做的操作很有限,這裡只以出兵這個操作為例。在遊戲開始的時候,會有一次校時操作,這個後面會說到。
玩家A在Tick1點出兵,播放各種出兵特效之後,Tick1 + Delay = Tick2,在Tick2這個時間才會真正出兵,Delay等於擡手時間,也就是一個允許的延遲時間,並不等於網路延遲,應該理解為一個前搖動畫的播放時間,這個時間越長越好,在不影響玩家體驗的前提下(例如弄一個士兵生產佇列,從出兵指令發出到士兵生產完畢,這可需要不少時間)。
這時候玩家請求伺服器,請求的內容包含Tick1 + 出兵指令,伺服器收到指令,對伺服器而言,這時可能有兩種情況,第一種是在Tick2之前收到,第二種是在Tick2之後收到
在Tick2之前收到,伺服器可以轉發操作,告訴所有的客戶端,我們在Tick2出兵(包的內容包含Tick2和出兵指令),這時候對另外一個玩家,他可能看不到出兵的特效,而是直接看到出兵,這沒有問題,或者他看到的特效在時間上晚了一些,這都是可以接受的,不影響遊戲邏輯的。
這是前後端網路正常時的情況
在Tick2之後收到,伺服器可以丟棄這個操作,或者立刻執行這個操作,這時候已經是Tick3了,廣播給所有玩家,在Tick3出一個兵,因為這時伺服器已經是Tick3,那麼客戶端收到出兵指令的時間是已經過了的,這時候會導致客戶端一定會進行一次糾正,那麼我們可以再延一延,讓這個指令在Tick4再執行,Tick3 + Delay2 = Tick4。丟棄,Tick3,Tick4執行的結果分別是,玩家的操作無效,戰鬥需要經過一次糾正(糾正會導致玩家看到奇怪的東西),以及玩家的操作延遲了,但是看到的東西是正常的,只是我的操作晚了一段時間而已。
伺服器延遲執行這個指令,客戶端在生效時間之前收到,大概是這樣子的
所有的指令從觸發到生效都不是立即的,都是經過延遲的,哪怕我的網路延遲在1ms之內,我這個指令都要在delay時間之後才執行,而前後端都會有一個指令Queue,來記錄在哪一幀應該執行哪一個指令。
所有的指令,只要做到在第幾幀執行的統一,就可以保證結果的統一。
客戶端接收到指令的時候,這時候又有2種可能,分別是在Tick2之前,以及Tick2之後
在Tick2之前收到,那麼皆大歡喜。在Tick2之後收到,那麼我們這個操作就延遲了,可能要進行糾正,這裡說可能,因為還有一線生機。就是玩家點出兵之後,並不等伺服器返回,而是在Tick2時自動出兵,這樣子的一個好處是,客戶端在網路比較差的情況下,所有的東西都可以得到反饋。雖然這些反饋可能是錯誤的,但是沒關係,關鍵是玩家體驗的流暢,錯了最後肯定會被糾正,只要處理好糾正時候的表現就可以了。
如果自動客戶端自行預測
而且這樣子做不一定是錯的,如果伺服器在Tick2之前收到我的請求,那麼伺服器會在Tick2執行出兵的邏輯,而我在Tick2之後收到伺服器的響應,但我也在Tick2執行了出兵的邏輯,這是同一幀,這種情況下是不需要糾正的,因為雖然延遲了,但是我們正確預測到了結果。需要糾正的有兩種情況,第一種是B玩家在Tick2之後收到,因為B玩家是無法預測到A玩家在Tick1點選了出兵,在Tick2出了一個兵。第二種是伺服器在Tick2之後才收到,那麼兩個端可能都需要糾正,這裡說可能,因為還有另外的一線生機。
如果我們不做預測,而是等伺服器的結果,根據伺服器的結果來執行,這種情況下,客戶端的表現是流暢的,戰鬥是流暢的,只是我的點選沒有立即反應而已。伺服器在Tick3下發(內容包含Tick4 + 出兵指令),在Tick4執行,客戶端只要在Tick4之前能收到,就可以在Tick4執行出兵,那麼結果也是正確的。這種甚至我們可以以伺服器收到的時間為準,伺服器每次收到都在當前時間的基礎上延遲一段時間來執行,完全無視玩家點選的時間,只要客戶端在這段延遲時間內收到結果,也是不需要糾正的。
這兩種一線生機的區別在於,一個是忽略了包從伺服器回來的延遲,一個是忽略了包到伺服器的延遲,一個是保證客戶端流暢,一個是儘量避免糾正。具體要在實際環境中測試才能知道,哪種更適合我們遊戲。由於所有指令的執行會放在一個佇列中,所以這幾種方式的切換隻需要改動少量的程式碼。當我們拿捏不準的時候,儘可能讓這部分可以被靈活地調整。整個伺服器這邊的Tick機制就是可以被靈活調整的(因為我不跑定時器)。
最後就是糾正了,首先糾正是由前端發起的,當然後端也知道前端延了,後端的處理其實比較隨意,無關緊要,前面說的,丟棄,立即執行,或者延遲執行,都是可以的,但看上去延遲執行是個更好的主意,這個也看具體遊戲吧。前端如何知道自己延了,這個問題其實很簡單,在戰鬥開始的時候進行一次校時,然後雙方都以一致的頻率,例如每秒10幀,從第0幀開始前進(實際上伺服器只是記錄一個時間,並不跑定時器)。
其實前後端只要記錄了第0幀的這個開始時間,是很容易算出當前是在第幾幀的,前後端的這個開始時間並不相等,只是邏輯上相對而已。當然,這個時間也會存在誤差,誤差的結果就是,一邊快一點或者慢一點。但是沒關係,我們不是按照時間來算,我們是按照幀來算,咱不要求同一個時間兩個端的內容是完完全全的一樣,咱只要求結果一樣。例如一臺裝置效能很差,每秒5幀,但是他的結果不會錯誤,遊戲10秒後結束,他這邊就20秒後結束,但結束時的結果是一樣的就行,至於操作,如果存在這樣的可能性,那伺服器就把操作延遲執行,對前端而已可能按下去要等好幾秒才能響應,但這時候都已經不是網路延遲的問題,是裝置卡頓的問題。前端一直在跑,但是跑不過來,要解決這個問題,只能是換手機,當然我們自己要保證在效能很差的手機上能跑起來才行,例如兩三年前的機器,但實際上咱也不需要花太多心思在這上面,這個玩家這麼爛的手機都不捨得換,怎麼捨得往遊戲裡面充錢呢?直接放棄他了。
伺服器並不跑計時器,這是什麼原因呢?有一部分是效能的原因,每個子彈,怪物,BUFF這些可能都需要掛計時器,我不希望伺服器掛太多的計時器。比較大的一部分是讓拿捏不準的這部分更加的可控。例如這個遊戲不做實時同步了,讓事情變得不是那麼糟糕。只需要少許的改動,就可以實現伺服器的校驗。
不跑計時器怎麼做呢?很簡單,首先你還是需要有一個計時管理的,類似Schedule,遊戲邏輯中新增的計時器全放到這裡面,當客戶端請求的時候,我們先從上一次計算的時間模擬到當前時間,將當前時間減去上次的時間,算出有N幀,然後直接 loop N次計時器,這個loop和客戶端的loop相比,就是少了一些判斷逝去時間是否小於最小間隔時間,如果是則sleep一下這樣的程式碼,而改成了一個for迴圈,迴圈N次。當然,loop的不一定只是計時器,但是一定包含計時器,而且最主要的就是計時器。
loop完之後,將上次的時間記錄為當前時間,然後進行校驗,轉發指令。這時並不執行指令,而是把通過校驗的指令放入指令佇列中,等待下次執行。指令執行的結果是什麼,伺服器現在是不知道的。可以看到是客戶端的請求來驅動伺服器,而不是計時器來驅動伺服器執行邏輯。這就有點類似回合制了。那麼還有一個問題,假設客戶端都不發請求,那伺服器不就動不了了?伺服器會跑一個計時器,這個計時器只做一件事,就是遊戲結束,模擬玩家發一個空的指令到伺服器這邊,伺服器loop到遊戲結束,然後下發戰鬥結果。
不跑計時器,如何讓當這個遊戲從實時同步變成非實時同步變得簡單呢?可以這樣子,客戶端所有傳送的指令,全部都在客戶端直接執行,但是記錄下來,然後在遊戲結束時,請求一次伺服器,將指令佇列發給伺服器,伺服器只需要設定指令佇列,然後執行一次loop到遊戲結束的呼叫,自然可以校驗到戰鬥結果。這個非實時同步的需求,本身就是存在的,例如單刷副本,這個我們是需要校驗的。所以只需要在外面進行一層包裝,就可以替換他們。而且改動的程式碼量並不多。
接下來還是說糾正的問題,當客戶端發現伺服器下來的包延遲了,超過可接收的時間了,客戶端需要向伺服器請求最新的資料來重新整理,這個過程中,客戶端是正常執行的,然後當資料下來之後,大概花0.5-1秒的時候來重新整理資料。將本地有伺服器沒有的物件幹掉,本地沒有伺服器有的物件建立,兩邊都有的資料進行一個平滑的插值計算,讓他在這段時間過渡到最新的資料。這裡一定會產生一些玩家看起來很奇怪的畫面,在上面加一個正在重新連線...,玩家應該會比較能接受這小段看上去奇怪的畫面。
過渡的時間內,本地的實時邏輯幀是一直在正常執行的,它記錄伺服器當前執行到第幾幀了,本地還有另外一個幀變數,這個變量表示當前邏輯幀,這兩個幀都是邏輯幀,正常而言,這兩個幀是相等的,但當糾正發生時,當前幀會小於實時幀,例如第200幀發現我本地需要糾正,然後請求伺服器,伺服器下發了最新,也就是201幀的結果下來,到客戶端這邊,已經是207幀了,但重置到最新的資料,也就是201幀,然後開始加速執行。執行到兩個邏輯幀相等,即恢復正常速度執行。
首先加速是可行的,因為伺服器可以瞬間執行完,那麼客戶端為什麼不能加速執行完呢?最關鍵的是,每一幀的結果都是一樣的,前面從200幀之前,客戶端有一部分的結果就已經錯誤了,糾正的本質是把錯誤的結果丟棄,然後重新設定正確的結果。再繼續執行。
另外假設是客戶端跑得太慢,跟不上伺服器的話,那實際上伺服器使用延遲執行的方式,基本上是不會有糾正的需求的,因為所有指令對於這臺裝置而言,都是未發生的,客戶端只能慢慢跑,慢慢演示戰鬥結果。這樣的裝置上,動畫都是一卡一卡的,問題已經從網路延遲的問題轉變為裝置的效能問題了,這是另外一個優化的話題了。
邏輯幀和顯示幀的分離,有這麼幾個目的,邏輯幀用來保證邏輯的準確性,也就是這場遊戲一共跑2000幀,執行2000次邏輯處理。這部分是前後端共用的。至於前端的顯示重新整理了8000幀,還是6000幀,影響的只是動畫的平滑度而已,不影響結果,前端花100秒還是200秒來跑完遊戲,也是不影響結果的。第二是方便糾錯和加速,邏輯幀和顯示幀的互動流程是這樣的,每次邏輯幀執行的時候,會修改類似位置這樣的屬性,或者改變一些狀態。顯示幀負責平滑地從當前位置,狀態改變到邏輯幀修改的位置和狀態,並播放相應的動畫。邏輯幀並不直接改變這些屬性,而是將這些修改放到一個每個物件特有的資料元件中,邏輯判斷時,是取這些裡面的資料來判斷,而不是顯示層的資料。每次更新,顯示幀都會做一個從當前過渡到該資料的邏輯。邏輯幀的頻率加快了,對顯示幀而已也是沒有影響的,兩者互相獨立,邏輯幀只管邏輯處理還有寫資料,顯示幀只管取出資料來進行顯示。
客戶端這邊,邏輯幀和顯示幀都是由同一個Schedule來驅動執行的,但頻率不同,他們比較大的一點區別是,邏輯幀每一幀的dt是一個固定的值,例如每秒10次邏輯幀,那麼這個dt就固定是0.1,而顯示幀是根據實際的逝去時間來作為dt的。這裡的dt指的是每次update傳入的逝去時間。每次邏輯幀寫入的資料除了位置狀態等資料,還會包含一個需要顯示幀在多長時間內模擬完成的資料,這個也是一個定值0.1。顯示幀拿到位置資料,我要移動去哪,再拿到時間資料,多長時間內移動到,那麼就可以執行平滑顯示的邏輯了。
至於2G 3G的網路延遲,這個和PC的延遲有什麼區別呢?一個是延遲會更大,另外一個就是不穩定。延遲的資料是多少,這個資料意義不是太大,因為這個資料只有一些參考意義,實際的延遲並不是按照這個資料來,而是很不穩定的。可能你蹲個廁所,把廁所門一關,訊號一下子就差了很多,這是很常見的。因為移動裝置是可移動的,移動到不同的地方都可能有不同的延遲,這時候可能有兩個資料會比較有用,一個是2G 3G比較快的速度是怎樣,另一個是2G 3G比較慢的速度是怎樣。在這樣差的網路下,必定會出現很多次的延遲糾正,但只要我們能收到訊息,遊戲就可以執行。客戶端模擬是可以獲得高延遲下好很多的體驗,因為每次點選都有反應,雖然真正生效的時間延遲了,但你的操作還是生效了。2G 3G的另外一個問題就是斷線,斷線重連其實又是另外一個略有蛋疼的話題了。
在2G 3G下,在高延遲下給使用者帶來不差的體驗,這個是實時同步比較難做到的,關鍵看遊戲指令的可延遲性,實時要求的高低,玩家操作的頻繁度,以及錯誤糾正時的體驗,能否讓玩家接受。如果不行,那麼就需要弱化它,例如在進入遊戲之前,檢測一下ping值,如果太高,則提醒玩家,你當前的網路延遲較高,讓玩家自己決定在不在高延遲的環境下游戲。實際上對於網路延遲,絕大多數的玩家都不陌生,打了一劑預防針之後,對後面的反應不及時的體驗包容性會高一點。在延遲高的情況下,建議玩家去刷單機副本,也是一個可行的方案。另外,還有一個方案,雖然有點欺騙玩家,但只要玩家覺得遊戲流暢就可以了,就是派AI的機器人跟高延遲的玩家打。
整個同步的想法目前正在試驗中,但理論上是可行的,看上去可能有些複雜,但實際的程式碼框架搭建起來之後,程式碼寫其實是很簡潔的,因為所有的邏輯都不需要關注延遲。並且可能會變化的部分被隔離開了,每個東西儘可能地獨立。當然,在實現的過程中肯定是會碰到更多的問題,有問題解決就是了,關鍵是要有思路,有解決問題的方向。
整個想法的落地大概是這樣的,前後端都是用的C++,前端是2dx,後端是kxserver,後端搭建一套模擬2dx的框架,實現一份簡化版的Director,Schedule,Node,Component,然後制定編寫邏輯相關元件的規則,用自己寫的訊息機制來傳遞訊息,當有一部分邏輯需要執行到顯示相關的內容時,可以用事件來處理,客戶端存在這個監聽者,而服務端不存在。另外也可以使用預處理,根據是否定義了Running In Server這樣一個巨集來預處理一些程式碼。
設計中是分了比較多的層和模組,來確保萬一哪個不行了,不影響到其他的程式碼。在落地的過程中會持續地完善程式碼,打磨,驗證想法。希望這個專案結束後,能沉澱下一套Cocos2d-x網路實時同步的規範和前後端簡易框架,來複用到其他有類似需求的專案中。