16.3 确定性回放与回归
前两节把「正常路径」测住了:逻辑对、像素对。但真实项目里最耗时的 bug 往往不是这些,而是玩家报来的一句「我跳起来的时候卡住了」「有一次金币没算分」——你知道它发生过,却复现不出来。
这一节讲确定性回放:把每一帧的输入录下来,出问题时原样喂回去,让 bug 从「偶发」变成「必然」。
本节是第 16 章收尾,也是全章唯一直接面向「线上问题」的一节。核心是一个朴素的事实:只要逻辑是确定性的、输入序列被完整记录,那么一次运行就可以被逐帧重放。我们会把《方块冲刺》的一个 bug 复现过程写成一条测试,最后诚实交代 Cocos Creator 3.8 在这件事上的能做与不能做。
16.3.1 为什么游戏 bug 这么难复现
先想清楚复现难在哪。一局游戏的推进由四样东西决定:
| 输入 | 说明 | 可记录性 |
|---|---|---|
| 时间 | 每帧的真实 dt | 用固定步长后,可消除 |
| 输入 | 每帧按键状态 | 可记录 |
| 随机 | 掉落、暴击、刷怪 | 需固定种子 |
| 初始状态 | 关卡、存档 | 可序列化 |
四样里,只要时间被固定步长吃掉、输入被记录、随机被种子锁死、初始状态被序列化,整局游戏就变成一段纯函数式的确定性计算。同一份输入序列,永远产出同一个结果。
mini2d 的 Clock 从 4.2 节起就用固定步长,等于已经消灭了「时间」这个变量。剩下的三样,本节逐个解决。反过来说,只要这四样里有任何一样没被控制住,回放就会「跑偏」——这就是很多项目里回放录了等于白录的根本原因。
16.3.2 录制输入序列:每帧的输入位
游戏的输入只有三个布尔位(左、右、跳)。一帧的输入就是这三个位的组合,一段回放就是一串这样的组合:
/** 一帧的输入位。用普通对象,便于序列化成 JSON。 */
export interface InputFrame {
left: boolean;
right: boolean;
jump: boolean;
}
/** 回放录制器:把每帧输入按顺序攒起来。 */
export class InputRecorder {
readonly frames: InputFrame[] = [];
record(input: InputFrame): void {
// 必须拷贝:调用方通常会复用同一个 input 对象。
this.frames.push({ left: input.left, right: input.right, jump: input.jump });
}
toJSON(): string {
return JSON.stringify(this.frames);
}
static fromJSON(json: string): InputFrame[] {
return JSON.parse(json) as InputFrame[];
}
}
两个细节值得说:
- 拷贝而非引用。游戏循环里通常复用同一个
game.input对象,每帧改它的字段。如果录制器存的是引用,最后所有帧都会指向同一个对象——回放出来全是最后一帧的输入。 - 用可序列化格式。
toJSON/fromJSON让录制结果能存文件、能贴进 issue、能进测试夹具。这正是「把玩家遇到的那一次录下来」的物理基础。
这里刻意用普通对象而非 TS 枚举来存输入位:普通对象天然能被 JSON.stringify 处理,而枚举在序列化、反序列化、跨版本兼容上都要额外处理。能进 JSON 的数据结构,才是能长期保存的录制品。
16.3.3 回放:把输入序列喂回固定步长
回放就是把录制结果按固定步长重跑一遍:
export function replay(
frames: InputFrame[],
options: { width?: number; height?: number } = {},
): Game {
const game = new Game({ time: new FakeTime(), ...options });
for (const f of frames) {
game.input.left = f.left;
game.input.right = f.right;
game.input.jump = f.jump;
game.step(game.clock.fixedStep); // 每帧一个固定步,与录制时完全一致
}
return game;
}
注意回放不调用 game.frame(),而是直接调 game.step(fixedStep)。原因很简单:frame() 走的是「读真实时间 → 累加 → 消耗」那条路,它的步数取决于实际经过的毫秒数;而回放要的是「每帧恰好一步」,与真实时间无关。录制时每帧记一条输入,回放时每帧消耗一条输入、推进一步——两边严格对齐。
只要录制和回放用的是同一个 fixedStep(都取 game.clock.fixedStep),这段回放就是逐帧精确的。反过来,如果录制时用的是 frame()、回放时用的是 step(),两边步数就对不上,回放必然跑偏——录制与回放必须共用同一条时间路径。
16.3.4 固定随机种子
《方块冲刺》现在还没有随机,但一旦加了掉落、暴击、刷怪,Math.random() 就会成为回放的杀手——它每次返回不同的值,同一段输入序列会跑出不同结果。
解决办法是把随机源也做成可注入的,并用带种子的伪随机数发生器(PRNG):
/** 带种子的 PRNG(mulberry32)。同一个种子永远产出同一串数。 */
export function makeRng(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
makeRng(42) 每次调用返回 [0, 1) 的伪随机数,但同一个种子永远给出同一串数。回放时把种子一起存下来,随机就重新变成确定性。mulberry32 只有几行,质量足够游戏玩法用,不需要引入依赖。
这和 FakeTime 是同一个思想:凡是不可控的外部源,都替换成可注入、可复现的实现。时间如此,随机如此,渲染(RecordingRenderer)也如此。游戏里如果用了 Date.now()、Math.random() 这类全局源,回放就不成立——它们是确定性最隐蔽的破坏者。
16.3.5 初始状态也要一起存
输入序列只描述了「玩家做了什么」,还差一半:游戏是从什么状态开始的。如果录制时玩家已经玩了 30 秒,回放却从第 0 帧开始,两边初始状态就不一致,回放当然对不上。
所以录制必须连初始状态一起存:
export interface Recording {
/** 录制开始时的世界状态。 */
initial: { player: Player; coins: Coin[]; score: number };
/** 每帧的输入位。 */
frames: InputFrame[];
/** 随机种子(如果用了随机)。 */
seed: number;
}
export function startRecording(game: Game, seed = 0): InputRecorder {
// 先把当前状态快照下来,作为回放的起点。
const initial = {
player: { ...game.player },
coins: game.coins.map((c) => ({ ...c })),
score: game.score,
};
void initial; // 实际实现里存进 Recording.initial
void seed;
return new InputRecorder();
}
要点是深拷贝:{ ...game.player } 拷一层,coins.map((c) => ({ ...c })) 逐枚拷。如果只存引用,录制结束后玩家继续玩,initial.player 会跟着变——回放起点又被污染了。
把「初始状态 + 输入序列 + 种子」三件套凑齐,一段录制就自包含了:它不依赖任何运行时环境,换台机器、隔几个月,都能原样重放。
16.3.6 把 bug 复现变成一条测试用例
现在回到「玩家报 bug」这件事。假设有人报:「跳到第三枚金币上方时,跳不起来,然后掉下去卡在地面上」。你不知道怎么手动复现,但只要他开了录制,你手上就有一串 InputFrame。
把这段序列直接贴进测试:
import { describe, expect, it } from 'vitest';
import { replay, type InputFrame } from '../replay';
// 玩家提交的回放片段:右侧连按,中间有一次跳。
const BUGGY: InputFrame[] = [
{ left: false, right: true, jump: false },
{ left: false, right: true, jump: false },
{ left: false, right: true, jump: true }, // 这一帧按了跳
{ left: false, right: true, jump: false },
];
describe('回归:玩家报告「金币上方跳不起来」', () => {
it('按下跳键后 onGround 必须变 false', () => {
const game = replay(BUGGY);
expect(game.player.onGround).toBe(false);
});
});
这条测试第一次跑会失败——失败本身就是收获:bug 从「偶发」变成了「必然」。接下来修代码,改到这条测试通过为止。从这一刻起,这个 bug 被永久钉死:任何人再改 step 的跳跃逻辑,只要把这个 bug 改回来,这条测试立刻变红。
这就是确定性回放最实用的形态:玩家遇到的每一次异常,都能沉淀成一条回归用例。录制一段、贴进测试、修到变绿——比任何「麻烦再复现一下」都高效。
要让它成为工作流,还需要三样东西:游戏内置一个录制开关(玩家一键开启)、一个导出按钮(把 Recording 存成 JSON 文件)、一个导入入口(把文件变成一条测试的夹具)。这三样加起来,才叫「可回放的线上问题处理流程」。
16.3.7 状态快照:给回放加一个校验点
回放测试如果只断言「最后状态」,容易漏掉中途的分歧。更稳的做法是在固定帧数处取状态快照并比对:
/** 把游戏的可观测状态压成一个字符串,便于比对。 */
export function snapshot(game: Game): string {
return JSON.stringify({
x: game.player.x,
y: game.player.y,
vx: game.player.vx,
vy: game.player.vy,
onGround: game.player.onGround,
score: game.score,
taken: game.coins.map((c) => c.taken),
});
}
回放时每 60 帧记一个快照,和「基准快照」逐条比对。这样即使最终状态偶然一致,中途的偏差也会被抓住。快照里只放可观测状态(位置、速度、分数、金币状态),不放内部计数器或缓存——后者是实现细节,不该写进测试。
快照还有一个副产品:它天然适合做性能回归。记录整段回放消耗的毫秒数,设一个上限,就能在 CI 里拦住「某次改动让逻辑变慢一倍」这类退化。逻辑测试测对错,性能测试测快慢,两者共用同一套回放。
16.3.8 Cocos 对照:能测什么,测不了什么
把 mini2d 的测试思路搬到 Cocos Creator 3.8,先要分清哪些是引擎替你管、你插不进去手的:
| 事项 | mini2d | Cocos 3.8 |
|---|---|---|
| 主循环 | 自己写 requestAnimationFrame | director / game 内部驱动 |
| 时间 | Clock 固定步长 | director.getDeltaTime() 可变 |
| 输入 | 自己记录 game.input | 事件回调,无统一录制点 |
| 生命周期 | 自己调 step | Component.update(dt) 由引擎调度 |
| 暂停 | timeScale = 0 | director.pause() / game.pause() |
能做的:把纯逻辑抽成不依赖 cc 的函数(如 16.1 的 integrateGravity),在 vitest 里像测 mini2d 一样测它们。这部分完全可复现。
做不了的:整局回放。因为主循环、时间、输入都归引擎管,你没有一个「喂输入序列、推进固定步数」的入口。硬要做的近似方案是用 director.getTotalFrames() 数帧、用 game.step() 手动推一帧:
import { director, game, sys } from 'cc';
/** 只能在「引擎已初始化、当前有场景」时调用。 */
export function advanceOneFrame(): void {
if (!director.getScene()) return;
game.step();
}
export function frameBudgetMs(): number {
// 目标帧率换算成单帧毫秒预算,用于性能回归阈值。
const fps = Number(game.frameRate) || 60;
return 1000 / fps;
}
export function isNativeBuild(): boolean {
return sys.isNative;
}
game.step() 手动推进一帧、director.getScene() 判断是否已有场景、game.frameRate 读目标帧率、sys.isNative 判断是否原生平台。它们真实存在,但注意 game.step() 推进的是引擎的真实帧,dt 仍由引擎决定——你依然无法像 replay() 那样精确控制每一步的 dt。
诚实结论:Cocos 工程难以命令行测试,不是缺工具,而是主循环的所有权不在你手里。可测的部分必须主动从引擎里「挤」出来(纯函数、纯数据),剩下的只能靠「固定环境 + 容差比对」的集成测试兜底。这也是《方块冲刺》先用 mini2d 把确定性打牢、再迁移到 Cocos 的原因——确定性的地基,一旦交给引擎就很难补回来。
本节 Cocos 代码为 L2 片段,
game.step、game.frameRate、director.getScene、sys.isNative已在cc.d.ts(Cocos Creator 3.8.8)中核对存在,不参与mini2d的测试。
到这里第 16 章收束:单测锁逻辑、E2E 锁像素、回放锁「偶发」。三种手段合起来,才谈得上「质量」二字。下一章转向发布——构建产物、多端差异,以及 Cocos 的构建面板。
阅读导航:上一节:16.2 渲染端到端测试:Playwright 截图 · 下一节:17.1 构建流程与产物体积分析 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。