Phaser 联机小游戏状态同步:插值、预测和回滚边界

从 Phaser 联机小游戏的快照同步、客户端预测、插值、回滚和表现修正出发,讨论可上线的状态同步设计。

联机不是把位置广播出去

Phaser 很适合做轻量联机小游戏,WebSocket 一接,玩家位置一发,其他人一收,看起来就能跑。但真实上线后会马上遇到问题:远端玩家抖动,本地玩家被服务器拉回,攻击看起来命中却没伤害,弱网玩家像瞬移,结算结果和画面不一致。联机难点不在 Phaser,而在状态权威和表现延迟之间的取舍。

我做过一个 2D 竞技房间小游戏,第一版每 50ms 广播玩家位置。局域网测试很顺,公网一上就抖。后来我们发现,客户端直接把收到的位置赋给远端 sprite,等于把网络抖动完整展示给玩家。本地玩家则完全相信自己移动,服务器一校正就被拉回。玩家感受到的不是“网络有延迟”,而是“游戏不跟手”。

联机同步需要先回答三个问题:谁拥有权威结果,客户端能预测什么,表现层允许多大程度的平滑。Phaser 负责展示和本地交互,不能替你决定同步模型。

sequenceDiagram
    participant Client as 本地客户端
    participant Server as 权威服务器
    participant Remote as 其他客户端
    Client->>Client: 采集输入并本地预测
    Client->>Server: 发送 input(seq, tick)
    Server->>Server: 权威模拟
    Server->>Client: 返回 snapshot(tick, state)
    Client->>Client: 对比预测并修正
    Server->>Remote: 广播 snapshot
    Remote->>Remote: 插值显示远端玩家

先选同步模型

轻量联机通常有几种模型。第一种是服务器权威快照,客户端发送输入,服务器模拟并广播状态。公平性好,但本地需要预测才能跟手。第二种是客户端上报位置,服务器转发或做粗校验。实现快,但容易作弊和不一致。第三种是房主权威,适合好友局和低成本项目,但房主断线和作弊风险更高。

选择模型要看玩法。合作闯关、弱竞争小游戏可以接受较宽松的同步;实时对战、排行榜、奖励强相关玩法就要更严格。不要用“只是 H5 小游戏”作为放弃权威的理由。只要结果影响奖励或排名,就要认真设计权威边界。

Phaser 客户端要清楚自己能决定什么。它可以立即播放移动、攻击前摇、按钮反馈和命中特效预演,但最终伤害、死亡、得分、掉落和结算应该由权威结果确认。表现可以先行,资产和胜负不能靠客户端自说自话。

本地预测提升手感

如果每次移动都等服务器确认,玩家会觉得输入延迟。本地预测的思路是:玩家按下方向键时,客户端立即按同样规则移动本地角色,同时把输入发给服务器。服务器稍后返回权威状态,客户端检查自己的预测是否一致。

预测要求客户端和服务器有相近的移动规则。至少输入序号、tick、速度、碰撞和边界要一致。如果服务器用一套规则,客户端用另一套表现规则,修正会频繁发生。轻量项目可以只预测移动,不预测复杂战斗。

预测错误时不要瞬间拉回,除非差异很大。可以小幅平滑修正位置,同时保留玩家操作反馈。差异过大说明发生了碰撞、传送、受击击退或作弊校正,这时需要更明确的表现,比如受击、硬直或回滚到安全点。

远端玩家用插值,不要直接赋值

远端玩家没有本地输入,客户端只能根据服务器快照显示。如果直接把每个快照的位置赋给 sprite,网络 jitter 会变成画面抖动。插值的做法是让远端显示时间比服务器时间慢一点,例如慢 100ms,在两个已收到快照之间平滑过渡。

插值延迟是有代价的。远端玩家看起来更顺,但显示略晚。对于大多数休闲联机,这个代价可以接受。玩家更讨厌抖动和瞬移,而不是远端位置晚几十毫秒。强对抗射击则要更复杂的命中补偿。

如果快照丢失,可以短时间外推,但外推要保守。根据最近速度预测 100ms 可以,预测 1 秒就会乱。超过阈值还没收到新快照,应进入等待或断线表现,而不是让远端角色继续穿墙奔跑。

回滚只用于值得的部分

回滚听起来高级,但不是所有 Phaser 联机项目都需要完整回滚。完整回滚要求保存历史状态、重放输入、保证模拟确定性,对物理、随机数和浮点都有要求。H5 小游戏如果只是轻量对战,完整回滚成本可能超过收益。

可以采用局部回滚或状态重演。比如本地预测移动,服务器确认后如果差异小就平滑修正,差异大就把角色状态回到权威 tick,再重放本地未确认输入。战斗命中则只做服务器确认,不在客户端完整回滚世界。

回滚边界要写清楚。哪些对象参与预测,哪些只表现插值,哪些完全等服务器。玩家角色可以预测,远端玩家插值,弹幕可以服务器快照或本地表现,奖励和结算必须等权威。边界不清,回滚会把表现系统也卷进去。

输入序号和快照版本很关键

状态同步离不开序号。客户端发送输入时带上 seq 和 tick,服务器返回快照时告诉客户端已经处理到哪个输入。客户端就能丢弃已确认输入,只重放未确认输入。没有序号,就无法判断服务器状态对应哪一次操作。

快照也要有版本。不同版本客户端字段可能不同,服务器下发的状态结构也可能升级。H5 热更新场景下,房间里可能短时间存在不同版本客户端。协议版本不一致时,要拒绝进房或走兼容字段,而不是让客户端半解析。

消息还要区分可靠和非可靠语义。WebSocket 本身是有序可靠,但应用层仍然要区分快照、输入、房间事件、聊天、结算。快照可以覆盖旧快照,结算事件不能丢,输入需要顺序。协议语义清楚,客户端逻辑才不会混乱。

Phaser 表现层要接受修正

状态同步最终会落到 Phaser sprite、动画、特效和 UI。表现层要能接受权威修正。比如本地预测打出攻击,但服务器判定未命中,客户端要停止后续伤害表现,或把命中特效降级为挥空。不要让表现一旦播放就无法撤回。

动画也要分本地和远端。本地玩家动画跟随预测输入,远端玩家动画跟随插值速度和服务器动作状态。受击、死亡、技能释放这类权威事件,要用服务器事件驱动。否则远端玩家看起来在跑,服务器已经判死,会非常割裂。

UI 同样要处理待确认状态。玩家点击拾取奖励,本地可以先播放按钮反馈,但背包数量最好等服务器确认。若确认失败,给出原因并恢复按钮。联机游戏里,反馈快和结果权威并不矛盾。

一个轻量同步骨架

下面示例展示本地预测的关键结构。真实项目需要和服务器协议配合,但客户端侧至少要保存未确认输入和最近权威快照。

type InputFrame = { seq: number; tick: number; x: number; y: number; attack: boolean };

class ClientPrediction {
  pending: InputFrame[] = [];
  seq = 0;

  pushInput(input: Omit<InputFrame, 'seq'>) {
    const frame = { ...input, seq: ++this.seq };
    this.pending.push(frame);
    return frame;
  }

  acknowledge(lastSeq: number) {
    this.pending = this.pending.filter(frame => frame.seq > lastSeq);
  }
}

这段代码解决不了同步本身,但它让客户端有能力知道哪些输入已经被服务器确认。后续才能做重放、修正和诊断。没有 pending input 列表,本地预测只是把角色提前移动,无法闭环。

调试同步要记录时间线

联机问题靠录屏很难定位。需要记录时间线:本地输入 seq、发送时间、服务器 tick、收到快照时间、预测位置、权威位置、修正距离、ping、jitter、丢包或重连次数。开发版可以把这些显示在角落。

还可以做输入回放。把一局游戏的本地输入和服务器快照保存下来,离线重放客户端表现。这样偶发拉扯、瞬移、命中不一致可以重复分析。Phaser 表现层如果完全依赖实时网络,就很难复现线上问题。

网络模拟也很重要。本地开发要能模拟 80ms 延迟、200ms 延迟、抖动、短暂断线和重连。只在本机 localhost 测联机,等于没有测联机。

上线前检查清单

上线前检查:权威边界是否明确,本地预测是否只覆盖可预测对象,远端是否插值,快照是否有 tick 和版本,输入是否有 seq,修正是否平滑,断线重连是否清理旧状态,结算是否只信服务器,调试面板是否显示 ping、jitter 和修正距离。

还要测弱网、切后台、锁屏恢复、房主离开、重复进房、版本不一致、服务器重启。联机 Bug 很多不在正常输入里,而在连接生命周期里。Phaser 客户端要把网络状态当成一等状态,而不是藏在 WebSocket 回调里。

断线重连不是重新 start Scene

联机游戏里,断线重连很容易被低估。最粗暴的做法是 WebSocket 断开后直接回大厅,玩家体验很差;另一种粗暴做法是重连后继续使用旧 Scene 状态,结果服务器已经进入下一 tick,客户端还停在旧世界。更稳的做法是把连接状态显式纳入游戏状态:connected、unstable、reconnecting、resyncing、failed。

重连成功后,客户端应该请求一份完整快照,而不是只接着收增量消息。完整快照用于重建权威世界,本地未确认输入要根据服务器确认情况决定是否丢弃或重放。远端插值缓冲也要清空,否则旧快照会和新快照混在一起,角色可能瞬移。

UI 也要参与同步状态。重连中可以冻结输入或只允许本地表现,超过阈值后提示玩家。不要让玩家在断线期间继续做关键操作,最后再一次性失败。Phaser 表现层可以让等待过程不那么突兀,但规则层必须清楚地告诉玩家当前是否仍在房间内。

命中表现和权威结果的取舍

联机动作游戏最容易争论的是命中反馈。本地玩家挥刀看起来打到了远端玩家,要不要立刻飘伤害?如果完全等服务器,反馈慢;如果立刻飘最终伤害,服务器否定时又很尴尬。一个折中方案是本地先播放轻量命中预演,比如刀光、命中火花和手柄反馈,但血量变化、击杀、奖励和排行榜只等服务器确认。

远端玩家也类似。收到服务器 attack.start 事件时,可以播放攻击前摇;收到 hit.confirmed 再播放受击和扣血。这样表现有节奏,结果仍可信。不要让客户端根据远端动画自行判断命中,否则不同客户端会看到不同战斗结果。

如果玩法允许,可以把伤害反馈分成 predicted 和 confirmed 两种视觉。predicted 颜色更淡,confirmed 才进入正式血条和结算。轻竞技小游戏不一定需要这么复杂,但这个边界意识很重要:玩家操作反馈可以快,资产和胜负必须准。

命中争议还要有日志。一次攻击至少记录 attacker、target、clientTick、serverTick、技能 id 和服务器判定结果。玩家反馈“打中了没伤害”时,工程能知道是客户端预演、服务器否定,还是消息延迟导致表现顺序错乱。

这些日志不需要每局完整上传,可以在争议事件或调试开关下保留最近片段。关键是发生问题时,客户端能把画面现象和服务器裁决对应起来。

否则联机问题会长期停留在玩家感受和工程猜测之间。

这类证据越早进入协议和客户端日志,后续每一次同步调优越有依据。

结语

Phaser 做联机小游戏完全可行,但联机不是把位置广播出去。客户端预测解决本地手感,插值解决远端平滑,服务器权威解决公平和结果一致。回滚不是必选项,边界清楚才是必选项。

当你能解释每一个状态来自客户端预测、服务器快照还是表现插值,联机问题就不再是一团雾。Phaser 负责把体验表现出来,而同步模型决定这个体验是否可信。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页