第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 的第二部分是翻译体检,逐条检查:

  1. 缺译文:<id> 缺少 en 译文 直接 fail;
  2. 结构不一致:validatePatch 递归检查「字段在原文是否存在」「数组长度是否一致」:
export function validatePatch(base: Json, patch: Json, path = ''): PatchIssue[] {
  // 数组:长度不一致 → "数组长度不一致:原文 2 项,译文 3 项"
  // 对象:原文里没有这个字段 → "原文里没有这个字段"
  // ...
}
  1. 合并后仍须过内容 schema:译文太长(如 moral 超过 90 字、story.text 超限)同样会被挡住;
  2. 合并后每一段关卡脚本要重新过一遍模板 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 */
}
  1. 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,需要动的点:

  1. packages/content/src/locale.ts:LOCALES 加 'zh-TW',isLocale / speechLang 同步(语音标签 zh-TW);
  2. packages/i18n/src/messages.ts:加繁体表(Record<MessageKey, string> 会强制键完整);
  3. packages/content/src/i18n/:加 zh-TW/ 目录与 12 份翻译补丁,注册到 patches;
  4. pnpm content:check:立刻给出全部缺失项清单,按清单补齐;
  5. pnpm audit:i18n:跑一遍版面巡检(繁体字形更宽,重点看卡片与按钮);
  6. 界面按钮可以沿用 nav.switchTo 的机制(当前是「中文 / English」两态切换,多语言时改为循环切换)。

因为「游戏不知道语言存在」这条设计,游戏包、模板、工具箱全程零改动。

11.9 小结

  • 双语三层分工:界面文案表(编译期完整)、内容补丁(构建期强制)、语音(按 locale 选嗓音);
  • 稳定性来自两道强制:Record<MessageKey, string> 的编译期检查、content:check 的译文体检(缺键、结构、超长、关卡脚本重校验、CJK 泄漏);
  • 版面必须「先量后排」,这是双语给全仓上的最重要一课;
  • audit:i18n 负责机器断言覆盖不到的部分:人眼过一遍每个画面。

下一章进入实战:从零开发一个全新的典故,把内容、游戏包、注册、测试、验证串成完整流程。