第13章 - 实战:开发一个新的日常任务

前十二章把 BambooBuddy 拆成了契约、外壳、内容、模板、引擎、平台、进度、双语八个部件。这一章把它们拼回去:从零新增一条日常任务,一条命令一条命令地跑通。

为了不让示例变成”编一个产品需求”,本章选择成本最低、也最能说明问题的一种加法:复用 learn 模板新增一条「数一数」(假设 id 为 count)。仓库里并没有这条任务——下面出现的 count 全部是示范,除此之外的每一个文件路径、每一行登记代码、每一条报错信息,都来自当前仓库的真实实现。读完你会发现:新增一条任务不需要碰 Phaser,不需要改模板,甚至不需要改进度系统,需要动的只有”登记点”。

13.1 先把”完成”定义清楚

这个仓库对”做完了”有一份可执行的答案,写在 package.json 与 scripts/verify.mjs 里:

门槛 命令 通过标准
类型 pnpm typecheck vue-tsc --noEmit -p tsconfig.json 全仓 strict 无错
风格 pnpm lint eslint 10 flat config,含”外壳禁止 import @bamboo/content/full“与”纯逻辑层禁止 Math.random / Date.now”
逻辑单测 pnpm test vitest run,当前 91 个用例全绿
内容体检 pnpm content:check tsx packages/templates/src/cli/check-content.ts,schema + 语义 + 双语 + 覆盖
打包体检 pnpm pack:check node scripts/check-packaging.mjs,Tauri / Capacitor / workflow 配置齐全
构建与体积 pnpm build + pnpm size dist 41 文件 / 1,245,469 B,首屏与单 chunk 均在预算内
端到端 pnpm e2e 20 passed / 2 skipped,10 个玩法 autoplay 全通关

这份顺序不是按「重要性」排的,而是按反馈速度排的:content:check 与 typecheck 通常几秒内出结果,test 十几秒,e2e 要起服务器并把十个玩法各玩一遍,size 必须等 build 结束。写内容的人 90% 的错误(少一个键、英文漏一行、时段覆盖不够)都会在这两道门禁里被抓到,不必等到浏览器里点。

pnpm verify 把前六项串成一条流水线(细节在第 14 章)。本章只新增内容、不新增模板,所以单测数量不变,而 e2e 的用例数会自动 +1:e2e/smoke.spec.ts 用 for (const id of TASK_IDS) 给每条任务生成一条”autoplay 能通关、任务卡打勾、存档计数增加”的用例,e2e/preview.spec.ts 也会多断言一个 game-count-*.js chunk。这是本项目最省事的一处设计:新增内容不用新增测试,测试跟着 TASK_IDS 长。

13.2 一条任务被登记在十三个地方

先给结论:新增一条任务要写 4 份 JSON(中文摘要、中文详情、英文摘要补丁、英文详情补丁)、改 7 个 TypeScript 文件(ids / tasks / loadTask / summaries / full / registry / 玩法入口)、加 1 个 package.json 子路径。下表把 loadTask.ts 的两张表拆成两行,所以是十三行——建议直接当 checklist 用:

# 位置 作用 漏了会怎样
1 packages/content/src/ids.ts 的 TASK_IDS 任务 id 的唯一事实来源 所有 Record<TaskId, …> 都缺键,typecheck 直接红
2 packages/content/src/tasks/<id>.json 中文摘要(首屏数据) tasks.ts 拿不到内容,运行时抛错
3 packages/content/src/tasks.ts 的 RAW 摘要表 typecheck:Record<TaskId, unknown> 缺一个键
4 packages/content/src/details/<id>.game.json 中文关卡脚本 + 语音文案 loadTask() 没有对应的 loader
5 packages/content/src/loadTask.ts 的 ZH_LOADERS 详情动态 import 表 typecheck 缺键;构建期 chunk 也不会生成
6 packages/content/src/i18n/en/<id>.json 英文摘要补丁 英文界面回落中文,content:check 判漏翻
7 packages/content/src/i18n/en/summaries.ts 英文补丁聚合 typecheck:Record<TaskId, SummaryPatch> 缺键
8 packages/content/src/i18n/en/<id>.game.json 英文详情补丁 英文关卡脚本缺失,content:check 拦
9 packages/content/src/loadTask.ts 的 EN_LOADERS 英文详情动态 import 切英文进玩法还是中文脚本
10 packages/content/src/full.ts 的 ZH_EXTRA / EN_DETAIL_PATCHES Node 侧完整内容(CLI 与单测) content:check 的条数对不上
11 games/daily/src/games/<id>.ts 玩法入口(defineGame) 动态 import 找不到模块
12 apps/site/src/registry.ts 外壳的 id → loader 表 typecheck 缺键;点进去 404
13 games/daily/package.json 的 exports 锁死 @bamboo/game-daily/<id> 子路径 构建期 Failed to resolve entry for package

这张表看起来吓人,但它其实是「内容拆两半」的直接代价与收益。摘要进首屏,所以要有独立的 JSON 与独立的类型断言;详情进懒加载 chunk,所以要有 loadTask() 的两张 loader 表;英文是补丁而不是第二份内容,所以要有聚合表把十条补丁收成一个小 chunk。每一个登记点都对应一次真实的体积或加载决策,不是仪式。

第 14 个”登记点”是 e2e 与体积报告,但它们不需要你手写:smoke.spec.ts 与 preview.spec.ts 都从 TASK_IDS 取清单。

两个”不会假红”的细节值得一提:

  • content:check 里”任务数量不对”的期望值是 TASK_IDS.length,所以加一条任务不会让这条检查误报;
  • tasks.ts 里有一句 if (task.id !== id) throw new Error(...),它防的是”JSON 里的 id 与文件名/键不一致”——这类错如果只靠人眼,通常要等玩家点进去才发现。

13.3 第 1 步:写中文摘要

摘要决定小屋、任务页、装扮页、日记页看到的一切。它必须能在首屏渲染,所以只放轻字段:

// packages/content/src/tasks/count.json(示范文件,仓库未包含)
{
  "id": "count",
  "title": "数一数",
  "emoji": "🔢",
  "color": "#f2c14e",
  "timeSlots": ["morning", "noon"],
  "template": "learn",
  "durationHintSec": 50,
  "ageRange": [4, 7],
  "unlockLevel": 1,
  "stats": { "mood": 6, "health": 3 },
  "reward": { "shoots": 2, "xp": 11 },
  "habit": "每天数一数身边的东西",
  "diary": "今天和竹竹一起数了苹果和积木。",
  "tip": "慢慢数,用手指点一下。"
}

字段的边界由 packages/content/src/schema.ts 的 TaskSummarySchema 定义,写超了在 content:check 就会被拦:

字段 约束(zod) 说明
id z.enum(TASK_IDS) 必须同时出现在 TASK_IDS 里
title 1–24 字 中文标题;英文标题必须不同,否则判漏翻
emoji 1–8 字符 卡片与日记的图标,唯一”美术资源”
color #rrggbb 卡片主色,正则校验
timeSlots 1–3 个,枚举 morning/noon/evening 推荐系统按它挑任务
template z.enum(TEMPLATE_IDS) 8 个模板之一
durationHintSec 20–120 的整数 单局目标时长,卡片上显示”约 50 秒”
ageRange [3–9, 3–9] 的元组 当前内容统一写 [4, 8] 或更窄
unlockLevel 1–4,默认 1 成长阶段门槛,见下面的注意事项
stats z.partialRecord(STAT_KEYS, 1–30) 只写这次任务提升的维度
reward.shoots 1–5 竹笋(当天重复减半)
reward.xp 5–30 成长值
habit 1–80 字 家长页”本周练习过的习惯”
diary 1–160 字 日记里的一行旁白
tip 1–100 字 卡住时才出现的温柔提示

两个坑:

  1. stats 必须用 z.partialRecord。zod 4 的 z.record(z.enum(STAT_KEYS), …) 是”穷尽记录”,只给两个维度会报另外三个键 missing——这是技术方案第 7 节记录的第一个坑。
  2. unlockLevel 慎写 2 以上。TasksView.vue 的 locked() 是 unlockLevel > progress.stage.id,写 2 意味着第一阶段的孩子只看到一张带锁的卡(data-testid="task-card-count" 仍在,但”开始”按钮 disabled)。e2e 的 autoplay 走 URL 直达,不受这把锁影响,所以”测试全绿但孩子点不进去”是可能发生的——示例里保持 1。

13.4 第 2 步:写关卡脚本

详情文件只做三件事:进入时念一句、完成时夸一句、做错时哄一句,外加这一局的关卡脚本。

// packages/content/src/details/count.game.json(示范文件,仓库未包含)
{
  "intro": "我们来数一数,看看有几个?",
  "done": "数得真准,竹竹给你鼓掌!",
  "retry": "再数一遍,别着急。",
  "levelScript": {
    "room": "classroom",
    "rounds": [
      {
        "kind": "number",
        "question": "这里有几个苹果?",
        "options": [{ "label": "2" }, { "label": "3" }, { "label": "4" }],
        "answer": 2
      },
      {
        "kind": "number",
        "question": "这里有几块积木?",
        "options": [{ "label": "1" }, { "label": "2" }, { "label": "3" }],
        "answer": 1
      }
    ]
  }
}

levelScript 的形状由模板的 *.logic.ts 决定。learn 的 schema 是这样的:

// packages/templates/src/learn.logic.ts(节选)
export const learnLevelSchema = z.object({
  room: z.enum(ROOMS),
  rounds: z
    .array(
      z.object({
        kind: z.enum(['color', 'shape', 'number']),
        question: z.string().min(1).max(80),
        options: z
          .array(
            z.object({
              label: z.string().min(1).max(8),
              color: hexSchema.optional(),
              shape: z.enum(SHAPES).optional(),
            }),
          )
          .min(2)
          .max(4),
        answer: z.number().int().min(0),
      }),
    )
    .min(2)
    .max(4),
});

也就是说:2–4 轮,每轮 2–4 个选项,answer 是下标。这里有一个真实的缺口要自己守:answer 只校验了 min(0),没有校验上界;写一个 answer: 5 能通过 content:check,但 chooseOption() 里 index !== question.answer 永远成立,这一轮会变成”怎么点都不对”。内容里出现这种值,最省事的发现方式是给新内容补一条断言(第 13.9 节)。

对比一下就该知道”关卡脚本”到底管多少事:刷牙的脚本是 6 个 spots 加 rubs: 2、finish: { kind: 'rinse', taps: 5 };睡觉是 4 个有序 steps;整理是 bins 与 items 的类别匹配。同样是 30–90 秒的一局,差别只在数据,不在代码——这正是模板库存在的意义。

坐标也统一:关卡脚本里的 x / y 是 0–1 的相对值,由模板映射到自己的 stage 矩形(例如刷牙的牙齿区是 { x: 250, y: 760, w: 260, h: 220 })。好处有两个:同一个模板换一张背景不必重排所有点位;e2e 可以拿同一份 0–1 数据算出屏幕坐标,而不必在用例里写魔法数字。代价是「这个点在画面上到底落在哪」必须靠 layout.ts 的常量对齐——场景画在哪,常量就得写在哪。

13.5 第 3 步:选模板,还是写模板

八个模板各自的 schema、判定函数与真实用途:

模板 schema 文件 判定关键点 当前用于
scrub scrub.logic.ts 最近点位 + 半径、每点 rubs 次、收尾 finish.taps 刷牙 / 洗手 / 洗澡
feed feed.logic.ts 每口递减、总口数、needKinds 均衡 吃饭
dress dress.logic.ts 槽位匹配、全部穿上 穿衣
order order.logic.ts 当前步骤、错点不清进度 睡觉
tidy tidy.logic.ts 类别匹配、全部归位 整理
rhythm rhythm.logic.ts 墙钟三角波窗口、220ms 连点门 运动
pick pick.logic.ts 目标情绪、轮次推进、安慰动作 情绪
learn learn.logic.ts 答案下标、可无限重试 学习

选型只看一句话:孩子在这一局里做的动作,和模板的原语是不是同一件事。”数一数”是点选,learn 就是对的;如果硬套 scrub,你会得到”用牙刷把数字刷掉”这种荒谬的隐喻。

真需要第九个模板时,登记顺序不能乱(顺序错了报错会很难懂):

  1. packages/content/src/ids.ts 的 TEMPLATE_IDS 加 id;
  2. 新写 packages/templates/src/<id>.logic.ts:zod schema + 纯判定函数 + 状态类型,不 import Phaser;
  3. packages/templates/src/schemas.ts 里加一行——它是 satisfies Record<TemplateId, ZodType>,少一个 typecheck 当场报;
  4. 写场景 packages/templates/src/<id>.ts,场景只负责画与把手势喂进判定;
  5. 若要 e2e 用真实指针通关,把最终坐标放进 packages/templates/src/layout.ts(纯常量、不 import Phaser);
  6. content:check 会检查”每个模板都被真实内容用到”——新模板必须至少有一条任务使用它,否则报”模板 X 没有被任何任务使用”。

第 6 条是刻意的:模板库最容易腐化的方式不是写错,而是写了没人用。

也要接受同质化的风险。八个模板撑十条任务,必然有复用:刷牙、洗手、洗澡共用 scrub,差别只在工具、点位与收尾动作(冲水 / 擦干)。项目的自我约束是每条任务至少有一处只属于它的画面或动作——洗澡的身体区、洗手的泡泡、刷牙的牙齿,都不是换个颜色了事。新增任务时如果发现自己只是把 title 改了,那多半应该做成同一条任务的一个关卡,而不是第十一条任务。

13.6 第 4 步:玩法入口与注册表

一个玩法入口通常不到 25 行。真正跑着的 brush.ts 与 learn.ts:

// games/daily/src/games/brush.ts
import { getTaskSummary } from '@bamboo/content';
import { defineGame } from '@bamboo/templates/core';
import { DESIGN } from '@bamboo/templates/layout';
import { createScrubScene } from '@bamboo/templates/scrub';
import type { ScrubLevel } from '@bamboo/templates/scrub.logic';

const summary = getTaskSummary('brush')!;

export default defineGame({
  manifest: {
    id: 'brush',
    title: summary.title,
    version: '0.1.0',
    template: summary.template,
    designSize: { width: DESIGN.width, height: DESIGN.height },
    ageRange: summary.ageRange,
  },
  scenes: (ctx) => [createScrubScene(ctx, ctx.task.levelScript as ScrubLevel)],
});

新增的 count.ts 照抄这个形状,只换三处:getTaskSummary('count')、createLearnScene、LearnLevel。

// games/daily/src/games/count.ts(示范文件,仓库未包含)
import { getTaskSummary } from '@bamboo/content';
import { defineGame } from '@bamboo/templates/core';
import { DESIGN } from '@bamboo/templates/layout';
import { createLearnScene } from '@bamboo/templates/learn';
import type { LearnLevel } from '@bamboo/templates/learn.logic';

const summary = getTaskSummary('count')!;

export default defineGame({
  manifest: {
    id: 'count',
    title: summary.title,
    version: '0.1.0',
    template: summary.template,
    designSize: { width: DESIGN.width, height: DESIGN.height },
    ageRange: summary.ageRange,
  },
  scenes: (ctx) => [createLearnScene(ctx, ctx.task.levelScript as LearnLevel)],
});

三点纪律:

  • 关卡脚本只做类型断言,不在运行时跑 zod。zod 加八个模板 schema 会进每一个游戏 chunk,而 content:check 已经在构建期校验过了;
  • manifest.id 必须与内容 id 一致,GameManifest.id 的类型是 TaskId,写错编译期就报;
  • 设计尺寸取 DESIGN(750×1334),不要在入口里写魔法数字。

然后是两个注册:

// games/daily/package.json(节选)
"exports": {
  ".": "./src/index.ts",
  "./brush": "./src/games/brush.ts",
  "./count": "./src/games/count.ts"
}
// apps/site/src/registry.ts(节选)
export const registry: Record<TaskId, GameLoader> = {
  brush: () => import('@bamboo/game-daily/brush'),
  count: () => import('@bamboo/game-daily/count'),
};

registry 的类型是 Record<TaskId, GameLoader>——这是”新增任务必须登记”的编译期保证,比任何文档都可靠。条目顺序也建议与 TASK_IDS 一致:两者顺序不同不会报错,但排查”到底少没少”时会多花几分钟。

还有一个「不该登记」的地方:games/daily/src/index.ts 是刻意空的(export {};),只为测试与类型检查提供一个总入口。不要在这里 re-export 十个玩法——那会让 barrel 把所有玩法拖进同一个 chunk,「一个任务一个 chunk」当场作废。Vite 会把每个入口切成独立 chunk(文件名 game-<id>-<hash>.js),所以新增 count 后 preview.spec.ts 里”10 个玩法不能共用一个 chunk”的断言会自动变成 11。

13.7 第 5 步:双语文案

双语不是”再写一份内容”,而是结构共用、文案打补丁。三条新文件:

// packages/content/src/i18n/en/count.json(示范文件,仓库未包含)
{
  "title": "Count With Me",
  "habit": "Count things around you every day",
  "diary": "We counted apples and blocks together.",
  "tip": "Count slowly and point with your finger."
}
// packages/content/src/i18n/en/count.game.json(示范文件,仓库未包含)
{
  "intro": "Let's count together. How many are there?",
  "done": "Perfect counting — Bamboo is clapping!",
  "retry": "Count again, take your time.",
  "levelScript": {
    "room": "classroom",
    "rounds": [
      {
        "kind": "number",
        "question": "How many apples are here?",
        "options": [{ "label": "2" }, { "label": "3" }, { "label": "4" }],
        "answer": 2
      },
      {
        "kind": "number",
        "question": "How many blocks are here?",
        "options": [{ "label": "1" }, { "label": "2" }, { "label": "3" }],
        "answer": 1
      }
    ]
  }
}

再各加一行聚合:

// packages/content/src/i18n/en/summaries.ts
import count from './count.json';
export const EN_SUMMARY_PATCHES: Record<TaskId, SummaryPatch> = {
  brush, wash, feed, dress, bath, sleep, tidy, sport, emotion, learn, count,
};
// packages/content/src/full.ts
import count from './details/count.game.json';
import countEn from './i18n/en/count.game.json';

const ZH_EXTRA: Record<TaskId, unknown> = { /* … */ count };
const EN_DETAIL_PATCHES: Record<TaskId, DetailPatch> = { /* … */ count: countEn };

英文补丁的规矩:

  • 摘要补丁只翻人读的字段(title / habit / diary / tip),结构字段不重复维护;
  • 详情补丁要给全 intro / done / retry 与整份 levelScript;运行期仍然逐字段回落中文(少一个字段不该把一局弄崩),但 content:check 会在构建期判漏翻;
  • 英文标题不能与中文相同,这是一条独立的漏翻信号;
  • 界面文案表(packages/i18n)里的键不用动——新任务只复用已有的按键(tasks.start、complete.reward 等)。真需要新键时,Record<MessageKey, string> 会让中英两张表同时报缺。

语音不用单独做一份。多数模板场景会念 ctx.task.intro,而 done 与 retry 在所有模板里都会用到;learn 与 pick 是例外——它们不念开场白,直接念每轮的 question。音频总线按当前语言挑嗓音(zh-CN / en-US),没有语音包就静默降级。所以内容作者只要保证「这句话读出来是人话」——短句、不用缩写、不写括号里的补充说明。带 ?instant=1 时多数模板会跳过开场语音,这正是截图与冒烟巡检不会等语音的原因。

13.8 第 6 步:奖励、日记与推荐为什么自动接上

这是模板 + 摘要架构的回报:新增任务不需要改 packages/progress 一行代码。原因是所有消费方都只读 summary 的几个字段:

字段 消费方 效果
reward.shoots / reward.xp rewards.ts 的 completionReward() 首次全份、当天重复减半(最低 1 根竹笋、3 成长值)
stats rewards.ts 的 statsDeltaFor() 首次全份、重复减半向上取整,永不为 0
timeSlots daily.ts 的确定性推荐 早上 / 中午 / 晚上各挑得出 2 件以上
diary diary.ts / DiaryView.vue “竹竹的一天”里自动出现这一行
habit ParentView.vue 本周练习过的习惯清单
unlockLevel TasksView.vue / HomeView.vue 阶段锁与今日推荐

贴纸与碎片也不需要登记:贴纸由”同一任务连续 3 天完成”在 completeTask() 里判定,碎片由”当天第 3/6/9 个不同任务”在 fragmentsFor() 里判定——两者都只依赖 TASK_IDS 与日记录,与具体是哪条任务无关。

结算链路值得完整看一眼,它解释了为什么”加内容”不用碰代码:

模板场景判定通过
  └─ ctx.emit('task:complete', { taskId, stars, mistakes, correct, total, durationMs })
       └─ PlayView 的 handleEvent → progress.completeTask(taskId, stars)
            └─ ProgressManager.completeTask(summary, stars)   ← 这里才第一次读 summary
                 ├─ completionReward()   竹笋 / 成长值(当天重复减半)
                 ├─ statsDeltaFor()      五维收益(重复减半、向上取整)
                 ├─ fragmentsFor()       第 3/6/9 个不同任务给碎片
                 ├─ nextStreak() + earnsSticker()   连续 3 天给贴纸
                 └─ commit() → ProgressStore.save() → IndexedDB

也就是说,模板只负责”这一局算不算完成”和”几颗星”,奖励口径一处都不在玩法里。这是”模板不允许自己拼奖励”的具体含义:如果让刷牙场景自己发竹笋,那么第二天新增的任务就会有一份自己的算法,数值迟早对不上。

13.9 第 7 步:跑一遍自检

pnpm content:check          # 先跑它:schema / 语义 / 双语 / 覆盖,最快的反馈
pnpm typecheck              # 再跑它:所有 Record<TaskId, …> 缺键会在这里一起爆
pnpm test                   # 91 个单测(本章不新增逻辑,数量不变)
pnpm dev                    # http://127.0.0.1:5190

开发时可用的两条直达链接(count 就位后):

http://127.0.0.1:5190/#/play/count?autoplay=1     自动通关
http://127.0.0.1:5190/#/play/count?instant=1      跳过过场,手动试玩

注意调试开关只在开发服务器与自动化浏览器里生效——createGameContext.ts 的 debugAllowed() 判断 import.meta.env.DEV 或 navigator.webdriver。这是为了防止有人把带 ?autoplay=1 的分享链接发出去自动刷星星。

然后跑端到端与体积:

pnpm e2e --grep "count"     # 只跑这一条任务生成的用例
pnpm build && pnpm size     # 产物结构 + 七项体积预算

三个预期结果:

  1. e2e 输出里多一条”玩法 · count:autoplay 能通关,任务卡打勾,存档计数增加”,并断言 save.tasks.count.completions >= 1、stars >= 1;
  2. dist/assets/ 里多出 game-count-*.js、content-count-*.js、count.game-*.js 三个文件(前者是入口,后两者是中文与英文详情),总量增加不到 8 KB;
  3. pnpm size 仍全绿:首屏不变(新增内容只进懒加载 chunk),最大 chunk 仍是 vendor-phaser。

要不要把新任务加进 flows.spec.ts?只有当它属于「必须真实手指点得动」的那几类(scrub / tidy / order)时才需要。learn 是点选,autoplay 走的就是真实点击路径,再写一条真实指针用例只是重复劳动。判断标准不是「重不重要」,而是这条路径有没有手势轨迹(按住、拖动、来回刷)。

最后补一条针对第 13.4 节缺口的断言——放在 packages/content/src/localize.test.ts 或模板单测里都行:

it('count 的每一轮 answer 都在选项范围内', async () => {
  const task = await loadTask('count');
  const level = task!.levelScript as { rounds: { options: unknown[]; answer: number }[] };
  for (const [index, round] of level.rounds.entries()) {
    expect(round.answer, '第 ' + (index + 1) + ' 轮').toBeLessThan(round.options.length);
  }
});

13.10 变更评审清单

门禁只能证明「机器认得的都对」,还有几件事只有人能判断。提交这条任务前,用下面六问过一遍(这就是本项目的 code review 清单在内容侧的版本):

# 问题 不合格的样子
1 这一局的动作,4 岁孩子看得懂吗? 需要读一段说明才会玩;按钮只有文字没有图标
2 有没有失败感? 出现倒计时、扣星、「你错了」;星星可能是 0
3 不靠颜色能不能玩? 只靠红绿区分对错,形状与图标没有第二重线索
4 关键文本有中文语音吗? 只有屏幕文字;intro / done / retry 写成了长句
5 文案里有没有伤害性词汇? 「失败」「惩罚」「笨蛋」「输了」「淘汰」(content:check 会拦,但要理解为什么拦)
6 英文是不是「另一个孩子也能读懂」? 直译、长句、标题与中文一模一样

第 2 条与第 5 条在 content:check 里有机器版本(禁词表、英文标题判重),但「有没有失败感」最终要看动画与音效:这一局做错时是「再试一次」的柔和提示,还是一声下降音。前者在 platform/web.ts 的 retry 音型里(两个下行三角波,音量 0.12),后者在这个项目里不存在。

13.11 漏登记对照表:第一个报错的会是谁

把 13.2 的表格换成”报错视角”,排查时不必从第一条开始猜:

漏掉的地方 第一个报错的命令 报错长什么样(近似)
TASK_IDS pnpm typecheck Property 'count' is missing in type … but required in type 'Record<TaskId, GameLoader>'
tasks/<id>.json pnpm typecheck Cannot find module './tasks/count.json'(tasks.ts 的静态 import 解析不到)
tasks.ts 的 RAW pnpm dev 或 pnpm test TypeError: Cannot read properties of undefined (reading 'id')(文件在、没登记进 RAW)
details/<id>.game.json 或 ZH_LOADERS pnpm typecheck 缺文件:Cannot find module './details/count.game.json';缺 loader:Record<TaskId, …> 缺键
i18n/en/summaries.ts pnpm typecheck Record<TaskId, SummaryPatch> 缺键
i18n/en/<id>.game.json pnpm content:check count(en)/ … 脚本 (根):… 或 英文内容里还有中文:levelScript.rounds[0].question
英文标题照抄中文 pnpm content:check count 的英文标题与中文相同(疑似漏翻)
文案含”失败/惩罚” pnpm content:check count 的中文文案出现"失败":…
games/daily 的 exports pnpm build Failed to resolve entry for package "@bamboo/game-daily"
registry.ts pnpm typecheck 同第一条,Record<TaskId, GameLoader> 缺键
full.ts 的两个表 pnpm content:check 任务数量不对:期望 11,实际 10
timeSlots 覆盖不足 pnpm content:check 早上时段只有 1 个任务(至少 2 个,保证有得选)

这张表能解释一件容易被误解的事:为什么”加内容”这种看起来纯数据的工作,也值得跑一遍全量门禁。因为内容分散在 13 个位置,而每个位置都有各自的守卫;单跑 pnpm dev 只会告诉你”这一条没崩”,不会告诉你”英文漏了三行”。

13.12 小结与下一章

一条新任务的成本被压到了这样一组动作:两份中文 JSON、两份英文补丁、一个 20 行的入口、一条 TASK_IDS,再加七处 TypeScript 登记。没有 Phaser 代码,没有模板改动,没有进度逻辑改动——第 11 条任务比第 1 条便宜,是这套分层唯一值得炫耀的地方。

但”便宜”不等于”随便”。成本账还可以再算细一点:一条任务在首屏增加的是一个摘要对象(约 0.4 KB 原始)与卡片上的一次渲染;在懒加载侧增加的是三个 chunk(入口约 0.3–8 KB、中文详情、英文详情各约 0.2–0.7 KB)。所以十条任务的全部内容加起来,仍小于 vendor-phaser 的百分之一——内容不是体积问题,代码分割与引擎才是。

也正因为成本低,真正的风险从”写不完”变成了”写得太多”:任务越多,任务页越长、推荐越稀、每条任务的动画越容易被稀释。这个项目把上限压住的方式是模板数(8 个)与”一条任务一个专属画面”的评审要求,而不是技术限制。

必须提醒的是:上面每一处登记都绑着一条门禁,而这些门禁本身也是代码,也会写错、也会假绿。下一章我们把它们全部拆开:91 个单测分别守什么、四套 Playwright 各证明什么、pnpm verify 的七个阶段为什么这样排、体积预算的七项口径又是怎么算出来的。


← 上一章 目录 下一章 →