第11章 - 中英双语实现
11.1 双语的覆盖范围与分层做法
CHAGS 要求界面、典故内容、语音、文档全部支持中英切换。分层做法是明确分工的:
| 层 | 做法 | 为什么这么做 |
|---|---|---|
| 界面文案 | @chags/i18n:一张扁平的 key→文案表,中英各一份 |
通用标签(「开始玩」)不属于任何典故,不该塞进内容 JSON |
| 典故内容 | 中文写在 allusions/*.json(保持可读),译文写在 i18n/en/*.json,结构镜像、只写要翻译的字段 |
避免把 JSON 写成双语大杂烩;一部典故没翻译也能跑(回落中文) |
| 语音 | AudioBus.speak(text, locale),TTS 按 zh-CN / en-US 选嗓音 |
将来上真人配音时,同样按 locale 取不同音频文件 |
| 文档 | README.md / README.en.md,docs/ 与 docs/en/ |
中英各自成篇,不做机翻拼贴 |
11.2 界面文案表:编译期完整
packages/i18n 的核心是一张扁平的对照表:
/** 界面文案表。典故本身的文字在内容 JSON 里,不在这里。 */
export const messages = {
'zh-CN': {
'app.title': '中国历史典故',
'nav.hall': '典故馆',
'nav.album': '我的图鉴',
'nav.switchTo': 'English',
'hall.start': '开始玩',
'scene.story': '看故事',
'scene.reward': '典故卡',
'reward.accept': '收下',
/* ... 全部界面标签 */
},
en: {
'app.title': 'Chinese Tales',
'nav.hall': 'Stories',
'nav.switchTo': '中文',
'hall.start': 'Play',
/* ... */
},
} as const;
两处「让编译器帮忙」的设计:
export type MessageKey = keyof (typeof messages)['zh-CN'];
/** 编译期保证英文表不缺键:缺一个就类型报错 */
const englishCompleteness: Record<MessageKey, string> = messages.en;
void englishCompleteness;
MessageKey由中文表推导 —— 模板里写titleKey: 'scene.hold'时键名写错会直接类型报错;Record<MessageKey, string>的赋值检查保证英文表缺一个键就编译不过。
取词器永不抛错、永不空白:
/** 取词器;缺键时回落到中文,最后回落到键名本身(永不抛错、永不空白) */
export function createTranslator(locale: Locale): Translator {
const table = messages[locale] ?? messages[DEFAULT_LOCALE];
return (key: MessageKey): string => table[key] ?? messages[DEFAULT_LOCALE][key] ?? key;
}
语言解析打配合:
/** 从 URL 的 ?lang= 读语言,用于分享与自动化测试 */
export function localeFromSearch(search: string): Locale | null {
const value = new URLSearchParams(search).get('lang');
if (!value) return null;
const lower = value.toLowerCase();
if (lower === 'en' || lower.startsWith('en-')) return 'en';
if (lower === 'zh' || lower.startsWith('zh-')) return 'zh-CN';
return null;
}
单测把「完整性」从编译期之外的角度再兜一层(messages.test.ts):每种语言全键无空串、中英文案不相同(防复制粘贴漏翻)、取词器回落行为、?lang= 解析(含 en-US → en、zh-TW → zh-CN)。
11.3 内容翻译补丁:结构镜像
英语译文文件(packages/content/src/i18n/en/pangu.json)只写需要翻译的字段:
{
"title": "Pangu Splits Heaven and Earth",
"dynasty": "Primordial",
"era": "Primordial · Myth",
"figures": ["Pangu"],
"source": {
"book": "San Wu Li Ji",
"quote": "Heaven and earth were in chaos like a hen's egg, and Pangu was born inside it. ..."
},
"moral": "He turned himself into the whole world.",
"keywords": ["Chaos", "Split", "Become"],
"story": [
{ "title": "No Sky, No Earth",
"text": "Long, long ago, sky and earth were not yet apart.",
"narration": "Long, long ago, sky and earth had not yet separated." },
{ "title": "A Great Egg",
"text": "The universe was like an egg, and Pangu slept inside.",
"narration": "The universe was like a great egg, and Pangu slept inside it." }
],
"keyMoment": { "question": "...", "options": [ { "text": "..." }, ... ], "reveal": "..." },
"misconceptions": [ "..." ],
"quiz": [ { "question": "...", "options": ["..."], "explanation": "..." } ]
}
注意几件事:
- 没有
id、没有数字、没有坐标 —— 结构性字段不需要翻译; - 数组按下标对齐:译文数组必须与原文等长(
story两幕就写两项);这是validatePatch强制检查的; game.stages的levelScript里也有需要翻译的文案(如unit: "baskets"、hint),同样写进补丁 —— 这一块的校验有个专门的坑,见 11.5。
合并函数在 localize.ts 里,规则简单明确:
/**
* 用翻译补丁覆盖基础内容(基础内容用中文写,翻译单独放,避免把 JSON 写成双语大杂烩)。
* - 对象:递归合并
* - 数组:按下标逐个合并(翻译文件必须与原文等长,由 content:check 保证)
* - 其余:补丁优先
*/
export function mergeLocalized<T>(base: T, patch: Json): T { /* ... */ }
“没有译文就原样返回”是 localizedAllusion 的兜底:
/** 把某个典故翻译成目标语言(没有译文就原样返回) */
export function localizedAllusion(content: AllusionContent, locale: Locale): AllusionContent {
if (locale === DEFAULT_LOCALE) return content;
const patch = patchFor(locale, content.id);
return patch === undefined ? content : mergeLocalized(content, patch);
}
补丁注册表在 i18n/index.ts,一行一个典故:
export const patches: Partial<Record<Locale, Record<string, unknown>>> = {
en: {
'caochong-elephant': enCaochong,
pangu: enPangu,
/* ... 共 12 个 */
},
};
export function patchFor(locale: Locale, id: string): unknown {
return patches[locale]?.[id];
}
11.4 游戏本身不知道语言的存在
这是整套双语方案里最重要的一条:外壳把 getAllusion(id, locale) 的结果(已经翻译好的内容)交给游戏,游戏只读 ctx.content(第 4 章契约)。因此:
- 游戏代码里没有任何
if (locale === 'en'); - 新增一种语言 = 加一份翻译 JSON,游戏一行不改;
ctx.locale只用于一件事:语音的嗓音选择。
语言切换的界面行为由外壳负责:PlayView 的 key 带上 locale(route.gameId + ':' + i18n.locale),切语言即重挂游戏(卸载 Phaser 再挂载),杜绝出现半中半英的场景。
11.5 构建期强制:翻译完整性不许等到用户发现
pnpm content:check 的第二部分是翻译体检,逐条检查:
- 缺译文:
<id> 缺少 en 译文直接 fail; - 结构不一致:
validatePatch递归检查「字段在原文是否存在」「数组长度是否一致」:
export function validatePatch(base: Json, patch: Json, path = ''): PatchIssue[] {
// 数组:长度不一致 → "数组长度不一致:原文 2 项,译文 3 项"
// 对象:原文里没有这个字段 → "原文里没有这个字段"
// ...
}
- 合并后仍须过内容 schema:译文太长(如
moral超过 90 字、story.text超限)同样会被挡住; - 合并后每一段关卡脚本要重新过一遍模板 schema —— 这一条是补上来的:
// 只查内容 schema 是不够的:game.stages[].levelScript 在内容 schema 里是 unknown,
// 英文译文把 unit 写成 "baskets" 就绕过了所有检查,直到玩家打开游戏才报错。
for (const [index, stage] of merged.data.game.stages.entries()) {
const schema = templateSchemas[stage.template];
const result = schema.safeParse(stage.levelScript);
/* 失败即 fail */
}
- CJK 泄漏扫描:合并后的英文内容里 不允许出现中日韩字符 —— 新增字段时最容易漏翻,扫描直接列出可疑路径:
/** 中日韩字符:英文版里出现它,就说明这个字段漏翻了 */
const CJK = /[\u3400-\u9FFF\uF900-\uFAFF]/;
实际运行输出(写作时实测):
校验 12 个典故…
· 曹冲称象 (caochong-elephant)
· 信史 / 2 幕故事 / 3 道复习题 / 1 段玩法:substitution
...
语言 en:
· Cao Chong Weighs the Elephant
· Pangu Splits Heaven and Earth
...
内容校验通过 ✅
11.6 版面教训:任何文字放进固定容器,必须先量后排
双语上线后暴露的第一类问题不是翻译缺失,而是版面:
- 典故卡糊成一团:英文标题冲出卡片、星星压住寓意、「出处」压在原文上 —— 早期卡片用固定纵坐标排版,中文「曹冲称象」五个字,英文 “Cao Chong Weighs the Elephant” 三十个字母,必然重叠。修复:
reward.ts改成先量后排的流式版面 —— 每个文字块按卡片内宽换行、量出高度后依次堆叠,卡片高度由内容决定;仍超出就整体等比缩小,宁可小一点也不重叠。按钮位置固定不变,自动化用例坐标因此不受影响; - 故事页小手压住正文:小手固定在
H-150,英文更长的句子排到第四行时被压住。修复:小手按正文实测高度定位,放不下就挪到右下角;正文起点也从 985 提到 950; - 提示气泡句号掉行:换行宽度
W-260太窄(如杞人忧天的长句),放宽到W-180; - 计数气泡宽度:
unit字段加max(8)约束 —— 英文单位(stones / baskets)更长,宽度自适应。
这些修复的共同结论被写进了项目文档:任何「文字放进固定容器」的地方,都必须先量后排。中英混排会把这类问题一次性照出来。
11.7 双语版面巡检:pnpm audit:i18n
自动断言的边界之外,还有「看起来是否正常」这一类问题。项目为此加了一条巡检流水线:把每部游戏在两种语言下的每个画面截图(写作时共 34 张),落到 .scratch/i18n-audit/(不入库):
pnpm audit:i18n
改文案、加语言、动版面之后跑一次,人眼扫一遍即可 —— 典故卡的版面问题就是它照出来的。这条巡检在 CI 上只在主干与手动触发时跑(约 6 分钟),截图作为 artifact 保留 14 天,供人工过目。
端到端里还有一条专门的双语用例(smoke.spec.ts):
test('中英文切换:界面与典故内容都跟着变,并且记得住选择', async ({ page }) => {
await page.goto('/?lang=en');
await expect(page.getByRole('heading', { name: 'Story Hall' })).toBeVisible();
await expect(page.getByText('Cao Chong Weighs the Elephant')).toBeVisible();
// 用英文内容玩一局,典故卡也应是英文
await page.goto('/?lang=en&autoplay=1#/play/pangu');
/* ... 通关后检查图鉴里的英文卡片、Source 标签 ... */
});
11.8 新增一种语言(以简繁为例)
假设要加 zh-TW,需要动的点:
packages/content/src/locale.ts:LOCALES加'zh-TW',isLocale/speechLang同步(语音标签zh-TW);packages/i18n/src/messages.ts:加繁体表(Record<MessageKey, string>会强制键完整);packages/content/src/i18n/:加zh-TW/目录与 12 份翻译补丁,注册到patches;pnpm content:check:立刻给出全部缺失项清单,按清单补齐;pnpm audit:i18n:跑一遍版面巡检(繁体字形更宽,重点看卡片与按钮);- 界面按钮可以沿用
nav.switchTo的机制(当前是「中文 / English」两态切换,多语言时改为循环切换)。
因为「游戏不知道语言存在」这条设计,游戏包、模板、工具箱全程零改动。
11.9 小结
- 双语三层分工:界面文案表(编译期完整)、内容补丁(构建期强制)、语音(按 locale 选嗓音);
- 稳定性来自两道强制:
Record<MessageKey, string>的编译期检查、content:check的译文体检(缺键、结构、超长、关卡脚本重校验、CJK 泄漏); - 版面必须「先量后排」,这是双语给全仓上的最重要一课;
audit:i18n负责机器断言覆盖不到的部分:人眼过一遍每个画面。
下一章进入实战:从零开发一个全新的典故,把内容、游戏包、注册、测试、验证串成完整流程。