《Cocos 游戏开发入门》16.3 确定性回放与回归

玩家报的「偶发 bug」怎么变成一条测试?本节讲确定性回放:把每帧输入位录成可序列化序列,用固定随机种子消灭不确定性,再逐帧喂回固定步长,把一个 bug 复现写成回归用例;末节诚实交代 Cocos Creator 3.8 里能测什么、为何整局回放做不到。

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[];
  }
}

两个细节值得说:

  1. 拷贝而非引用。游戏循环里通常复用同一个 game.input 对象,每帧改它的字段。如果录制器存的是引用,最后所有帧都会指向同一个对象——回放出来全是最后一帧的输入。
  2. 用可序列化格式。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,先要分清哪些是引擎替你管、你插不进去手的:

事项mini2dCocos 3.8
主循环自己写 requestAnimationFramedirector / game 内部驱动
时间Clock 固定步长director.getDeltaTime() 可变
输入自己记录 game.input事件回调,无统一录制点
生命周期自己调 stepComponent.update(dt) 由引擎调度
暂停timeScale = 0director.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 构建流程与产物体积分析 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画