第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 字 | 卡住时才出现的温柔提示 |
两个坑:
stats必须用z.partialRecord。zod 4 的z.record(z.enum(STAT_KEYS), …)是”穷尽记录”,只给两个维度会报另外三个键 missing——这是技术方案第 7 节记录的第一个坑。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,你会得到”用牙刷把数字刷掉”这种荒谬的隐喻。
真需要第九个模板时,登记顺序不能乱(顺序错了报错会很难懂):
packages/content/src/ids.ts的TEMPLATE_IDS加 id;- 新写
packages/templates/src/<id>.logic.ts:zod schema + 纯判定函数 + 状态类型,不 import Phaser; packages/templates/src/schemas.ts里加一行——它是satisfies Record<TemplateId, ZodType>,少一个 typecheck 当场报;- 写场景
packages/templates/src/<id>.ts,场景只负责画与把手势喂进判定; - 若要 e2e 用真实指针通关,把最终坐标放进
packages/templates/src/layout.ts(纯常量、不 import Phaser); 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 # 产物结构 + 七项体积预算
三个预期结果:
e2e输出里多一条”玩法 · count:autoplay 能通关,任务卡打勾,存档计数增加”,并断言save.tasks.count.completions >= 1、stars >= 1;dist/assets/里多出game-count-*.js、content-count-*.js、count.game-*.js三个文件(前者是入口,后两者是中文与英文详情),总量增加不到 8 KB;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 的七个阶段为什么这样排、体积预算的七项口径又是怎么算出来的。