附录 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
固定步长模拟的全部机制都在这里。它把「真实经过了多少毫秒」和「逻辑该跑几步」解耦,并且用整数微秒累加避免浮点误差。
| 名称 | 形态 | 说明 |
|---|---|---|
TimeSource | interface | 唯一成员 now(): number,单调递增毫秒时间戳 |
realTime | const: TimeSource | 真实时间源,浏览器与 Node 都走 performance.now() |
FakeTime | class | 手动时间源,测试里显式推进,完全确定 |
ClockOptions | interface | fixedStep?: number(秒,默认 1/60)、maxSubSteps?: number(默认 5) |
StepStats | interface | steps: number(本帧实际步数)、alpha: number(插值系数 [0,1)) |
Clock | class | 固定步长累加器,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 概念都会有一个引擎侧的对应物;本附录保留为对照时的「左侧一栏」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。