第12章 - 实战:开发一个新的典故

12.1 目标

前面十一章把 CHAGS 的每个零件都拆开讲过了。这一章把它们装回去:从零添加一个全新的典故「孔融让梨」,走完内容、游戏包、注册、测试、验证的完整流程。

  • id:kongrong
  • 定性:信史(东汉),yearApprox: 190
  • 动词:让
  • 玩法:复用 place 模板(把东西放到它该去的地方)—— 三只大小不同的梨,一只只送到三位家人手里
  • 预期改动:内容 JSON、英文补丁、一个游戏包(5 个文件)、外壳注册两处、端到端用例一条

选 place 不是偷懒:模板库的设计目标就是「新典故优先复用,装不下再新增模板」(place 在项目文档里明确列过「孔融让梨、完璧归赵、负荆请罪」等复用面)。本章最后也给出「什么时候该新写模板」的判断。

12.2 第一步:内容 JSON

新建 packages/content/src/allusions/kongrong.json:

{
  "id": "kongrong",
  "kind": "history",
  "title": "孔融让梨",
  "pinyin": "kǒng róng ràng lí",
  "dynasty": "东汉",
  "era": "东汉·末年",
  "yearApprox": 190,
  "figures": ["孔融"],
  "source": {
    "book": "《后汉书·孔融传》",
    "quote": "年四岁时,与诸兄共食梨,融辄引小者。大人问其故,答曰:我小儿,法当取小者。"
  },
  "moral": "把大的让出去,把小的留给自己。",
  "keywords": ["谦让", "分享", "家人"],
  "ageRange": [6, 9],

  "story": [
    { "id": "s1", "title": "一盘梨",
      "text": "桌上有一盘梨,有大有小。",
      "narration": "桌上有一盘梨,有大有小。",
      "illustration": "none" },
    { "id": "s2", "title": "谁来拿",
      "text": "哥哥们看着孔融,他只有四岁。",
      "narration": "哥哥们都看着孔融,他只有四岁。",
      "illustration": "none" }
  ],

  "keyMoment": {
    "beatId": "s2",
    "question": "一盘梨有大有小,孔融会拿哪一个?",
    "options": [
      { "id": "big", "text": "挑最大的", "isCanonical": false, "hint": "" },
      { "id": "small", "text": "拿最小的,大的留给哥哥", "isCanonical": true, "hint": "" },
      { "id": "none", "text": "都不吃", "isCanonical": false, "hint": "" }
    ],
    "reveal": "孔融拿起了最小的梨 —— 我年纪小,应该吃小的。"
  },

  "misconceptions": [
    "谦让就是把自己喜欢的全都让出去",
    "孔融想吃大的,只是不敢拿"
  ],

  "quiz": [
    { "id": "q1", "kind": "recall",
      "question": "孔融把什么样的梨留给了自己?",
      "options": ["最大的", "最小的", "不好吃的"],
      "answer": 1,
      "explanation": "他把最大的让给哥哥,自己拿了最小的。" },
    { "id": "q2", "kind": "recall",
      "question": "这件事发生在孔融几岁的时候?",
      "options": ["四岁", "十岁", "十四岁"],
      "answer": 0,
      "explanation": "他四岁的时候,就知道把大的让给哥哥。" },
    { "id": "q3", "kind": "transfer",
      "question": "下面哪件事和「让梨」是同一个道理?",
      "options": ["把最后一个座位让给爷爷", "抢在前面拿玩具", "自己的东西紧紧抱住"],
      "answer": 0,
      "explanation": "都是把好的先让给别人。" }
  ],

  "game": {
    "template": "place",
    "stages": [
      { "template": "place", "levelScript": {
          "slots": [
            { "id": "elder",  "x": 170, "y": 380, "kind": "elder",  "radius": 78 },
            { "id": "second", "x": 375, "y": 330, "kind": "second", "radius": 78 },
            { "id": "kong",   "x": 580, "y": 380, "kind": "kong",   "radius": 78 }
          ],
          "orbs": [
            { "id": "pearBig",   "slotId": "elder",  "x": 150, "y": 1226, "kind": "big",
              "speak": "最大的给大哥" },
            { "id": "pearMid",   "slotId": "second", "x": 375, "y": 1226, "kind": "mid",
              "speak": "不大不小的给二哥" },
            { "id": "pearSmall", "slotId": "kong",   "x": 600, "y": 1226, "kind": "small",
              "speak": "最小的留给我自己" }
          ]
      } }
    ]
  }
}

对照第 6 章的规则自查一遍:

  • story 两幕,text 都在 90 字符内;
  • keyMoment 恰好一个 isCanonical: true,beatId 命中 s2;
  • quiz 三题,其中一道 transfer;
  • game.stages 一段 place 脚本:3 个槽位 / 3 个光球、数量一致、一个槽位一只梨;
  • 每个光球都带 speak(归位时的念白);
  • source.quote 是真实出处。

然后注册到内容目录 —— 编辑 packages/content/src/allusions/index.ts,加两行:

import rawKongrong from './kongrong.json';

/* ... 在 allusions 数组里追加(位置即大厅列表中的顺序,实际排序按 yearApprox) */
AllusionSchema.parse(rawKongrong),

12.3 第二步:英文补丁

新建 packages/content/src/i18n/en/kongrong.json(结构镜像、只写要翻译的字段):

{
  "title": "Kong Rong Shares the Pears",
  "dynasty": "Eastern Han",
  "era": "Eastern Han",
  "figures": ["Kong Rong"],
  "source": {
    "book": "Book of the Later Han · Biography of Kong Rong",
    "quote": "At the age of four, when eating pears with his elder brothers, Kong Rong always took the smallest. Asked why, he answered: I am the youngest, so I should take the small one."
  },
  "moral": "Give the big one away, keep the small one.",
  "keywords": ["Yielding", "Sharing", "Family"],
  "story": [
    { "title": "A Plate of Pears",
      "text": "A plate of pears sat on the table, big ones and small ones.",
      "narration": "A plate of pears sat on the table, big ones and small ones." },
    { "title": "Who Takes Which",
      "text": "His brothers looked at Kong Rong. He was only four.",
      "narration": "His brothers all looked at Kong Rong. He was only four years old." }
  ],
  "keyMoment": {
    "question": "The pears were big and small. Which one would Kong Rong take?",
    "options": [
      { "text": "The biggest one", "hint": "" },
      { "text": "The smallest one, leaving the big ones for his brothers", "hint": "" },
      { "text": "None of them", "hint": "" }
    ],
    "reveal": "Kong Rong took the smallest pear — I am the youngest, so I should take the small one."
  },
  "misconceptions": [
    "Yielding means giving away everything you like",
    "Kong Rong wanted the big pear but did not dare to take it"
  ],
  "quiz": [
    { "question": "Which pear did Kong Rong keep for himself?",
      "options": ["The biggest", "The smallest", "The bad one"],
      "explanation": "He left the biggest for his brothers and took the smallest." },
    { "question": "How old was Kong Rong when this happened?",
      "options": ["Four", "Ten", "Fourteen"],
      "explanation": "At four, he already knew to leave the big ones for his brothers." },
    { "question": "Which of these is the same idea as \"sharing the pears\"?",
      "options": ["Giving the last seat to grandpa", "Rushing to grab first", "Holding your toys tight"],
      "explanation": "Both are about letting others have the good thing first." }
  ],
  "game": {
    "stages": [
      { "levelScript": { "orbs": [
        { "speak": "The biggest one is for our eldest brother" },
        { "speak": "The middle one is for our second brother" },
        { "speak": "The smallest one is for me" }
      ] } }
    ]
  }
}

注意最后一段:关卡脚本里也有面向孩子的文字(orbs[].speak 是归位时念出来的),它必须一起翻译 —— content:check 会在合并后扫描中文泄漏,漏掉立即报错。数组的写法是「按索引对齐」:三个光球的补丁必须写三项。

再到 packages/content/src/i18n/index.ts 注册:

import enKongrong from './en/kongrong.json';

export const patches = {
  en: {
    /* ... */
    kongrong: enKongrong,
  },
};

12.4 第三步:跑内容校验

pnpm content:check

期望在新典故处看到(节选):

· 孔融让梨 (kongrong)
  · 信史 / 2 幕故事 / 3 道复习题 / 1 段玩法:place

语言 en:
  · Kong Rong Shares the Pears

如果 place 脚本写得不合法(比如少一个光球、槽位重复),会在这里直接报错,例如:

  ✗ place 脚本 orbs: 光球数量必须与槽位数量一致

这一步永远在写游戏代码之前做 —— 内容先立住,游戏包只是「怎么画」。

12.5 第四步:游戏包

在 games/kongrong/ 下建 5 个文件。

package.json

{
  "name": "@chags/game-kongrong",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": "./src/index.ts"
  },
  "scripts": {
    "test": "vitest run",
    "typecheck": "vue-tsc --noEmit -p ../../tsconfig.json"
  },
  "dependencies": {
    "@chags/content": "workspace:*",
    "@chags/game-kit": "workspace:*",
    "@chags/phaser-kit": "workspace:*",
    "@chags/templates": "workspace:*",
    "phaser": "^4.2.1"
  }
}

建完后在仓库根跑一次 pnpm install 让 workspace 链接生效。

src/manifest.ts

import type { GameManifest } from '@chags/game-kit';
import { DESIGN } from '@chags/templates';

export const manifest: GameManifest = {
  id: 'kongrong',
  title: '孔融让梨',
  version: '0.1.0',
  template: 'place',
  orientation: 'portrait',
  designSize: { width: DESIGN.width, height: DESIGN.height },
  ageRange: [6, 9],
  sessionSeconds: [120, 240],
  skills: ['谦让', '分享', '观察'],
  curriculum: { dynasty: '东汉', yearApprox: 190, figures: ['孔融'] },
  assets: { cover: 'none' },
};

src/logic.ts

import { stageScript } from '@chags/content';
import type { AllusionContent } from '@chags/content';
import { PlaceLevelSchema } from '@chags/templates/schemas';
import type { PlaceLevel } from '@chags/templates/schemas';

/** 纯逻辑:不碰 Phaser,可以直接单测 */
export function placeLevel(content: AllusionContent): PlaceLevel {
  return PlaceLevelSchema.parse(stageScript(content, 'place'));
}

src/art.ts(全部画面由代码画)

import type Phaser from 'phaser';
import { THEME, circle, groundShadow, roundRect, shade } from '@chags/phaser-kit';

const PEAR_SIZE: Record<string, number> = { big: 54, mid: 42, small: 30 };

function drawPear(g: Phaser.GameObjects.Graphics, x: number, y: number, kind: string): void {
  const r = PEAR_SIZE[kind] ?? 40;
  g.fillStyle(THEME.leaf, 1);
  g.fillEllipse(x, y - r * 1.3, r * 0.9, r * 0.55);          // 叶子
  g.fillStyle(0xf5d76e, 1);
  g.fillEllipse(x, y, r * 1.7, r * 2.1);                     // 梨身
  g.lineStyle(6, THEME.outline, 1);
  g.strokeEllipse(x, y, r * 1.7, r * 2.1);
  g.fillStyle(shade(0xf5d76e, 0.86), 1);
  g.fillCircle(x - r * 0.4, y + r * 0.35, r * 0.28);         // 腮部阴影
}

/** 光球:一只梨,大小按 kind(big / mid / small) */
export function drawOrb(g: Phaser.GameObjects.Graphics, kind: string): void {
  drawPear(g, 0, 0, kind);
}

/** 槽位:三位家人;归位后手里多一只梨 */
export function drawPiece(
  g: Phaser.GameObjects.Graphics,
  kind: string,
  x: number,
  y: number,
  filled: boolean,
): void {
  const s = kind === 'second' ? 1.06 : 0.96;
  groundShadow(g, x, y + 96, 150 * s, 26, 0.14);
  roundRect(g, x - 54 * s, y - 60 * s, 108 * s, 150 * s, 44,
    kind === 'kong' ? THEME.red : THEME.teal, THEME.outline, 6);
  circle(g, x, y - 96 * s, 44 * s, THEME.sand, THEME.outline, 6);
  if (filled) {
    const size = kind === 'elder' ? 'big' : kind === 'second' ? 'mid' : 'small';
    drawPear(g, x + 70, y - 34, size);
  }
}

/** 分幕插图:第一幕一盘梨,第二幕三个孩子 */
export function drawBeat(
  g: Phaser.GameObjects.Graphics,
  index: number,
  rect: { x: number; y: number; w: number; h: number },
): void {
  const cx = rect.x + rect.w / 2;
  const cy = rect.y + rect.h / 2;
  if (index === 0) {
    roundRect(g, cx - 200, cy + 90, 400, 40, 20, THEME.wood, THEME.outline, 6);
    drawPear(g, cx - 90, cy + 20, 'big');
    drawPear(g, cx, cy + 52, 'mid');
    drawPear(g, cx + 90, cy + 76, 'small');
    return;
  }
  for (const [dx, h] of [[-170, 300], [-40, 330], [110, 190]] as const) {
    groundShadow(g, cx + dx, cy + 170, h * 0.7, 30, 0.14);
    roundRect(g, cx + dx - h * 0.18, cy + 170 - h, h * 0.36, h, 60, THEME.teal, THEME.outline, 6);
    circle(g, cx + dx, cy + 170 - h - 46, h * 0.22, THEME.sand, THEME.outline, 6);
  }
}

画法遵循第 8 章的工具箱约定:厚描边(THEME.outline)、落地阴影、每个梨三种尺寸(视觉上能直接看出大小)。后续要换真美术,只改这个文件。

src/index.ts

import { SCENE_KEYS, createPlaceScene, defineGame } from '@chags/templates';
import { drawBeat, drawOrb, drawPiece } from './art';
import { placeLevel } from './logic';
import { manifest } from './manifest';

export default defineGame({
  manifest,
  drawBeat,

  gameplay: (ctx, session) => ({
    entry: SCENE_KEYS.place,
    scenes: [
      createPlaceScene(ctx, {
        level: placeLevel(ctx.content),
        session,
        nextSceneKey: SCENE_KEYS.reward,
        drawPiece,
        drawOrb,
        reveal: ctx.content.keyMoment.reveal,
      }),
    ],
  }),
});

至此游戏包完成:一共不到 150 行,其中一半是画面。defineGame 会自动给它装上分幕故事与典故卡(第 4 章),createPlaceScene 提供零失败归位、拿起即提示、念白播放与无头自动。

src/logic.test.ts

import { getAllusion } from '@chags/content';
import { isPlaceComplete, slotFor } from '@chags/templates/place.logic';
import { describe, expect, it } from 'vitest';
import { placeLevel } from './logic';

describe('孔融让梨 · 关卡数据', () => {
  const raw = getAllusion('kongrong');

  it('三位家人、三只梨,一一对应', () => {
    const level = placeLevel(raw!);
    expect(level.slots.length).toBe(3);
    expect(level.orbs.length).toBe(3);
    for (const orb of level.orbs) expect(slotFor(level, orb)).toBeTruthy();
  });

  it('最大的梨在大哥的槽位,最小的留给孔融', () => {
    const level = placeLevel(raw!);
    const orb = (id: string) => level.orbs.find((o) => o.id === id);
    expect(orb('pearBig')?.slotId).toBe('elder');
    expect(orb('pearSmall')?.slotId).toBe('kong');
  });

  it('每个梨归位时都有一句念白', () => {
    const level = placeLevel(raw!);
    for (const orb of level.orbs) expect(orb.speak.length).toBeGreaterThan(0);
  });

  it('集齐三只梨才算完成,重复归位不算', () => {
    const level = placeLevel(raw!);
    expect(isPlaceComplete(level, ['elder', 'second', 'kong'])).toBe(true);
    expect(isPlaceComplete(level, ['elder', 'elder', 'elder'])).toBe(false);
  });
});

最后一个用例正是「去重计数」那条 bug 的回归测试(第 7 章)。

12.6 第五步:外壳注册

两处改动:

// apps/site/src/registry.ts —— 加一行
export const registry = {
  /* ... */
  kongrong: () => import('@chags/game-kongrong'),
};
// apps/site/package.json —— 加一条依赖
"dependencies": {
  /* ... */
  "@chags/game-kongrong": "workspace:*"
}

改完 pnpm install 一次。此后 apps/site/src/registry.test.ts(第 5 章)会自动开始要求「内容、注册表、懒加载入口」三方一致 —— 哪个环节写漏了,pnpm test 直接红。

12.7 第六步:运行与测试

开发时直接直达:

http://127.0.0.1:5183/#/play/kongrong
http://127.0.0.1:5183/?autoplay=1#/play/kongrong

然后补一条端到端真实操作用例(e2e/smoke.spec.ts,模式与 pangu / nuwa 相同):

const kongrongPlace = loadContent('kongrong').game.stages[0]!
  .levelScript as unknown as PlaceScript;

test('孔融让梨:真实拖拽把三只梨送到家人手里', async ({ page }) => {
  await page.goto('/#/play/kongrong');
  await expect(page.locator('canvas')).toBeVisible();
  await expect.poll(() => events(page)).toContain('session:start');

  // 两幕故事(注意 350ms 输入锁)
  await page.waitForTimeout(450);
  await clickDesign(page, 375, 900);
  await page.waitForTimeout(520);
  await clickDesign(page, 375, 900);
  await expect.poll(() => events(page)).toContain('scene:enter:place');

  // 三只梨各归各位:坐标直接从内容 JSON 读,不手抄
  for (const orb of kongrongPlace.orbs) {
    const slot = kongrongPlace.slots.find((s) => s.id === orb.slotId);
    if (!slot) throw new Error('光球 ' + orb.id + ' 找不到槽位');
    await dragDesign(page, { x: orb.x, y: orb.y }, { x: slot.x, y: slot.y });
    await page.waitForTimeout(200);
  }

  await expect.poll(() => events(page), { timeout: 30_000 }).toContain('card:unlocked');
  await clickDesign(page, REWARD.done.x, REWARD.done.y);
  await expect.poll(() => events(page)).toContain('session:exit');
});

另外两条用例会自动覆盖新典故,不需要手写:

  • 「中英双语下每个典故都能载入」 会枚举内容目录里的全部 id × 两种语言 —— 加过 JSON 就算进枚举;
  • 建议再补一条 autoplay 用例(照抄任一现有典故的写法),保证无头流程也过一遍。

12.8 第七步:完整验证

pnpm verify     # 类型 → lint → 单测 → 内容校验 → 构建 → 体积
pnpm e2e        # 端到端(含新加的用例)

预期:

  • 单测从 156 项增长到 160 项(新典故 4 项)全部通过;
  • registry.test.ts 的三条一致性检查通过;
  • 构建产物里多出一个 kongrong-xxxx.js 的小 chunk(几 KB),Phaser 仍是共享 chunk;
  • 首次构建后 .scratch/ 之外无新增文件。

最后跑一遍 pnpm audit:i18n,人工看一眼「孔融让梨」在两种语言下的每个画面(故事、归位、典故卡),确认没有排版问题 —— 繁体/英文的字宽差异是这类新内容最容易踩的坑。

12.9 什么时候该新写一个模板

复用优先,但遇到下面任一情况,就该按第 7 章 7.14 的清单新增模板(而不是硬塞进现有模板):

  • 判定语义不同:place 判「放对位置」,如果玩法核心是「在正确的时间点动手」(守株待兔、一鼓作气),这是新模板 timing;
  • 教学核心需要新的强制约束:sort 的「先尝后分」是数据约束,不是表现效果 —— 塞进 place 会把教学核心降级成文案;
  • 输入原语不存在:棋盘对阵(田忌赛马)、剧情分支(完璧归赵)与现有任何模板都不共享判定;
  • 同一段「定制代码」在两个典故里重复出现 —— 到第三个就该泛化,而不是复制。

判断标准可以归结为一句话:新模板的价值是让第二个典故变便宜,否则它只是一次性代码。

12.10 小结

新增一个典故的完整清单:

步骤 文件 校验者
1. 内容 JSON packages/content/src/allusions/kongrong.json AllusionSchema(构建期)
2. 内容注册 allusions/index.ts 编译期
3. 英文补丁 i18n/en/kongrong.json + i18n/index.ts content:check(结构 / 长度 / 漏翻)
4. 游戏包 ×5 games/kongrong/src/* vue-tsc / eslint / vitest
5. 外壳注册 ×2 registry.ts + apps/site/package.json registry.test.ts
6. 端到端 e2e/smoke.spec.ts Playwright
7. 全量验证 — pnpm verify + pnpm e2e + audit:i18n

内容先行、画面后补、测试兜底 —— 这套流程对「第 13 个典故」和「第 30 个典故」是一样的。

下一章展开讲这些守护者的实现:测试体系与质量门禁。