《Cocos 游戏开发入门》附录 A:mini2d API 与模块速查

本附录是全书 mini2d 运行时的 API 速查表:按模块列出 TimeSource、realTime、FakeTime、Clock、Color、rgba、DrawCommand、Renderer、RecordingRenderer、CanvasRenderer 与 Game 的真实类型签名,并给出「假时钟推进固定步数 + 记录式渲染器断言绘制指令序列」的确定性测试写法,方便随手回查。

附录 A:mini2d API 与模块速查

这一卷的代码全部围绕一个从零写出的 2D 运行时 mini2d 展开。正文按「为什么这样设计」的顺序讲解,本附录则把它压缩成一张随时可查的表:每个类型、每个方法的签名都以仓库里的真实代码为准,不是示意。你在读正文时如果只记得住「大概有这么个类」,回到这里核对签名即可。

本附录只覆盖 mini2d 的核心面——时钟、渲染抽象、游戏循环。后续章节会长出场景图、碰撞、资源管理等更多模块,它们各自的 API 会在对应章节末尾补进速查表。

A.1 模块与文件地图

mini2d 的源码放在 src/ 下,按职责分目录。当前核心文件如下:

src/
├── core/
│   └── clock.ts              # TimeSource / realTime / FakeTime / Clock
├── render/
│   ├── renderer.ts           # Color / rgba / DrawCommand / Renderer
│   ├── recording-renderer.ts # RecordingRenderer(记录式,测试用)
│   └── canvas-renderer.ts    # CanvasRenderer(浏览器 Canvas2D)
├── game.ts                   # Player / Coin / GameOptions / Game
├── browser.ts                # 浏览器入口:renderAt / run
└── __tests__/
    └── game.test.ts          # vitest 用例

三条依赖方向值得记住:game.ts 只依赖 core/clock 与 render/renderer;render/ 下的两个实现都只依赖 renderer.ts 的接口;browser.ts 是唯一的「叶子」,它把具体实现(CanvasRenderer)接到游戏上。因此 Game 本身不碰 DOM、不碰 canvas,这也是它能在 Node 里被逐步断言的前提。

A.2 时钟模块:core/clock.ts

固定步长模拟的全部机制都在这里。它把「真实经过了多少毫秒」和「逻辑该跑几步」解耦,并且用整数微秒累加避免浮点误差。

名称形态说明
TimeSourceinterface唯一成员 now(): number,单调递增毫秒时间戳
realTimeconst: TimeSource真实时间源,浏览器与 Node 都走 performance.now()
FakeTimeclass手动时间源,测试里显式推进,完全确定
ClockOptionsinterfacefixedStep?: number(秒,默认 1/60)、maxSubSteps?: number(默认 5)
StepStatsinterfacesteps: number(本帧实际步数)、alpha: number(插值系数 [0,1))
Clockclass固定步长累加器,tick(step) 驱动模拟

TimeSource 与两个实现:

export interface TimeSource {
  /** 单调递增的毫秒时间戳(可以是小数)。 */
  now(): number;
}

/** 真实时间源:浏览器与 Node 都有 performance.now()。 */
export const realTime: TimeSource = {
  now(): number {
    return typeof performance !== 'undefined' ? performance.now() : Date.now();
  },
};

/** 手动时间源:测试里显式推进,完全确定。 */
export class FakeTime implements TimeSource {
  constructor(startMs = 0);
  now(): number;
  /** 推进毫秒数。 */
  advance(ms: number): void;
}

FakeTime 的构造函数接受一个起始毫秒数(默认 0),advance(ms) 把内部时间戳往前推 ms 毫秒。它实现的是同一个 TimeSource 接口,所以凡是接受 TimeSource 的地方都能把 realTime 换成 FakeTime——这是全书「可注入时间」的基石。

Clock 的签名:

export class Clock {
  /** 固定步长(秒)。对外始终是秒,游戏逻辑不该关心微秒。 */
  readonly fixedStep: number;
  readonly maxSubSteps: number;

  constructor(time: TimeSource = realTime, options: ClockOptions = {});

  /**
   * 推进一帧:把真实经过的时间累加进累加器,然后按固定步长消耗。
   * 返回本帧要跑几步、以及给渲染用的插值系数。
   */
  tick(step: () => void): StepStats;
}

tick 的行为有三个关键点,速查时要一起记住:

  • 第一帧只对齐时间基准,不产生步进,返回 { steps: 0, alpha: 0 }。
  • 单帧最多补 maxSubSteps 步,超出的时间被直接丢弃,防止卡顿后「死亡螺旋」。
  • 累加器用整数微秒,所以「60 帧跑出 59 步」这类结果可以精确断言,不需要容差。

A.3 渲染抽象:render/renderer.ts

渲染层只有一张接口和几个原语。引擎侧只需要「画矩形 / 画精灵 / 画文字」,落到哪个后端由实现决定。

export interface Color {
  r: number;
  g: number;
  b: number;
  a: number;
}

export const rgba = (r: number, g: number, b: number, a = 1): Color => ({ r, g, b, a });

rgba 的 a 默认 1,返回一个普通对象;Color 的四个分量都是 number(r/g/b 取 0–255,a 取 0–1)。

一次绘制调用被建模成一个可辨识联合,这样「绘制序列」本身就能被断言:

export type DrawCommand =
  | { op: 'clear'; color: Color }
  | { op: 'rect'; x: number; y: number; w: number; h: number; color: Color; rotation: number }
  | { op: 'sprite'; texture: string; x: number; y: number; w: number; h: number; rotation: number }
  | { op: 'text'; text: string; x: number; y: number; color: Color };

四个变体分别对应清屏、矩形、精灵、文字。rect 与 sprite 都带 rotation(弧度),clear 与 text 不带。op 字段是判别式,测试里用 c.op 就能过滤出关心的那几类。

Renderer 接口把上面这些原语组织成「一帧」的固定流程:

export interface Renderer {
  beginFrame(width: number, height: number): void;
  clear(color: Color): void;
  drawRect(x: number, y: number, w: number, h: number, color: Color, rotation?: number): void;
  drawSprite(texture: string, x: number, y: number, w: number, h: number, rotation?: number): void;
  drawText(text: string, x: number, y: number, color: Color): void;
  endFrame(): void;
}

调用顺序约定为 beginFrame → clear → 若干 draw* → endFrame。drawRect 与 drawSprite 的 rotation 是可选的,省略时按 0 处理;texture 是纹理名字(字符串),不是图像对象——这一点在最小实现里很重要,因为它让记录式渲染器不必持有任何真实资源。

A.4 两个渲染实现

RecordingRenderer 与 CanvasRenderer 实现同一个 Renderer 接口,一个产生断言、一个产生像素。

export class RecordingRenderer implements Renderer {
  readonly frames: DrawCommand[][] = [];

  get lastFrame(): DrawCommand[];   // 最后一帧的绘制指令
  get frameSize(): { width: number; height: number }; // 本帧尺寸

  reset(): void;                    // 清空所有帧与当前帧
}

frames 是「每一帧一个数组」的历史,lastFrame 取最后一帧(没有帧时返回空数组),frameSize 返回最近一次 beginFrame 传入的宽高。reset() 把历史与当前帧一起清空,便于在一个用例里复用同一个渲染器跑多轮。

CanvasRenderer 是浏览器侧的真实实现:

export class CanvasRenderer implements Renderer {
  constructor(canvas: HTMLCanvasElement);
}

它从构造参数拿到 HTMLCanvasElement,取 2d 上下文;取不到就抛 Error('Canvas2D context unavailable')。drawSprite 在最小实现里并不真正加载图片,而是把纹理名画成一个占位方块,并在一旁写上纹理名——这足以让 Playwright 截图有稳定、可复现的内容。

A.5 游戏核心:game.ts

Game 是《方块冲刺》的最小可运行核心。它的状态与方法是:

export interface Player {
  x: number; y: number;
  vx: number; vy: number;
  w: number; h: number;
  onGround: boolean;
}

export interface Coin {
  x: number; y: number; r: number;
  taken: boolean;
}

export interface GameOptions {
  time?: TimeSource;
  clock?: ClockOptions;
  width?: number;
  height?: number;
}

export class Game {
  readonly clock: Clock;
  readonly width: number;
  readonly height: number;

  player: Player;
  coins: Coin[];
  score = 0;
  /** 当前帧的输入意图,由外部设置。 */
  input = { left: false, right: false, jump: false };

  constructor(options: GameOptions = {});
  /** 推进一帧真实时间,内部按固定步长跑 step()。返回本帧跑了几步。 */
  frame(): number;
  /** 一个固定步长的模拟。dt 单位是秒。 */
  step(dt: number): void;
  /** 把当前世界画出来。alpha 是渲染插值系数。 */
  render(renderer: Renderer, alpha = 0): void;
}

四个入口的职责要分清楚:

方法输入做什么
input由外部每帧写入只表达「意图」:left / right / jump 三个布尔
step(dt)秒一个固定步长的纯逻辑模拟,不含任何真实时间
frame()无读时钟、按步长调用 step,返回本帧步数
render(r, alpha)渲染器把当前世界画出来,alpha 供插值使用

input 是「外部写入、内部消费」的:step 里读一次 jump 并在同一步内消费掉,所以一次按键只会起跳一次。frame() 是唯一读真实时间的地方,其余逻辑全凭 dt 推进——这就是「逻辑与帧率解耦」落到 API 上的样子。

A.6 断言法:假时钟 + 记录式渲染器

mini2d 的测试不跑真实时间,也不比对像素。它用两个「假模型」把游戏变成确定性的可断言事实:用 FakeTime 控制时间,用 RecordingRenderer 记录绘制。

推进固定步数的写法是「先对齐基准、再逐帧推进」:

import { FakeTime } from '../core/clock';
import { Game } from '../game';

const STEP_MS = (1 / 60) * 1000;

const time = new FakeTime();
const game = new Game({ time });
let total = 0;
for (let i = 0; i < 60; i += 1) {
  time.advance(STEP_MS);
  total += game.frame();
}
// 第一帧只对齐基准,剩下 59 帧各 1 步;整数微秒累加,结果精确。
expect(total).toBe(59);

如果想绕开时钟、直接断言单步逻辑,可以手动调 step,例如把方块放在金币上方再推一步,验证收集判定:

const game = new Game();
const coin = game.coins[0]!;
game.player.x = coin.x - 4;
game.player.y = coin.y - 4;
game.step(1 / 60);
expect(game.score).toBe(1);

渲染断言则交给 RecordingRenderer:render 之后读 lastFrame,用 op 过滤出关心的指令,或整体比对:

import { RecordingRenderer } from '../render/recording-renderer';

const game = new Game();
const r = new RecordingRenderer();
game.render(r);

expect(r.frames).toHaveLength(1);
expect(r.frameSize).toEqual({ width: 480, height: 270 });

const ops = r.lastFrame.map((c) => c.op);
expect(ops[0]).toBe('clear');
expect(ops.filter((o) => o === 'rect')).toHaveLength(4); // 3 枚金币 + 1 个玩家
expect(ops[ops.length - 1]).toBe('text');

expect(r.lastFrame[0]).toEqual({
  op: 'clear',
  color: { r: 24, g: 28, b: 38, a: 1 },
});

这套写法的价值在于确定性与可读性兼得:同一个步数序列必然得到同一个绘制序列,测试失败时打印出的是「第几个 op 对不上」,而不是一张看不出差在哪的截图。确定性回放(第 16 章)与 Playwright 截图回归(第 16 章)都建立在同一套假模型上——前者录输入、后者比像素,但它们复用的都是这里的时间源与渲染器抽象。

A.7 一页速记

  • 时间:TimeSource(接口)→ realTime / FakeTime(两个实现)→ Clock.tick(step)(固定步长)。
  • 渲染:Color / rgba / DrawCommand / Renderer(抽象)→ RecordingRenderer / CanvasRenderer(两个实现)。
  • 游戏:Game 的 input(写意图)→ step(dt)(纯逻辑)→ frame()(读时钟)→ render(r, alpha)(出画面)。
  • 测试:FakeTime.advance 推时间,RecordingRenderer.lastFrame 断言绘制序列。
  • 全书的验证都落在这套 L1 代码上;Cocos 侧只做类型级与结构级验证,见附录 B。

下一卷(《Cocos 游戏开发实战》)会用 Cocos Creator 3.8 把《方块冲刺》重做一遍,届时这里的每个 mini2d 概念都会有一个引擎侧的对应物;本附录保留为对照时的「左侧一栏」。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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