幾乎市面上所有關于游戲網絡編程的資料,都在講快節奏游戲該怎么同步。經典的 UDP、客戶端預測、每秒鐘塞六十幀快照到家庭帶寬里,都在服務一個目的——讓操作手感不卡。但這一切,和我正在做的游戲幾乎毫無關系。
Old Light 是一款多人瀏覽器策略游戲。游戲里,一支艦隊橫穿銀河系需要好幾個小時;你把瀏覽器標簽頁關掉,后方經濟照樣自行運轉。哪怕一個操作來回延遲 300 毫秒,游戲里也完全感受不到。真正的難點在另一個方向:在漫長時段里,同時為數千個帝國保持數據正確性。
![]()
只發一次快照,余下全是增量
客戶端打開一個 WebSocket 連接。服務器只回一條消息——world.init,里面裝著這個玩家被允許看到的一切,外加服務器當前的時鐘。之后,所有變化都以world.delta的形式下發:一個極小的補丁包,客戶端拿到后自己合并到本地狀態里。
socket.on("world.init", (payload) => {state = new GameState(payload.world);clockOffset = payload.serverNow - Date.now();socket.on("world.delta", (delta) => state.applyDelta(delta));
整個協議就靠這兩個事件處理器撐著。不需要序列號,也不用插值緩沖。麻煩的點全卡在兩者的邊界上:當快照還在客戶端構建時,新的增量可能同時從服務器推過來。服務器端的處理方式很簡單——先把world.init準備完畢,再把 Socket 加入廣播房間,這樣構建中途觸發的 delta 絕不可能溜進新連接里,而且快照本身已經包含了那次變化。客戶端側則把初始化完成前抵達的增量先緩沖起來,等本地狀態建好再一次性排空。這兩步,任何一邊漏掉,都會導致正好在那個毫秒接進來的玩家,看到的世界和服務器不一致。
那個代碼片段里還有個clockOffset。服務器把自己時鐘打進每一條負載里,客戶端在啟動時只測量一次差值。從那之后,一切和時間有關的計算全部以服務器時鐘為準——畢竟玩家本機時間偏差幾分鐘是常有的事。
兩邊都做時間運算時,最容易踩的坑
Old Light 里的資源不是靠定時器逐個“跳動”的。服務器采用按需計算:上次持久化的余額,加上累積速率乘以經過的時間。客戶端跑的是同一套公式,所以即便在兩個網絡消息之間,游戲里的數字也能平滑地自己往上翻。
這就要求線路上傳輸的資源值,除了余額數字本身,還必須帶一個“錨點時間戳”,以便客戶端從那個時間點繼續向前推算。陷阱恰恰出在這個時間戳上。
當服務器響應一個請求時,它會基于當前時間,把數據庫里存儲的余額向前投影到“現在”。可數據庫那行記錄里保存的settledAt時間戳,仍然是上次真正落盤的時間——說不定是幾個小時以前。現在問題來了:把剛剛投影到當前時刻的新余額,配上那個早已過時的舊錨點,客戶端一拿到,又會重新做一次時間推進:
// 錯誤做法:新余額,舊錨點return { balance: projectedToNow, settledAt: row.settledAt };// 客戶端數秒后執行:display = balance + rate * minutesSince(settledAt); // 把幾小時的產出又加了一遍
同樣一波幾小時的資源產出,被反復算了兩次。沒有任何報錯。游戲里的資源計數器會一路高歌猛進,跑得比真實數值快上一大截,直到下一次從服務器拿到準確數據,又猛地跳回正常水位。
解決辦法其實直接得近乎粗暴:任何被投影過的數值,必須隨身攜帶它被投影到的那個時間點。投影出來的余額,就配投影時刻的時間戳。這樣客戶端接手后,只需繼續算后面那一小段時間的增量,再也不會憑空多出幾小時收入。
對于一個艦隊要走好幾個小時才能跨過星系的游戲,最要命的性能問題從來不是延遲,而是在漫長的時間線上,讓幾千個帝國各自賬本始終不差毫厘。
特別聲明:以上內容(如有圖片或視頻亦包括在內)為自媒體平臺“網易號”用戶上傳并發布,本平臺僅提供信息存儲服務。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.