第17章 - 实战-从零开发一个新关卡
前面 16 章分别拆开了契约、模板、工具台、画布、证据与存档。这一章把它们合起来,回答一个具体问题:明天要上线第 21 关,从空文件到 pnpm verify 全绿,一共要动哪些地方、按什么顺序动、哪一步最容易漏。
20 关是同一套机制长出来的,新增一关的成本本来只该有两件事:写清楚这关训练什么思维操作,再用模板给的脚本把它表达出来。真正让人卡住的是内容之外的登记点——它们分散在近十个文件里,却必须在同一次提交里对齐。少一个,typecheck 会报;错一处,content:check 会报;还有一种最麻烦:门禁都不报,进游戏才发现 404,或者首屏凭空多了 220 KB。
17.1 六个登记点与真实文件清单
“六个登记点”是心智模型,落到文件上其实是九处改动。把它们按”内容 → 代码 → 外壳 → 测试”的顺序排开:
| # | 登记点 | 文件 | 漏了会怎样 |
|---|---|---|---|
| 1 | 关卡 id | packages/content/src/ids.ts(LEVEL_IDS / SHIPPED_LEVEL_IDS) |
内容 schema 的 z.enum(LEVEL_IDS) 直接拒收;Record<ShippedLevelId, …> 全部报缺键 |
| 2 | 摘要 | packages/content/src/levels/<id>.json + summaries.ts 的 RAW 表 |
typecheck 报 Record 缺属性;运行时抛”缺少关卡摘要” |
| 3 | 详情 | packages/content/src/details/<id>.level.json + loadLevel.ts 的 LOADERS + full.ts 的 LEVEL_DETAILS |
loadLevel() 返回 null,宿主抛「关卡内容没有加载成功」,页面显示加载失败错误态(不白屏,但进不去) |
| 4 | 关卡模块 | games/forge/src/levels/<id>.ts + games/forge/package.json 的子路径导出 |
动态 import 解析失败,路由进入即报错 |
| 5 | 外壳注册表 | apps/site/src/registry.ts |
缺键本身已被 Record<ShippedLevelId, …> 挡住;真漏了运行时会抛「关卡没有登记: |
| 6 | 测试与内容门禁 | content:check / audit:levels / 单测 / e2e |
内容或判定不合规却没被拦住,等于把校验推迟到玩家 |
有一条容易误解的”第 7 处”:games/forge/src/index.ts 不是登记点,而且不能改。它现在只有一个 export {},注释写得很直白:
// games/forge/src/index.ts
/**
* 关卡入口是**子路径导出**(./k-structure-pattern-path 等,见 package.json exports),
* 由 apps/site/src/registry.ts 逐条动态 import —— 这样每关是一个独立 chunk,
* 外壳的 registry 才不会被 20 关的代码拖进首屏。
*
* 这里刻意不 re-export 任何关卡模块:一句 export * from './levels/...'
* 就会让所有关卡(连同 Phaser 画布场景)进入同一个 chunk,docs/02 §7.1 的
* "外壳不 import Phaser" 与体积门禁都会失守。
*/
export {};
games/forge/src/forge.test.ts 把这条约束钉成了断言(code 不允许 export *、不允许 from './levels/'),顺手还校验了 package.json 的子路径导出与 SHIPPED_LEVEL_IDS 逐条一致。所以第 4 处真正要写的是 package.json,不是 index.ts。
17.2 第 1 处:两套 id 与它们的区别
ids.ts 是整条链路的常量层,刻意不含 zod、不含内容 JSON、不含 JSX,这样外壳和工具都能放心 import 而不会把 zod 拖进首屏。文件里有两套 id:
/** 20 关目标矩阵:内容层唯一事实来源 */
export const LEVEL_IDS = [
'k-structure-pattern-path',
'k-algorithm-train',
// ……共 20 条
] as const;
export type LevelId = (typeof LEVEL_IDS)[number];
/** M0 只发布这一关;M1 扩到 6 关、M2 扩到 20 关 */
export const SHIPPED_LEVEL_IDS = [
'k-structure-pattern-path',
'k-data-tally',
// ……共 20 条
] as const;
export type ShippedLevelId = (typeof SHIPPED_LEVEL_IDS)[number];
新增一关要同时加进两个数组,因为它们的语义不同:
LEVEL_IDS是目标矩阵(5 学段 × 4 主线 = 20),内容 schema 用它做z.enum,所以 id 不在这里,摘要/详情连解析都过不了;SHIPPED_LEVEL_IDS是”本次发布包含哪些关”,星图、注册表、单测、e2e、内容体检全部以它为准;content:check --require-all会拿两者相减,差一关就红。
id 的命名约定是 <band>-<thread>-<slug>,三段的枚举值都在同一个文件里(BANDS、THREADS)。加完之后先跑一次最短的校验:
pnpm typecheck # 常量层加完,下面的 Record 表会立刻开始报缺键
这一步报错是好事:它把所有必须同步登记的表一次性点出来。
17.3 第 2 处:摘要 JSON 与静态摘要表
摘要只服务星图与列表页,字段少、体积小,所以是静态 import(20 份摘要随首屏一起加载,代价可接受)。最小可用的一份摘要长这样(真实文件 packages/content/src/levels/k-data-tally.json):
{
"id": "k-data-tally",
"title": "分类小站",
"emoji": "🔢",
"color": "#2f7d78",
"band": "k",
"thread": "data",
"template": "experiment",
"tools": ["probe"],
"estimatedMinutes": 5,
"thinkingTarget": "把一筐图形数清楚,再用统计条说明最多和最少",
"difficulty": {
"structure": 1,
"representation": 2,
"constraint": 1,
"transfer": 2
},
"unlock": { "after": [] },
"titleEn": "Sorting Station",
"thinkingTargetEn": "Sort a basket of shapes and answer most/least with a tally."
}
摘要的 schema(packages/content/src/schema.ts)是 strict() 的,字段名写错不会被静默剥掉,会直接报错——这是刻意的:thinking_target 这种拼写错误如果被忽略,会变成”字段没生效却校验通过”。几条容易踩的上限也在这里:title ≤ 24 字、thinkingTarget ≤ 40 字、estimatedMinutes 3–15、难度四轴各 1–5。
写完文件,还要在 summaries.ts 里登记两处:顶部的静态 import 和 RAW 表。RAW 的键类型是 ShippedLevelId,这就是”少登记一关 typecheck 就报”的机制:
const RAW: Record<ShippedLevelId, unknown> = {
'k-structure-pattern-path': kPatternPath,
'k-data-tally': kDataTally,
// ……一关一行
};
export const LEVEL_SUMMARIES: readonly LevelSummary[] = SHIPPED_LEVEL_IDS.map((id) => {
const summary = RAW[id] as LevelSummary | undefined;
if (!summary) throw new Error('缺少关卡摘要:' + id);
if (summary.id !== id) throw new Error('关卡摘要 id 不一致:' + id);
return summary;
});
注意这段注释,它说明了为什么运行时不跑 zod:
运行时不跑 zod(构建期由 content:check 校验),否则 zod 会进首屏。
也就是说,schema 的防线在构建期,运行时只保留 id 一致性断言和一句能看懂的报错。作者写内容时不能指望”运行时兜底”,content:check 才是那道门。
17.4 第 3 处:详情 JSON 与作者检查表的 12 行
详情是重头:details/<id>.level.json 一个文件承载了关卡的全部可玩内容,通过 loadLevel() 动态 import 成独立 chunk,因此它的体积直接进”单关 chunk”预算。
// packages/content/src/loadLevel.ts(节选)
const LOADERS: Record<ShippedLevelId, () => Promise<unknown>> = {
'k-structure-pattern-path': () => import('./details/k-structure-pattern-path.level.json'),
'k-data-tally': () => import('./details/k-data-tally.level.json'),
// ……一关一行
};
详情文件的顶层键(以 k-data-tally.level.json 为例,顺序与 schema 一致):
id / title / band / thread / template / thinkingTarget
intro / prompt / done / retry
tools[] / requiredToolArtifacts[]
levelScript
evidencePlan[] / solutions[] / negativeSamples[] / variants[] / feedback{}
badges[]
manualAudit{}
titleEn / thinkingTargetEn / introEn / promptEn / doneEn / retryEn
docs/03 §1 给了”每关必须填满”的 12 行作者模板,它就是这张键表的自然语言版本:
关卡 id:
学段 / 主线 / 模板:
一句话认知目标(thinking target):
情境(scenario):
工具与产物(tools / requiredToolArtifacts):
硬约束与多解空间(constraints / solutions):
可观察后果(what the player sees when it breaks):
失败类与反馈(failure classes / feedback):
证据检查点(evidencePlan):
变体(variants,至少 2,轴不能是数字大小):
徽章映射(badges):
验收 fixtures(至少 2 解 + 3 负例):
| 作者检查表 | 落到详情 JSON 的哪里 | 硬下限 |
|---|---|---|
| 关卡 id | id |
必须在 LEVEL_IDS 里,且与文件名一致 |
| 学段 / 主线 / 模板 | band / thread / template |
与摘要、manifest 三方一致 |
| 认知目标 | thinkingTarget |
≤ 40 字,星图卡片直接用它 |
| 情境 | intro / prompt |
各 ≤ 120 字;宿主会渲染成情境条 |
| 工具与产物 | tools[].requiredArtifacts / requiredToolArtifacts |
每个必用产物都要有工具声明产出 |
| 约束与多解 | levelScript + solutions[] |
≥ 2 解,且逐条通过 verifier |
| 可观察后果 | feedback[].messageKey / hintKey |
可达失败类必须都有反馈 |
| 失败类与反馈 | feedback{} + negativeSamples[] |
≥ 3 条负例,失败类名要对得上 |
| 证据检查点 | evidencePlan[] |
≥ 2 阶段,id 唯一,require 非空 |
| 变体 | variants[] |
≥ 2 个,axis 取自 VARIANT_AXES |
| 徽章映射 | badges[] |
取自 BADGE_IDS |
| 验收 fixtures | solutions[] / negativeSamples[] / variants[] |
见上三行 |
三个片段值得单独看,它们决定了 author 写的东西能不能被机器复核:
"evidencePlan": [
{ "id": "sample", "phase": "observe", "require": "probe/sample >= 1",
"descriptionKey": "要把整筐都采完,再下结论。" }
]
require 是”动作计数 + 比较”的字符串,不是自由文本;它只用于提示,不参与判定(判定在模板 verifier 里)。变体是一份 patch,而不是另一份完整脚本:
{
"id": "v-fruit",
"axis": "representation",
"patch": {
"categories": [
{ "id": "circle", "label": "苹果", "token": "🍎", "count": 6 }
]
}
}
patch 会被模板的 applyVariant 合并到 levelScript 上;content:check 只验证”patch 可解析”,不验证”变体有意思”——后者是人工复核的事,所以每份详情都必须带 manualAudit:
"manualAudit": {
"reviewed": true,
"reviewer": "exp-builder · M1",
"reviewedAt": "2026-11-01",
"notesKey": "四类 20 个图形、采集批次 5;统计条与结论的失败类已逐条走查。"
}
reviewedAt 不是 ISO 日期、reviewed 不是 true,content:check 与 pack:check 都会红。这一条是 A5/A10 人工项的唯一机器痕迹:脚本没法判断”这关是不是一道选择题”,只能要求作者签字。
最后别忘了 full.ts——它是 Node CLI 与单测的入口,把 LEVEL_DETAILS 全量静态拼起来(只给脚本用,绝不进首屏):
export const LEVEL_DETAILS: Record<ShippedLevelId, LevelDetail> = {
'k-structure-pattern-path': kPatternDetail as LevelDetail,
'k-data-tally': kDataTallyDetail as LevelDetail,
// ……一关一行
};
17.5 第 4、5 处:关卡模块、子路径导出与外壳注册表
关卡模块是全仓最薄的文件。真实的无画布关卡 k-data-tally 只有 15 行:
// games/forge/src/levels/k-data-tally.ts
import type { LevelManifest } from '@mmf/game-kit';
import { defineExperimentLevel } from '@mmf/templates/experiment';
/** 无画布实验关:情境在工具面板里,designSize 为 0×0,宿主隐藏"运行"。 */
const manifest: LevelManifest = {
id: 'k-data-tally',
band: 'k',
thread: 'data',
template: 'experiment',
designSize: { width: 0, height: 0 },
estimatedMinutes: 5,
hasCanvas: false,
};
export default defineExperimentLevel(manifest);
有画布的关卡多一行设计尺寸常量,其余同构:
// games/forge/src/levels/k-structure-pattern-path.ts
import { DESIGN } from '@mmf/templates/layout';
const manifest: LevelManifest = {
id: 'k-structure-pattern-path',
band: 'k',
thread: 'structure',
template: 'pattern',
designSize: DESIGN,
estimatedMinutes: 5,
hasCanvas: true,
};
export default definePatternLevel(manifest);
manifest 里有一半字段与内容重复(band/thread/template/estimatedMinutes),这不是冗余,而是故意留的漂移探测器:forge.test.ts 会逐个比对 manifest 与摘要,任何一处对不上就红。hasCanvas 也有断言——只有 pattern / rule 允许为 true,因为其余模板的正文由 Vue 面板承担。
然后补 games/forge/package.json 的一行:
"exports": {
".": "./src/index.ts",
"./k-data-tally": "./src/levels/k-data-tally.ts"
}
再补外壳注册表 apps/site/src/registry.ts 的一行:
export const registry: Record<ShippedLevelId, LevelLoader> = {
'k-data-tally': () => import('@mmf/game-forge/k-data-tally'),
};
这两处加起来才构成一条完整的动态 import 链:registry[id]() → 子路径导出 → levels/<id>.ts → 模板工厂。少任何一半,都是一个 “Failed to resolve import” 或者运行时 undefined。 而类型系统只能保证 registry 的键齐(因为它是 Record<ShippedLevelId, …>),不能保证 package.json 里有对应子路径——所以 forge.test.ts 才要读 package.json 逐条比对。
17.6 第 6 处:测试与内容门禁
内容类的东西不会自动”被覆盖”,得知道哪一层在守什么:
| 门禁 | 对新关卡做什么 | 是否自动 |
|---|---|---|
pnpm typecheck |
summaries / loadLevel / full / registry 四张 Record 表缺键即报 |
自动 |
pnpm content:check |
摘要/详情 schema、标题一致、英文字段零中文、2 解/3 负例/2 变体、反馈覆盖、禁词、unlock 引用 |
自动 |
pnpm audit:levels |
A1 无工具提交被拒、A1b 空产物被结构反馈拒绝、A3 两解产物不同、A4 负例失败类、A6 变体、A8 autoplay 走到 passed |
自动 |
pnpm test |
content.test.ts 逐关比对摘要/详情;forge.test.ts 比对 manifest 与子路径导出 |
自动 |
pnpm e2e(m1.spec.ts) |
从 SHIPPED_LEVEL_IDS 枚举,每关两条:autoplay 通关、无工具提交被拒 |
自动 |
pnpm e2e(budget.spec.ts) |
交互次数预算,BUDGETS 是手写列表 |
手动补 |
pnpm e2e(ui-smoke.spec.ts) |
工具面板”关键动作后可见区必须变化”,CASES 也是手写 |
手动补 |
前五行是”不补就红”,后两行是”不补不会红,但会静默失去保护”。budget.spec.ts 的注释写明了它存在的理由:
/**
* 交互次数预算(docs/12 §4 / §7-3)。
* 目的:把"每关最少几次点击"钉成门禁,防止以后又长回仪式步骤或死按钮。
*/
也就是说,一个新关卡如果没进 BUDGETS,将来有人给它加三个仪式按钮也不会被发现。作者提交前应当问自己一句:这关的”最少几次点击”是多少,写进 BUDGETS 了吗?
17.7 最小可跑骨架与校验顺序
下面是一个”能过门禁”的最小骨架,假设新增 id 为 g46-structure-mirror(示例用,仓库里没有这一关)。真正的细节按模板不同而不同,但文件的形状是固定的。
1)packages/content/src/ids.ts 两个数组各加一行:
export const LEVEL_IDS = [
// ……
'g46-structure-mirror',
] as const;
export const SHIPPED_LEVEL_IDS = [
// ……
'g46-structure-mirror',
] as const;
2)packages/content/src/levels/g46-structure-mirror.json:
{
"id": "g46-structure-mirror",
"title": "镜像补墙",
"emoji": "🪞",
"color": "#7a5c3e",
"band": "g46",
"thread": "structure",
"template": "pattern",
"tools": ["pattern", "construct"],
"estimatedMinutes": 6,
"thinkingTarget": "从半面墙推出镜像规则,再把缺的格子补成对称",
"difficulty": { "structure": 2, "representation": 3, "constraint": 2, "transfer": 3 },
"unlock": { "after": [] },
"titleEn": "Mirror Wall",
"thinkingTargetEn": "Infer a mirror rule from half a wall and complete the missing cells."
}
3)summaries.ts 加 import 与 RAW 一行;4)details/g46-structure-mirror.level.json 按 17.4 的键表写满;5)loadLevel.ts 的 LOADERS 与 full.ts 的 LEVEL_DETAILS 各加一行;6)games/forge/src/levels/g46-structure-mirror.ts:
import type { LevelManifest } from '@mmf/game-kit';
import { definePatternLevel } from '@mmf/templates/pattern';
import { DESIGN } from '@mmf/templates/layout';
const manifest: LevelManifest = {
id: 'g46-structure-mirror',
band: 'g46',
thread: 'structure',
template: 'pattern',
designSize: DESIGN,
estimatedMinutes: 6,
hasCanvas: true,
};
export default definePatternLevel(manifest);
7)games/forge/package.json 加子路径导出;8)apps/site/src/registry.ts 加一行。然后按下面的顺序验证——每一步盯的是不同的东西:
# 1. 类型与登记表:四张 Record 表是否齐
pnpm typecheck
# 2. 内容:schema、双语、2 解 / 3 负例 / 2 变体、反馈覆盖、禁词
pnpm content:check
# 3. 判定与可达性:A1/A1b/A3/A4/A6/A8(含 autoplay 真的走到 passed)
pnpm audit:levels
# 4. 单测:摘要/详情/ manifest / 子路径导出 的漂移检查
pnpm test
# 5. 产物:新关必须切出两个 chunk;顺带量体积
pnpm build && pnpm size
ls apps/site/dist/assets | grep g46-structure-mirror
# 6. 真实浏览器:autoplay + 无工具被拒(自动覆盖),预算与冒烟(手写列表)
pnpm e2e
第 5 步的 ls 是一个很实用的自查:vite.config.ts 的 chunkFileNames 会把关卡模块命名为 assets/level-<id>-[hash].js、详情命名为 assets/content-<id>-[hash].js。如果产物里找不到这两个名字,说明动态 import 没接上(导出或注册表漏了),而不是”构建有问题”。
17.8 常见错法
| 症状 | 根因 | 正确做法 |
|---|---|---|
typecheck 报 Property 'xxx' is missing in type |
只加了 LEVEL_IDS,没加 SHIPPED_LEVEL_IDS,或反之 |
两个数组同一次提交里一起加;让编译器逐一指出剩下四张表 |
content:check 报”目标矩阵缺 1 关” |
SHIPPED_LEVEL_IDS 里没有它,而 CI 跑的是 --require-all |
确认两套 id 都有;本地也用 pnpm content:check --require-all 复跑一次 |
content:check 报”摘要/详情标题不一致” |
两份 JSON 的 title / titleEn 手抖写岔 |
从摘要复制标题过去,别重打 |
| 关卡能进,但画面空白 / 控制台报 resolve 失败 | 只改了 registry.ts,忘了 package.json 子路径导出(或反之) |
两处成对加;用 forge.test.ts 与第 5 步的 ls 双重确认 |
| 首屏体积突然变差 | 在 games/forge/src/index.ts 里写了 export * from './levels/...' |
保持 index.ts 为 export {};每关走子路径导出,保证独立 chunk |
模板里出现 if (detail.id === 'xxx') |
想给单关加特殊逻辑,于是把内容耦合进模板 | 模板只依赖 levelScript;差异写进脚本或加可选 config 字段,否则下一个内容作者复制不了、audit:levels 也覆盖不到 |
| 12 行作者检查表填不满 | 负例只写 2 条、变体只写 1 个、evidencePlan 只写 1 阶段、manualAudit 没签 |
schema 的硬下限就是 2 解 / 3 负例 / 2 变体 / 2 阶段;少一条content:check 直接红,没有”以后补” |
| 英文界面漏中文 | 六个 *En 字段只写了一半,或复制中文时带上了全角标点 |
content:check 会扫 CJK 与 ,。;:!?()【】;写英文时不要中文标点 |
有一条经验值得单独写出来:先写详情、再写摘要、最后写模块。详情是最重的,它逼你把失败类、多解、变体想清楚;摘要和 manifest 都是它的投影,最后照着填,反而不会漂移。反过来(先做模块再补内容)看着快,实际上会因为 designSize / hasCanvas / estimatedMinutes 的反复而重写三遍。
小结
新增一关要动九个文件、六个登记点,链路的每一段都有机器守着:
ids.ts的两套数组决定”这关存不存在”与”这次发不发”;- 摘要是静态 import 的首屏数据,详情的动态 import 决定单关 chunk;
summaries.ts/loadLevel.ts/full.ts/registry.ts四张Record<ShippedLevelId, …>表让”少登记”在typecheck阶段就报,而不是等到线上 404;games/forge/src/index.ts必须保持空出口,package.json的子路径导出才是真正的入口;content:check管内容合规,audit:levels管判定不可绕过,e2e/m1管可完成,BUDGETS与ui-smoke需要作者手动补;- 模板只吃
levelScript,任何”单关特殊逻辑”都是在给自己和下一个作者挖坑。
下一章把这里提到的门禁逐个打开:369 条单测、90 条 e2e、内容体检与反刷题审计各自守什么,以及 pnpm verify 为什么把 size 放在最后串行跑。