线上多人 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]$ 的包;想支持任意跳转,定期在日志里埋快照帧。
实现骨架
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 接口,签名以当前版本文档为准。