几乎市面上所有关于游戏网络编程的资料,都在讲快节奏游戏该怎么同步。经典的 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); // 把几小时的产出又加了一遍
同样一波几小时的资源产出,被反复算了两次。没有任何报错。游戏里的资源计数器会一路高歌猛进,跑得比真实数值快上一大截,直到下一次从服务器拿到准确数据,又猛地跳回正常水位。
解决办法其实直接得近乎粗暴:任何被投影过的数值,必须随身携带它被投影到的那个时间点。投影出来的余额,就配投影时刻的时间戳。这样客户端接手后,只需继续算后面那一小段时间的增量,再也不会凭空多出几小时收入。
对于一个舰队要走好几个小时才能跨过星系的游戏,最要命的性能问题从来不是延迟,而是在漫长的时间线上,让几千个帝国各自账本始终不差毫厘。
热门跟贴