技术博客

自定义 MultiplayerAPI:把网络层换成本地日志的离线回放

第 119 / 149 篇 · 2025-12-07 · godot · 网络同步架构回放系统

线上多人 bug 是调试的地狱:问题发生在别人的机器上、别的网络环境里、你自己看不到的瞬间。成熟的团队都有一个共同武器:网络流量录制 + 离线回放。把对局期间每一个进出的包按时间戳存下来,出 bug 让玩家把日志发回来,开发机上一放,整场对局在断网状态下原样重演。Godot 的多层架构(七-1)让这个功能的实现成本低得意外——但先要想清楚在哪一层动手。

两个注入点

回顾一下分层:语义层(你的节点树)下面是编解码层(SceneMultiplayer),再下面是传输层(MultiplayerPeer,接口只有 put/get/poll)。做回放系统有两个候选注入点:

方案 A:自定义 MultiplayerPeer。 写一个 RecordingPeer,内部包一个真实的 ENet peer,每次 put_packet 先写日志再转发,get_packet 时优先喂录制的数据。回放时换成 PlaybackPeer:忽略所有网络操作,按录制时间戳从日志里吐出包。游戏代码和 SceneMultiplayer 完全不知情——它们看到的只是一个"延迟有点奇怪的 peer"。

方案 B:自定义 MultiplayerAPI。 Godot 允许 set_multiplayer(custom, root_path) 给子树换编解码层,GDExtension 里还有专门的 MultiplayerAPIExtension。自定义实现要自己处理 RPC 编解码、NodePath 缓存、同步分发——等于重写 SceneMultiplayer 的一大半,工作量和长期维护成本都高一个量级。

结论先行:回放系统选方案 A。编解码层不是你要替换的语义——你想替换的是"包从哪来、到哪去",这正是传输层接口的表达。方案 B 留给真正要改 RPC 语义的场景(比如离线双人同机、把 RPC 映射成本地函数调用的工具链)。方案 A 里 SceneMultiplayer 的 checksum 校验、NodePath 缓存、delta 解码全部原样工作,回放保真度由传输层天然保证。

录制格式

每条记录三个字段:

[tick, direction, payload]
  • tick:本地物理 tick 号(不用 wall clock——回放要挂在确定性时钟上,见下);
  • direction:发送还是接收,回放时方向反着读;
  • payload:包的原始字节。

格式本身一个 PackedByteArray 顺序写就行,文件头带 magic、引擎版本、录制时的 RPC checksum(加载时校验,版本不匹配直接拒——省了"回放半天发现脚本不一致"的冤枉路)。发送和接收都录:只录接收侧省一半体积,但发送侧是"本地节点当时认为自己在做什么"的证据,排查预测/和解类 bug(七-5)时价值更高。体积预算:动作游戏一人一分钟进出各几 KB~几十 KB, gzip 后整场对局几 MB,让玩家传文件毫无压力。

回放时钟:和 wall clock 脱钩

回放最容易写错的不是读写,是时钟。真实对局里包到达受网络延迟支配;回放如果按录制时的"到达间隔"放包,等于把当时的抖动原样重放一遍——你只想重放游戏状态演化,不想重放网络噪声(除非你就在查抖动本身)。

做法:回放时本地推进物理 tick,每 tick 查日志,把所有 tick <= 当前tick 的包一口气喂完。包提前到达没关系(协议本身容忍乱序),滞后到达在回放里被消除——回放运行的是"理想网络"下的对局。这有一个前提:游戏逻辑必须是 tick 驱动的确定性函数。状态同步类架构天然满足(服务器状态在包里,本地怎么调度包只是观察视角);输入回放的 lockstep 架构则要求物理确定性(四-19 的结论:Godot 物理目前不保证,lockstep 回放要慎用这个方案)。

回放播放控制(暂停、减速、跳转)因此免费:跳 tick 就是重建状态 + 快进喂包。跳到 tick $N$ 需要 tick 0 的初始快照加 $[0, N]$ 的包;想支持任意跳转,定期在日志里埋快照帧。

录制 / 回放:注入点在传输层,SceneMultiplayer 无感 录制(线上对局) SceneMultiplayer RecordingPeer 写日志+转发 ENet → 网络 replay.log [tick, 方向, 字节] 回放(开发机离线) SceneMultiplayer PlaybackPeer 按 tick 喂包 无网络 回放时钟纪律 按物理 tick 放包,不按 wall clock 消除录制时的网络抖动 状态同步架构天然可用; lockstep 需物理确定性(四-19)

实现骨架

class_name RecordingPeer
extends MultiplayerPeerExtension  # 或直接包一层委托

var inner: MultiplayerPeer
var log_file: FileAccess

func _put_packet(p: PackedByteArray) -> Error:
    _write_record(Time.get_ticks_msec(), 0, p)  # 0 = 发送
    return inner.put_packet(p)

func _get_packet() -> PackedByteArray:
    var p := inner.get_packet()
    _write_record(Time.get_ticks_msec(), 1, p)  # 1 = 接收
    return p

回放侧 PlaybackPeer 在 _poll() 里按当前 tick 把日志中到期的包灌进收包队列,_get_available_packet_count() 返回队列长度——SceneMultiplayer 每帧 poll 拿包,完全照常工作。挂法就一行:multiplayer.multiplayer_peer = playback_peer,整个游戏以为自己在联网。

边界与注意

  • RPC checksum:录制文件头存下当时的 checksum,回放加载时和当前工程的 RPC 表比对,不一致拒绝加载——脚本版本和录制对不上时回放出来的东西毫无参考价值;
  • 墙钟依赖的逻辑:Time.get_ticks_msec() 驱动的超时(比如技能 CD)在回放里语义不变,但任何"等 3 秒真实时间"的逻辑回放时行为不同,回放环境要把这类调用也 tick 化;
  • 录制开销:put_packet 里多一次文件写,对发包频率敏感的帧可以攒 buffer 批量落盘(每 64 条 flush 一次),丢电丢最后一批数据可接受;
  • 隐私合规:录制含全部游戏内通信,发给开发者的日志要脱敏或知情同意,商用项目写进用户协议。

这套东西从零写大概两天的量,换来的调试效率是数量级的——尤其预测/和解类 bug,没有回放基本只能靠复现运气。

本文基于 Godot 4.2–4.4;MultiplayerPeerExtension 用于 GDScript 扩展 peer 接口,签名以当前版本文档为准。