第07章 - 玩法模板库详解

7.1 一个模板的两半:判定与画面

八个模板(packages/templates/src/)每个都由两个文件组成,文件名后缀就是契约:

文件 内容 能不能 import Phaser
<id>.logic.ts zod schema + 纯函数判定 + 状态类型 不能,且连 Math.random / Date.now 都不许用
<id>.ts createXxxScene(ctx, level),返回一个继承 BaseScene 的场景类 可以,它就是在画画面

这条分工不是审美,是可测性。场景类活在 Phaser 的渲染循环里,要在 Node 里跑它就得伪造 canvas、WebGL 上下文与时钟;而判定逻辑一旦抽成纯函数,就成了一个入参出参都确定的模块:

// packages/templates/src/scrub.logic.ts
export function scrubAt(
  state: ScrubState,
  level: ScrubLevel,
  x: number,
  y: number,
): ScrubHit {
  let hit: (typeof level.spots)[number] | null = null;
  let best = Number.POSITIVE_INFINITY;
  for (const spot of level.spots) {
    const radius = 0.11 * spot.size;
    const dist = Math.hypot(x - spot.x, y - spot.y);
    if (dist <= radius && dist < best) { best = dist; hit = spot; }
  }
  if (!hit) return { state, hitId: null, cleanedNow: [] };
  if ((state.cleaned[hit.id] ?? 0) >= level.rubs) return { state, hitId: hit.id, cleanedNow: [] };
  const cleanedNow: string[] = [];
  const cleaned = { ...state.cleaned, [hit.id]: (state.cleaned[hit.id] ?? 0) + 1 };
  if (cleaned[hit.id]! >= level.rubs) cleanedNow.push(hit.id);
  return { state: { ...state, cleaned }, hitId: hit.id, cleanedNow };
}

eslint.config.js 为 packages/progress/src/**/*.ts 和 **/*.logic.ts 打开了 no-restricted-properties,禁掉 Math.random 与 Date.now,报错文案是「纯逻辑层禁止 Math.random,请注入种子」与「纯逻辑层禁止 Date.now,请注入 now」。这条规则解释了为什么 registerTap 要多一个 now 参数、isInZone 要多一个 elapsedMs 参数——时间必须从外面进来,判定才能被断言。

这条规则还带来一个可量化的结果:八个 *.logic.ts 合计约 19.5 KB,八个场景合计约 112.6 KB——判定占两成、画面占八成,而只有那两成需要逐条断言。测试文件因此在 Node 里就能跑完,不需要浏览器、不需要 Phaser、不需要等动画。

场景那一半的签名是统一的:

export function createScrubScene(ctx: GameContext, level: ScrubLevel): Phaser.Types.Scenes.SceneType {
  return class ScrubScene extends BaseScene {
    private readonly level = level;
    constructor() { super(SCENE_KEYS.scrub, ctx); }
    create(): void {
      this.ctx.emit('scene:enter', { scene: SCENE_KEYS.scrub });
      this.ctx.emit('session:start', { at: this.ctx.services.now() });
      // …
    }
  };
}

create 的头两行是八个场景一模一样的前置动作:报「进了哪个场景」、报「这一局开始了」。BaseScene 的 onShutdownCleanup(fn) 则保证每个场景都能在 shutdown 时撤掉自己挂的输入监听与定时器——这是路由切换不残留 canvas 与 rAF 循环的第二道保险(第一道是 GameModule.unmount 里的 game.destroy(true),见第 4 章)。

八个场景都从 @bamboo/templates/<id> 子路径引入,packages/templates/src/core.ts 里没有 export * from './scrub' 这样的 barrel:

// packages/templates/src/core.ts
/**
 * 场景走 '@bamboo/templates/<id>' 子路径,一个游戏只 import 自己用到的场景,
 * 避免 barrel 把全部模板拖进共享 chunk(CHAGS 实测约 90 KB gzip 的教训)。
 */
export { defineGame } from './defineGame';
export type { GameDefinition } from './defineGame';
export * from './layout';

这份节制的收益可以直接在产物里读出来:十个玩法的入口 chunk 只有 342–8,189 字节,体积的差别恰好说明了共享的粒度——刷牙 / 洗手的入口只有 344 / 342 字节(它们只是把内容交给模板),而场景代码集中在共享的 scrub-<hash>.js(9,504 字节)里。刷牙、洗手、洗澡三条任务共用同一份 packages/templates/src/scrub.ts,而不是各自拷一份 19 KB。

packages/templates/src/layout.ts 是两半之间的第三种文件:纯常量,不 import Phaser。

/** 逻辑分辨率 750×1334(竖屏);Phaser 的 Scale.FIT 负责适配真机 */
export const DESIGN = { width: 750, height: 1334 } as const;

export const SCRUB_LAYOUT = {
  /** 内容 JSON 的 spots 0–1 坐标映射到这个矩形(刷牙=牙齿区,洗手=手部区,洗澡=身体区) */
  stage: { x: 250, y: 760, w: 260, h: 220 } satisfies Rect,
  /** 冲水 / 擦干按钮中心(finish 全部完成后才可点) */
  finish: { x: 375, y: 1215 } satisfies Point,
  /** 工具初始位置(拖拽起点) */
  toolHome: { x: 110, y: 1190 } satisfies Point,
} as const;

注释里那句「这个文件不 import Phaser,所以 Playwright 可以安全地直接 import 它来算点击位置」是这个设计的全部理由:e2e 需要知道「冲水按钮在 (375, 1215)」才能做真实点击,而如果坐标写在 scrub.ts 里,测试就得在 Node 里加载整个 Phaser。

7.2 scrub:来回刷,零失败

三条任务共用这一个模板:刷牙(tool: brush)、洗手(tool: hands)、洗澡(tool: sponge)。schema 的注释写得很直白:

刷洗类玩法(刷牙 / 洗手 / 洗澡):工具在目标区域来回刷,把点点刷干净。零失败:刷不干净永远可以再刷;只有「刷完了」这一种结束条件。

状态是一张 spotId → 已刷次数 的表,加一个收尾计数与完成标记:

export interface ScrubState {
  cleaned: Record<string, number>;
  finishTaps: number;
  done: boolean;
}

export function scrubProgress(state: ScrubState, level: ScrubLevel): number {
  const total = level.spots.length * level.rubs;
  const current = level.spots.reduce((sum, spot) => sum + Math.min(level.rubs, state.cleaned[spot.id] ?? 0), 0);
  return total === 0 ? 1 : current / total;
}

判定用「最近且在半径内」而不是「区域内的所有点」:孩子刷得歪也算数,但不会刷一下同时清掉两个点。半径是 0.11 * spot.size(size 是内容里的 0.5–1.6 系数),坐标与半径都在 0–1 空间里,所以同一份逻辑既能给刷牙用,也能给面积不同的身体区用。

场景把这些数字翻译成像素与手感:

常量 值 作用
STAGE SCRUB_LAYOUT.stage(250, 760, 260×220) 内容坐标的映射目标
SPOT_RADIUS min(260, 220) * 0.085 ≈ 19 点的屏幕半径,比判定圈小(判定更宽容)
MIN_SCRUB_STEP 10 px 指针移动超过它才记一次刷洗,防止一帧刷十几下
GRAB_RADIUS 130 px 抓工具的半径,孩子手指粗
IDLE_HINT_MS 12_000 12 秒没有有效操作才提示

三条任务的内容差异只有四个字段,摆在一起看最清楚:

内容 tool spots rubs finish 提示时长
brush(刷牙) brush 6 个 germ 2 rinse × 5 45 秒
wash(洗手) hands 6 个 foam 2 rinse × 6 40 秒
bath(洗澡) sponge 8 个 dirt 2 wipe × 5 70 秒

三个 kind(germ / dirt / foam)换成三种画法:细菌是紫色圆身子加五根小刺和两只眼睛,污点是三团深浅不一的褐色,泡沫是一簇空心圆。一份 logic、一份场景、三条任务——这就是模板库存在的意义,也是内容作者唯一需要理解的抽象。

画面由 tool 决定(pickVariant(level)):brush → 洗手间镜子近景 + drawPandaHead 大头 + 牙刷;hands → 洗手池(柜体、龙头、盆体、水柱,画在 scrub.ts 内部)+ 两只手;其余 → 浴缸压住下半身 + 海绵。刷干净时换一张脸(刷牙闭嘴、洗澡眯眼),这是「做到了」的奖励而不是分数。

收尾动作是可选的:finish.kind 为 rinse 或 wipe,finish.taps 是次数。只有 allSpotsClean() 成立之后 finishTap() 才计数——所以「先刷干净,再冲水」这个顺序是逻辑层保证的,不是靠 UI 把按钮藏起来。刷牙 12 下 + 冲水 5 下,自动通关用墙钟 setTimeout(150 + i * 110) 串起来,约 2 秒完成:

用墙钟而不是 Phaser 计时器:Phaser 在场景刚启动的 120 帧里会压缩 delta,低帧率设备(含无头浏览器)上 2 秒的自动演示会被拉成 5 秒,验收会误判超时。

7.3 feed:一口一口,把类别吃全

feed 的教育点是「不挑食」,所以算法上没有「不许吃」的逻辑,只有一张「吃到了哪几类」的清单:

export function feedProgress(state: FeedState, level: FeedLevel): FeedProgress {
  const finishedFoods: string[] = [];
  const kinds = new Set<string>();
  let eatenBites = 0;
  for (const food of level.foods) {
    const left = state[food.id];
    const remaining = left === undefined ? food.bites : left;
    eatenBites += food.bites - remaining;
    if (remaining <= 0) { finishedFoods.push(food.id); kinds.add(food.kind); }
  }
  const total = totalBites(level);
  return {
    totalBites: total,
    eatenBites,
    finishedFoods,
    kinds: [...kinds],
    complete: eatenBites >= total,
    balanced: level.needKinds.every((kind) => kinds.has(kind)),
  };
}

state 有意设计成稀疏的:FeedState = Record<string, number>,没喂过的食物不占键,读数时用 food.bites 兜底。这样 createFeedState() 就是空对象,bite() 也不需要初始化整张表。

场景布局:竹竹的嘴在 (375, 650),判定半径 BITE_RADIUS = 150——比看到的嘴大得多,孩子不用瞄准;食物区是 { x: 105, y: 770, w: 540, h: 300 },比桌面窄一圈,保证 6 个食物的标签不会互相压住(内容里苹果 0.62 与饼干 0.86 是同一列上下两个)。抓取半径 GRAB_RADIUS = 100(比食物本身大),拖到别处只 springBack 弹回,不算错。

顶部是「均衡进度」:needKinds 里每一类一个圆形图标,吃到一类亮一个(BalanceView 用 lit 标记),类别名走 @bamboo/i18n 的 food.* 键。吃到全部类别时夸一句「吃得真均衡」,并且至少留 900ms 再做完成结算(BALANCE_LINE_MS),否则这句夸奖会被完成卡片直接盖掉。

7.4 dress:部位匹配与「不算错」的边界

判定核心只有十几行,三种结果里两种都不是惩罚:

export function tryDress(state: DressState, level: DressLevel, itemId: string, slot: ClothingKind): DressResult {
  const item = dressItem(level, itemId);
  if (!item) return { state, status: 'wrong' };
  if (state[itemId] === item.slot) return { state, status: 'already' };
  if (item.slot !== slot) return { state, status: 'wrong' };
  return { state: { ...state, [itemId]: item.slot }, status: 'correct' };
}

export function dressComplete(state: DressState, level: DressLevel): boolean {
  return level.items.every((item) => state[item.id] === item.slot);
}

already 这一支是给「孩子把已经穿好的衣服又拖了一次」准备的——不报错、不重复计数、也不播放错误音。

场景里五个部位的判定中心是写死的常量(帽子 375/560、围兜 375/810、书包 460/760、整套衣服 375/900、鞋 375/1110),SLOT_RADIUS = 130 比约定下限 120 还宽;slotAt() 取最近的槽位,没落在任何槽位里返回 null。这里有一条不容易想到的产品判断:

const slot = this.slotAt(node.x, node.y);
if (slot === item.slot) { this.wear(item, node); return; }

// 只有「放进别的部位」才算放错;随手放在空地上只是弹回去,不打击孩子
if (slot !== null) {
  this.noteMistake('dress');
  this.say(ctx.task.retry, 1800);
}
node.setDepth(30);
springBack(this, node, home.x, home.y);

当前内容给的是四件:帽子、围兜、书包、鞋(dress.game.json 的 items 长度 4,落在 schema 允许的 3–5 之间)。第五个部位 outfit 在 SLOTS 与 ACCESSORY_CANDIDATES 里都留着,注释写了原因:「内容现在不给 outfit;留着这个部位,等级脚本换成整套衣服时也能玩」。于是「加一套睡衣」这件事在模板侧是零改动,只要在内容 JSON 里多一件 slot: "outfit" 的衣服。

还有一个细节:内容里的 color 是任意 #rrggbb,而 drawPanda 的配件是按 id 前缀取预置配色的。accessoryIdFor(slot, hex) 用 RGB 欧氏距离在候选表里挑「最接近的一种」,所以拖过来的那件和穿在身上的那件至少是同一色系——这是一个明确的妥协,注释也承认了它。

7.5 order:顺序错了不清进度

睡前四步:拉窗帘 → 关灯 → 盖被子 → 拍拍睡。判定是一个「下一个该点谁」的游标:

export function tapStep(state: OrderState, level: OrderLevel, stepId: string): { state: OrderState; status: OrderStatus } {
  const step = currentStep(state, level);
  if (!step) return { state, status: 'complete' };
  if (step.id !== stepId) return { state, status: 'wrong' };
  const completed = [...state.completed, step.id];
  const next = state.next + 1;
  if (next >= level.steps.length) return { state: { next, completed }, status: 'complete' };
  return { state: { next, completed }, status: 'correct' };
}

status: 'wrong' 时返回的是原对象({ state, status } 里的 state 没有变),场景因此只会把刚点的那个道具晃一晃,已经做完的事仍然亮着。这条设计在文件头被写成两条:

  • 点错不清进度:顺序错了只把刚点的那个晃一晃,已经做完的事仍然亮着;
  • 不惩罚:没有倒计时、没有扣分,只有一句「再试一次」。

内容里的四步是 curtain(拉窗帘)、lamp(关灯)、blanket(盖被子)、pat(拍拍睡),而 ORDER_LAYOUT.steps 里备了七个坐标(另有 clock、moon、teddy)——内容用几个就画几个,位置由 id 对齐。这也是「内容决定玩什么、模板决定画在哪」的又一个例子:换成「先收玩具再刷牙再关灯」的三步脚本,模板一行都不用改。

场景侧 TAP_RADIUS = 130,道具位置来自 ORDER_LAYOUT.steps(七个坐标,内容只用到其中四个 id),顶部 DOTS_Y = 168、间距 64 的进度点显示「第几步」。卡住 9 秒(CHECK 里 now - lastActionAt < 9000 就返回)会把当前步骤用金色圆环亮出来,同时念一句内容里的 tip。中文步骤名很短(「拉窗帘」),英文更长(Next: Turn off light),所以底下垫了一张药丸卡 stepPill,保证它在任何墙面上都读得清。

7.6 tidy:形状 + 图标,不靠颜色

整理模板的判定与穿衣几乎同构(tryTidy / tidyComplete / tidyProgress),但它的信息传达方式是八个模板里最讲究的:

/**
 * 整理:把地上的玩具按类别放回箱子(球 / 积木 / 书 / 小车)。
 * 颜色只是装饰,类别靠「形状 + 箱子上的图标」区分 —— 不靠颜色单独传达信息(色盲友好)。
 */
export const tidyLevelSchema = z.object({
  room: z.enum(ROOMS),
  bins: z.array(z.object({
    kind: z.enum(TOY_KINDS),
    label: z.string().min(1).max(12),
    color: hexSchema,
  })).min(2).max(4),
  items: z.array(z.object({
    id: z.string().min(1),
    kind: z.enum(TOY_KINDS),
    color: hexSchema,
    x: normalized,
    y: normalized,
  })).min(4).max(10),
});

三个措施让「颜色单通道」不可能成立:

  1. 玩具的颜色与箱子的颜色故意错开。内容里 ball2 是绿色、block2 是红色,而「球箱」是红色、「积木箱」是绿色——孩子按颜色找箱子一定会放错,只能去看形状。
  2. 箱子有图标 + 名字。drawBin 在箱口画对应形状,下面写 label(「球」「积木」「书」)。
  3. 玩具本身是可区分的几何形。drawToy 里球是圆、积木是方块、书是带书脊的矩形、小车是带轮子的方块。

场景的 GRAB_RADIUS = 86(玩具本身只有 70 左右)、BIN_RADIUS = 130,箱体尺寸在 BIN_SIZE 里按类别给(三排箱子 190×150,小车箱 140×118,因为它在第二排、位置更靠边)。卡住 9 秒会把正确箱子亮出来。

7.7 rhythm:墙钟驱动的判定窗口

运动模板(跳竹竿)是八个模板里唯一有「时机」的,也是唯一把这条经验写成硬约束的:

纯判定只用墙钟(elapsed)—— 与 CHAGS 的教训一致:不累加帧 delta,慢手机与 CI 上结果一致,测试也能确定性地断言。

指针位置是一条三角波,一个来回(左→右→左)恰好两拍:

export function cycleMs(level: RhythmLevel): number {
  return (60_000 / level.bpm) * 2;
}

export function pointerPosition(elapsedMs: number, level: RhythmLevel): number {
  const cycle = cycleMs(level);
  const phase = ((elapsedMs % cycle) + cycle) % cycle;
  const half = cycle / 2;
  return phase <= half ? phase / half : 1 - (phase - half) / half;
}

export function isInZone(elapsedMs: number, level: RhythmLevel): boolean {
  const pos = pointerPosition(elapsedMs, level);
  return Math.abs(pos - 0.5) <= level.zone / 2;
}

zone 是窗口宽度(0.15–0.5),判定居中;zoneRange() 把同一个区间换算成进度条上的左右边界,所以画面上的绿区和判定用的是同一个函数,不会出现「看起来在里面却没算」。点击进 registerTap(),两条规则:

  • 220ms 内的连点直接忽略(TAP_GATE_MS),不计分也不计错——孩子连点两下不该被算成两次失误;
  • 点空只重置连击,misses 记录一次、streak 归零,进度不后退、指针不停。

奖励是听得见的:jumpTone() 返回 440 + Math.min(streak, 8) * 45,连击越高音调越高,场景把它交给 ctx.services.audio.tone(tone, 140)。

当前内容只有一条 rhythm:bpm: 84、jumps: 8、zone: 0.3。场景常量也围着它:竹竿在 y = 1032,张到最开 110px 的缝、合拢几乎贴住(20px);底部面板 702×228,进度条 { x: 72, y: 1178, w: 606, h: 48 }。

自动通关也在这里踩了同一个坑,而且解法更细:

create(): void {
  this.startAt = this.ctx.services.now() - cycleMs(this.level) / 4;   // 进场就在窗口正中
  // …
  if (this.autoplay) {
    this.handleTap();
    if (!this.done) this.autoTimer = window.setTimeout(() => this.autoStep(), 24);
  }
}

private autoStep(): void {
  const elapsed = this.ctx.services.now() - this.startAt;
  if (isInZone(elapsed, this.level)) {
    this.handleTap();
    if (!this.done) this.autoTimer = window.setTimeout(() => this.autoStep(), 24);
    return;
  }
  const toCenter = this.nextCenterDelay(elapsed);
  const toRisingCenter = nextHitDelay(elapsed, this.level);
  const halfWindow = (this.level.zone / 2) * (cycleMs(this.level) / 2);
  const sleep = Math.min(toCenter, toRisingCenter) - halfWindow;
  this.autoTimer = window.setTimeout(() => this.autoStep(), Math.max(16, sleep));
}

三个细节值得抄:startAt 往前挪四分之一拍,让「第一下就能跳」在首帧成立;自动通关用 window.setTimeout 而不是 this.time(Phaser Clock 会被 delta 平滑拖慢,而判定用的是墙钟,两者不能混用同一个时基);nextHitDelay 只朝「上升沿中心」看,会漏掉一个来回里的另一半窗口,所以还要和 nextCenterDelay 取更近的那个。

7.8 pick:认情绪 + 安慰

情绪模板是唯一一个两阶段的玩法:先选对脸,再做一个安慰动作。

export interface PickState {
  round: number;
  phase: 'choose' | 'comfort';
  done: boolean;
}

export function chooseEmotion(state: PickState, level: PickLevel, emotion: Emotion): { state: PickState; status: ChooseStatus } {
  if (state.phase !== 'choose') return { state, status: state.done ? 'done' : 'comfort' };
  const round = currentRound(state, level);
  if (!round) return { state: { ...state, done: true, phase: 'comfort' }, status: 'done' };
  if (emotion !== round.target) return { state, status: 'wrong' };   // 不推进,也不换题
  return { state: { ...state, phase: 'comfort' }, status: 'comfort' };
}

选错不推进、不换题、不扣任何东西,只换一句更温柔的话(内容里的 retry)。选对进入 comfort 阶段,comfort() 负责推进到下一轮或收尾。安抚动作本身不携带文案,按钮文字走 @bamboo/i18n 的 comfort.hug / comfort.pat / comfort.highfive——同一个动作在玩法、小屋、日记里都出现,文案必须只有一份。

当前内容是三轮:难过(安慰=抱抱)、生气(拍拍)、开心(击掌)。三张脸由 drawEmotionFace 画,五种表情各有自己的眉眼与嘴形;每一轮的 options 恰好三项、必须包含 target——这两条由 zod 的 .length(3) 与 content:check 的语义校验共同守住,其中「选项里必须有正确答案」是 schema 表达不了的。

场景里三张脸卡片是 200×230、间距 24、顶部 950;安慰按钮是 460×104 的固定矩形,命中靠手算(Math.abs(pointer.x - COMFORT.cx) <= COMFORT.w / 2),注释解释了原因:「避免 Phaser 容器命中区在 4.x 的怪癖」——与 createDragController 自己算距离是同一个决定(第 8 章)。中文标签在 EMOTION_LABEL_KEYS 里,五张脸(happy / sad / angry / scared / sleepy)各一条。

一个诚实的妥协写在 pandaMoodOf() 的注释里:drawPanda 没有「害怕」这个表情,用难过的脸近似;但卡片上的脸是完整的害怕脸(竖眉 + 圆嘴),所以认知训练的目标不受影响。

7.9 learn:认颜色、形状、数字

轻认知模板的判定最短:

export function chooseOption(state: LearnState, level: LearnLevel, index: number): { state: LearnState; status: LearnStatus } {
  if (state.done) return { state, status: 'done' };
  const question = currentQuestion(state, level);
  if (!question) return { state: { ...state, done: true }, status: 'done' };
  if (index !== question.answer) return { state, status: 'wrong' };
  const nextRound = state.round + 1;
  if (nextRound >= level.rounds.length) {
    return { state: { ...state, round: nextRound, done: true }, status: 'done' };
  }
  return { state: { ...state, round: nextRound }, status: 'correct' };
}

答案用下标(answer)而不是选项内容,因为「数字 3」的选项本身就是标签文本(label: "3"),用内容做答案会要求标签唯一。schema 里 options 是 2–4 个对象,每个可以有 color 或 shape,所以同一种题型能画成色卡、形卡或数字卡。

场景把这种「同一模板三种长相」压到最小:卡片 200×250、间距 24、顶部 910;SHAPE_COLORS 只给形状上色,颜色不承担「正确答案」的信息——形状题的答案看形状本身,颜色只是让卡片更好看。答对后 600ms 才换下一题:

// 答对后 600ms 才换下一题 —— 给孩子一点时间看见自己答对了
const wait = this.instant ? 150 : 600;

locked 标记在这段等待里挡住连点,防止「一次连点把下一题也点掉」。右侧 (588, 780) 站着一只缩到 0.62 倍的竹竹当陪读;背景是 classroom,房间绘制里带一块小黑板(drawRoom 的 classroom 分支)。

7.10 33 个单测盯住的就是这些判定

八个模板的判定函数共用一个测试文件 packages/templates/src/logic.test.ts:9 个 describe、33 个 it,分布是 scrub 6、feed 4、dress 3、order 3、tidy 3、rhythm 6、pick 3、learn 3,最后 2 个盯 schema 注册表。挑几个能说明「测试盯的是什么」的用例:

describe 代表用例 盯的规则
scrub 「只在半径内命中最近的点」 宽容但只命中一个点
scrub 「刷够次数才算干净,刷完的点不再计数」 rubs 上限与幂等
scrub 「所有点干净之前不进入收尾」 冲水必须先刷干净
feed 「全部吃完才完成,均衡目标看类别」 complete 与 balanced 是两个概念
feed 「未知食物不会破坏状态」 脏 id 不抛错、不改状态
dress 「未知物品不改变状态」 同上
order 「先点后面的步骤不算数,也不会清掉进度」 零失败的核心断言
tidy 「每个玩具类别都必须有箱子(由 content:check 额外守住)」 逻辑层管不到、内容层管
rhythm 「中心附近是有效窗口」 isInZone 的边界
rhythm 「220ms 内的连点被忽略,不计分也不计错」 连点门
rhythm 「自动通关能算出下一次窗口时间」 nextHitDelay 与真实判定共用同一套函数
pick 「每轮选项必须包含答案且不重复」 语义约束(与 content:check 重复覆盖,双保险)
learn 「答案下标必须落在选项范围内」 内容写坏时的第一道拦截
schema 注册表 「8 个模板全部登记了 schema」/「每个 schema 都能拒绝空对象」 模板契约本身的完整性

最后一条测试是「反例测试」:schema.safeParse({}) 必须失败。它挡住的是「schema 写得太松」这种情况——如果某个模板的 schema 全字段可选,{} 也会通过,那内容校验就等于没做。这类测试不测业务,测的是校验器还有没有校验能力。

这 33 个用例全部跑在 Node 里:不启动浏览器、不加载 Phaser、不等动画,vitest run 一两秒内跑完。能做到这一点,正是因为 *.logic.ts 里没有一行渲染代码——scrubAt 不知道「细菌」长什么样,tapStep 不知道「拉窗帘」画在哪,registerTap 不知道音调会被送给谁。它们只是把「孩子做了什么」变成「这一局现在是什么状态」,而这个变换是完全可以断言的。

八个模板的共同点,回到一句话:判定与画面分离,时间与随机从参数进来,错误状态永不清空进度。 前者让 33 个单测能覆盖全部玩法规则,中间那条让慢设备与 CI 上结果一致,最后一条是儿童产品的底线。下一章换到另外一半:这些判定被画到屏幕上时用的那套工具,以及为什么它值得为体积做一次裁剪。


← 上一章 目录 下一章 →