第06章 - 内容数据模型与典故JSON
6.1 为什么内容必须与代码分离
CHAGS 的产品承诺里有一条:合集要能长到 30–100 个典故,且新增典故 = 加一个包 + 一份 JSON。要做到这一点,典故的一切(故事、寓意、复习题、关卡参数)都必须以数据形式存在,代码只负责解释数据。
packages/content 就是这个数据层,它由四部分组成:
packages/content/src/
├─ schema.ts # AllusionSchema(zod):一切内容的形状与约束
├─ allusions/*.json # 十二份典故内容(中文为基准语言)
├─ allusions/index.ts # 构建期 zod 解析 + 查询函数(getAllusion / stageScript ...)
├─ i18n/en/*.json # 英文翻译补丁(结构镜像,只写需要翻译的字段)
├─ i18n/index.ts # patchFor(locale, id)
├─ locale.ts # LOCALES / DEFAULT_LOCALE / speechLang
└─ localize.ts # mergeLocalized / validatePatch
关键理念:校验在构建期,而不是运行时。allusions/index.ts 在被 import 的瞬间就用 zod 解析全部十二份 JSON,内容里任何一个字段写错,开发服务器会立刻报错,而不是等孩子点开才发现。
6.2 AllusionSchema 逐字段
export const AllusionSchema = z
.object({
id: z.string().regex(/^[a-z0-9-]+$/, 'id 只能是小写字母、数字与连字符'),
/** 信史 / 神话 / 传说:典故馆要分开呈现(神话落在时间轴最左端) */
kind: z.enum(['history', 'myth', 'legend']).default('history'),
title: z.string().min(1),
pinyin: z.string().min(1),
dynasty: z.string().min(1),
era: z.string().min(1),
/** 用于历史长卷排序,公元前为负数 */
yearApprox: z.number().int(),
figures: z.array(z.string().min(1)).min(1),
source: SourceSchema,
/** 一句话寓意,孩子能听懂 */
moral: z.string().min(1).max(90),
keywords: z.array(z.string().min(1)).min(2).max(6),
ageRange: z.tuple([z.number().int(), z.number().int()]),
story: z.array(StoryBeatSchema).min(2).max(6),
keyMoment: KeyMomentSchema,
misconceptions: z.array(z.string().min(1)).min(1),
quiz: z.array(QuizQuestionSchema).min(2),
game: z.object({
/** 主玩法模板,用于缩略图与筛选 */
template: z.string().min(1),
/** 实际参与编排的各段脚本,按顺序执行 */
stages: z.array(GameStageSchema).min(1).max(3),
}),
})
// ... 五条 refine 规则见 6.3
每个字段的用途与约束:
| 字段 | 约束 | 用途 |
|---|---|---|
id |
小写字母 / 数字 / 连字符 | 三处对账的关键字:内容、注册表、游戏包 |
kind |
history / myth / legend,默认 history |
典故馆徽章;神话的 yearApprox 为负数,排在最前 |
title / pinyin / figures |
非空 | 大厅卡片、典故卡 |
dynasty / era |
非空 | 卡片上的朝代信息 |
yearApprox |
整数,公元前为负 | 排序;将来历史长卷的横轴 |
source |
{ book, chapter?, quote } |
典故卡的出处与原文引用 |
moral |
≤ 90 字 | 一句话寓意,孩子能听懂 |
keywords |
2–6 个 | 卡片标签、复习关联 |
ageRange |
二元组 | 大厅展示 |
story |
2–6 幕 | 分幕绘本的正文与旁白 |
keyMoment |
见 6.5 | 教学核心的数据锚点 |
misconceptions |
≥ 1 条 | 常见误解(用于生成干扰项、复习材料) |
quiz |
≥ 2 题,含 ≥ 1 道 transfer | 复习测验 |
game.template |
主模板 id | 缩略图与筛选(如 grow、substitution) |
game.stages |
1–3 段,{ template, levelScript } |
实际编排;levelScript 的形状由模板 schema 校验 |
6.3 五条「物理不可绕过」的内容规则
schema 末尾的五条 refine 是整个数据模型里最值得学习的部分 —— 它们把教学与质量要求写成了数据结构不可违反的约束:
.refine((v) => v.quiz.every((q) => q.answer < q.options.length), {
message: 'quiz 的 answer 下标越界',
path: ['quiz'],
})
.refine((v) => v.keyMoment.options.filter((o) => o.isCanonical).length === 1, {
message: 'keyMoment 必须恰好有一个 isCanonical 选项',
path: ['keyMoment'],
})
.refine((v) => v.story.some((b) => b.id === v.keyMoment.beatId), {
message: 'keyMoment.beatId 必须存在于 story 中',
path: ['keyMoment', 'beatId'],
})
.refine((v) => new Set(v.story.map((b) => b.id)).size === v.story.length, {
message: 'story 的 beat id 不能重复',
path: ['story'],
})
.refine((v) => v.quiz.some((q) => q.kind === 'transfer'), {
message: '至少需要一道 transfer(迁移)题,否则无法验证“真的记住了”',
path: ['quiz'],
});
逐条对应一个真实风险:
- 选项下标越界 → 答题时点「第二项」却指向不存在的选项;
- 标准答案必须唯一 → 关键抉择题恰好一个
isCanonical: true,多了少了都说明内容设计没想清楚; - 关键抉择必须长在故事上 →
keyMoment.beatId必须命中某一幕,避免内容重组后悬挂; - 幕 id 不能重复 → 事件里的
beat:enter:<id>依赖唯一性; - 必须有一道迁移题 → 记忆题(
recall)只能证明「记得」,迁移题(transfer)才验证「理解」。两者在 schema 里是显式区分的:
export const QuizQuestionSchema = z.object({
id: z.string().min(1),
kind: z.enum(['recall', 'transfer']).default('recall'),
question: z.string().min(1),
options: z.array(z.string().min(1)).min(2).max(4),
answer: z.number().int().min(0),
explanation: z.string().min(1),
});
6.4 故事:每一幕都是「一屏一句话」
export const StoryBeatSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
/** 屏幕上的一句话。中日文卡得紧,英文天然更长,所以上限按英文给 */
text: z.string().max(90),
narration: z.string().min(1), // 旁白可以更长,TTS 会完整读出来
illustration: z.string().min(1).default('none'),
});
text 的 90 字符上限是为英文留的:中文一句话十几个字,英文翻译可能三十个字母起步。这条限制直接塑造了叙事的写法 —— 每屏只说一件事,剩下的放进 narration 由语音讲。
illustration: 'none' 是阶段 0 的占位:美术全部由游戏包用几何图形绘制(drawBeat 回调)。将来换成真图片时,把文件放进 games/<id>/public 并改这个字段即可,content:check 会提示确认资源就位。
6.5 keyMoment:数据保留、渲染放弃
export const KeyMomentSchema = z.object({
beatId: z.string().min(1),
question: z.string().min(1),
options: z.array(KeyMomentOptionSchema).min(2).max(4),
reveal: z.string().min(1),
});
这里有一个重要的产品历史,写进教程值得单独说明:第一版曹冲称象把关键抉择渲染成了「三选一的文字选择题」,被判定「太死板、文字太多、说教」。重做后,关键抉择改由交互本身承载(玩家自己撞见秤被压垮、自己做代换),keyMoment 数据则转为提供两样东西:
reveal—— 完成关键动作时屏幕上那句揭晓语(例如盘古的「盘古倒下了,可他的眼睛成了日月,血液成了江河 —— 世界是他留下的。」);options与misconceptions—— 复习测验与干扰项的素材。
也就是说,这个字段仍然在「教学核心」的位置上,只是不再等于一张选择题卡片。数据模型服务于教学,而不是反过来。
6.6 game.stages:一个典故可以由多段玩法拼成
export const GameStageSchema = z.object({
template: z.string().min(1),
levelScript: z.unknown(),
});
levelScript 在内容 schema 里是 unknown,它的真实形状由模板层的 schema 校验(packages/templates/src/schemas.ts 的 templateSchemas 注册表,第 7 章)。这样做的好处是内容 schema 不需要知道所有模板;代价是要求 content:check 做两遍校验 —— 第一遍对着注册表校验每段脚本,第二遍在合并译文后再校验一次(第 11 章)。
当前十二个典故的编排分布:
caochong-elephant substitution
pangu tap → place
houyi aim
nuwa place
jingwei tap
kuafu tap(hold 模式)
yugong tap(带三条里程碑)
shennong sort(带 probe 先尝后分)
dayu tap(堵)→ trace(疏)
taigong wait
fenghuo beacon(专属场景,数据登记在模板层)
qiren place
6.7 完整示例:盘古开天辟地的内容 JSON
以 packages/content/src/allusions/pangu.json 为例,看数据与玩法如何对应(此处为节选,斜体为注释):
{
"id": "pangu",
"title": "盘古开天辟地",
"pinyin": "pán gǔ kāi tiān pì dì",
"kind": "myth",
"dynasty": "上古",
"era": "上古·神话",
"yearApprox": -9999, // 历史长卷的最左端
"figures": ["盘古"],
"source": {
"book": "《三五历纪》",
"chapter": "三国·吴 徐整 整理",
"quote": "天地混沌如鸡子,盘古生其中。万八千岁,天地开辟,阳清为天,阴浊为地。"
},
"moral": "他把自己,变成了整个世界。",
"keywords": ["混沌", "分开", "化身"],
"ageRange": [7, 10],
"story": [ // 两幕,每屏一句话
{ "id": "s1", "title": "还没有天地",
"text": "很久很久以前,天和地还没有分开。",
"narration": "很久很久以前,天和地还没有分开。",
"illustration": "none" },
{ "id": "s2", "title": "一个大鸡蛋",
"text": "宇宙像一个鸡蛋,盘古睡在里面。",
"narration": "宇宙像一个鸡蛋,盘古就睡在里面。",
"illustration": "none" }
],
"keyMoment": { // 关键抉择(数据层)
"beatId": "s2",
"question": "盘古倒下了,可这个世界是从哪里来的?",
"options": [
{ "id": "nothing", "text": "什么都没有了", "isCanonical": false, "hint": "" },
{ "id": "body", "text": "他的身体变成了天地万物", "isCanonical": true, "hint": "" },
{ "id": "wake", "text": "他又醒过来了", "isCanonical": false, "hint": "" }
],
"reveal": "盘古倒下了,可他的眼睛成了日月,血液成了江河 —— 世界是他留下的。"
},
"misconceptions": ["盘古倒下就什么都没有了", "天地本来就在那里"],
"quiz": [
{ "id": "q1", "kind": "recall", /* 左眼右眼变成什么 */ },
{ "id": "q2", "kind": "recall", /* 撑了多久 */ },
{ "id": "q3", "kind": "transfer", /* 哪个也是「变成了别的东西」:毛毛虫变蝴蝶 */ }
],
"game": {
"template": "tap", // 主模板(展示用)
"stages": [
{ "template": "tap", "levelScript": {
"mode": "tap",
"targetValue": 18000, // 撑天目标:一万八千年
"stepValue": 600, // 每次点击 +600 → 30 次点满
"unit": "年",
"resistance": 0.35, // 后半程视觉上更吃力
"hint": "点一点,把天撑高",
"milestones": []
} },
{ "template": "place", "levelScript": {
"slots": [ // 世界上的六个空槽
{ "id": "sun", "x": 170, "y": 300, "kind": "sun", "radius": 62 },
{ "id": "moon", "x": 580, "y": 300, "kind": "moon", "radius": 62 },
/* stars / wind / river / grass ... */
],
"orbs": [ // 底部托盘里的六团光
{ "id": "leftEye", "slotId": "sun", "x": 105, "y": 1226, "kind": "eye",
"speak": "左眼变成了太阳" },
{ "id": "rightEye", "slotId": "moon", "x": 215, "y": 1226, "kind": "eye",
"speak": "右眼变成了月亮" }
/* hair / breath / blood / skin ... */
]
} }
]
}
}
几个值得注意的数据细节:
targetValue / stepValue = 18000 / 600 = 30次点满。tap 模板的 schema 强制整除(「否则永远点不满」),单测里还有一条断言直接验证「正好 30 次」;slots与orbs通过slotId建立映射,校验要求数量一致、槽位 id 不重复、一个槽位只能有一个光球;- 每个 orb 带一句
speak(「左眼变成了太阳」)—— 归位时用语音说出原文,用听不用看; - 方位有语义:日月星辰的
y < 640(上半屏),江河草木在下方。单测里直接断言了这个分区。
6.8 构建期解析与查询函数
allusions/index.ts 在模块加载时就完成解析:
export const allusions: AllusionContent[] = [
AllusionSchema.parse(raw), // 曹冲称象
AllusionSchema.parse(rawPangu),
AllusionSchema.parse(rawHouyi),
/* ... 共 12 个 */
];
导出的查询函数是外壳与游戏包共用的数据入口:
| 函数 | 用途 |
|---|---|
stageScript(content, template) |
取某典故某个模板的阶段脚本;缺失时早失败(「别等玩到一半才发现」) |
localizedAllusion(content, locale) |
应用翻译补丁(中文基准直接返回) |
getAllusion(id, locale) |
按 id 取某个典故(游戏宿主用) |
allusionsFor(locale) |
取全部典故(大厅/图鉴用) |
allusionsByYear(locale) |
按年代排序(大厅当前用 yearApprox 排序) |
/** 取某个典故里指定模板的阶段脚本;缺了就早失败,别等玩到一半才发现 */
export function stageScript(content: AllusionContent, template: string): unknown {
const stage = content.game.stages.find((s) => s.template === template);
if (!stage) {
throw new Error('典故 ' + content.id + ' 缺少 ' + template + ' 阶段脚本');
}
return stage.levelScript;
}
游戏包里的 logic.ts 就是「取脚本 + zod 解析」两行:
// games/pangu/src/logic.ts —— 纯逻辑:不碰 Phaser,可以直接单测
export function tapLevel(content: AllusionContent): TapLevel {
return TapLevelSchema.parse(stageScript(content, 'tap'));
}
export function placeLevel(content: AllusionContent): PlaceLevel {
return PlaceLevelSchema.parse(stageScript(content, 'place'));
}
6.9 新增一份内容的步骤(概览)
完整流程在实战章(第 12 章)用一个新典故走一遍,这里先给检查清单:
- 新建
packages/content/src/allusions/<id>.json,id与文件名一致(小写、连字符); - 在
allusions/index.ts加import与一行AllusionSchema.parse(raw); - 新建英文补丁
packages/content/src/i18n/en/<id>.json(结构镜像,只写需要翻译的字段),并在i18n/index.ts的patches.en注册; - 运行
pnpm content:check:校验内容 schema、模板关卡脚本、译文完整性与“漏翻字段”; - 写游戏包、注册注册表(第 12 章详述)。
6.10 内容设计的经验条目
从十二份内容里可以总结出几条可直接复用的经验:
- 故事短:两个幕起步,最多六个;每屏一句话。孩子是来「做」的,不是来读的;
text按英文长度设限:中文看着很短的一句话,翻成英文可能超限;schema 的上限按英文设,逼着内容写得更精简;misconceptions是真实素材:写内容时先想清楚「孩子会怎么想错」,疑难点才立得住;- 迁移题比记忆题值钱:
transfer题问的是「哪个和它是同一类」,答对了才算理解(盘古的迁移题是「毛毛虫变蝴蝶」); - 年代可以夸张但要有据:神话用
yearApprox: -9999落在最左端,是产品决策而非历史考据;但source.quote必须是真引用 —— 儿童教育产品的信任建立在这一条上。
6.11 小结
- 内容是 JSON + zod schema,构建期解析、构建期校验,十二份典故一次全过才算通过;
- 五条 refine 规则把教学要求(唯一标准答案、迁移题、抉择挂在故事上)变成了数据约束;
game.stages支持一个典故由多段模板拼成,脚本形状由模板层校验;- 双语采用「中文基准 + 翻译补丁」结构,避免把 JSON 写成双语大杂烩(细节见第 11 章)。
下一章进入「让第 30 个典故变便宜」的关键设计:玩法模板库。