第12章 - 中英双语实现
BambooBuddy 的双语不是“加一个翻译文件”那么简单:界面文案、十条任务的内容(标题、习惯、日记、提示)、每个玩法的关卡脚本与语音文案,全都要能切换;而且游戏代码完全不知道语言的存在。这条边界是整章的主线——语言是外壳与内容层的事,Phaser 场景拿到的永远是一份已经翻译好的 TaskDetail。
三层各自的事实来源与加载方式:
| 层 | 中文来源 | 英文来源 | 加载方式 |
|---|---|---|---|
| 界面文案 | packages/i18n/src/messages.ts 的 zhCN |
packages/i18n/src/en.ts 的 enUS |
中文常驻;英文表是独立 chunk,ensureMessages('en') 动态加载 |
| 内容摘要 | packages/content/src/tasks/<id>.json |
packages/content/src/i18n/en/<id>.json 补丁 |
英文补丁汇总成 summaries 一个小 chunk,切语言时下载 |
| 内容详情 | packages/content/src/details/<id>.game.json |
packages/content/src/i18n/en/<id>.game.json 补丁 |
每条英文详情单独懒加载,进入该玩法才下载 |
| 语音 | speechLang('zh-CN') → zh-CN |
speechLang('en') → en-US |
同一个 AudioBus.speak(text, locale) |
12.1 语言清单只有一个事实来源
// packages/content/src/ids.ts
export const LOCALES = ['zh-CN', 'en'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'zh-CN';
game-kit 与 @bamboo/i18n 都从这里再导出 Locale,所以“支持哪几种语言”只写一次。新增一种语言时,Locale 参与的类型会逐处报错:speechLang() 的分支、LOCALE_LABELS 的穷尽记录、文案表与内容缓存的键。内容补丁本身不是按 Locale 索引的,仍需人工新增一套;类型能保证“不会漏掉代码里的分支”,不能保证“有人写翻译”。这比在五个地方各写一份字符串可靠得多。
DEFAULT_LOCALE 是中文而不是英文,因为产品的主场景是中文家庭;英文是完整能力,不是“默认回退”。缺任何英文资源时回落到中文,宁可语言不完整,也不白屏。
语言也不进入存档。SaveData 里没有 locale 字段:语言是“这台设备的显示偏好”,不是孩子的进度,换设备或清档都不该改变它。它存在 localStorage 的 bamboo.locale,和存档分开;清档不会把语言重置成中文,换一台设备也不会把进度带过去。这个边界让“家长清档后孩子还能用英文界面”成为自然结果。
12.2 界面文案:174 个键与编译期兜底
中文表是一个 as const 对象,键就是文案 id:
// packages/i18n/src/messages.ts(节选)
export const zhCN = {
'app.title': '熊猫宝宝的一天',
'app.subtitle': '和竹竹一起过好每一天',
'nav.home': '小屋',
'home.care.pat': '摸摸头',
'tasks.streak': '连续 {n} 天',
// …共 174 个键
} as const;
export type MessageKey = keyof typeof zhCN;
export type MessageParams = Record<string, string | number>;
export type Translator = (key: MessageKey, params?: MessageParams) => string;
174 个键覆盖顶部导航、五维状态、小屋、任务、装扮、日记、家长页、玩法 HUD、完成页、休息提醒、存档警告等。键名写错不是运行时“显示 key”,而是 TypeScript 直接报错,因为所有取词函数都只接受 MessageKey。
带参数的文案统一用 {n}、{s}、{f} 这样的占位符,而不是在代码里拼字符串。例如“连续 {n} 天”“今天得到竹笋 {s} 根、装扮碎片 {f} 枚”“长大到第 {n} 阶段解锁”。拼字符串在中文里也许能凑合,换到英文就会遇到语序、单复数与量词问题;占位符让翻译者能重排整句。
英文表用 Record<MessageKey, string> 约束:
// packages/i18n/src/en.ts
import type { MessageKey } from './messages';
export const enUS: Record<MessageKey, string> = {
'app.title': 'Bamboo Buddy',
'app.subtitle': 'A Panda’s Day',
'nav.home': 'Home',
'home.care.pat': 'Pat head',
'tasks.streak': '{n} days in a row',
// …
};
少一个键,enUS 就不满足 Record<MessageKey, string>,typecheck 当场失败;多一个不存在的键也一样。这是第一道漏翻防线,成本几乎为零。
取词器有三层回落,永远不抛错、不空白:
export function createTranslator(locale: Locale = DEFAULT_LOCALE): Translator {
const table = locale === 'en' ? (enTable ?? zhCN) : zhCN;
return (key: MessageKey, params?: MessageParams): string => {
const text: string = table[key] ?? zhCN[key] ?? key;
return params === undefined ? text : fillTemplate(text, params);
};
}
如果英文表还没加载完就取词,会先用中文;键不存在则回落到键名(方便开发时一眼看出漏了哪条)。三层回落保证 createTranslator() 在任何时候都能返回字符串,组件模板里不需要写 ?? '…' 之类的兜底。
fillTemplate() 只替换 {参数},缺参数时保留原文而不是显示 undefined:
fillTemplate('竹笋 +{n}', { n: 3 }); // '竹笋 +3'
fillTemplate('竹笋 +{n}', {}); // '竹笋 +{n}'
英文表不是静态主包的一部分。ensureMessages('en') 用动态 import('./en') 加载独立 chunk,失败时保留中文并把加载 Promise 清空,允许下次重试:
export function ensureMessages(locale: Locale): Promise<void> {
if (locale !== 'en' || enTable !== null) return Promise.resolve();
enLoading ??= import('./en')
.then((module) => { enTable = module.enUS; tables.en = enTable; })
.catch(() => { enLoading = null; });
return enLoading;
}
构建产物里对应的文件是 en-Dw0jTPoR.js,6,260 B。中文用户永远不会下载它。
12.3 内容双语:补丁而不是第二份完整内容
任务内容分成“摘要”和“详情”两半(第 6 章)。英文也按同样两半组织,但形式是补丁:只翻人读的字段,结构字段不重复维护。
摘要补丁只允许四个字段:
// packages/content/src/localize.ts
export type SummaryPatch = Partial<Pick<TaskSummary, 'title' | 'habit' | 'diary' | 'tip'>>;
export function localizeSummary(summary: TaskSummary, patch: SummaryPatch | undefined): TaskSummary {
if (patch === undefined) return summary;
return {
...summary,
title: patch.title ?? summary.title,
habit: patch.habit ?? summary.habit,
diary: patch.diary ?? summary.diary,
tip: patch.tip ?? summary.tip,
};
}
id、emoji、timeSlots、template、stats、reward、unlockLevel 这些结构字段不翻译,也不该在两份文件里各写一遍——否则改一个奖励值要改两处,迟早不一致。补丁模式把“翻译”限制在文案字段,结构仍然只有一份事实来源;英文文件的 review 也就只需要看“这句话翻得对不对”,不用核对数值。
英文摘要补丁集中在 packages/content/src/i18n/en/summaries.ts:
import bath from './bath.json';
import brush from './brush.json';
// …十条
export const EN_SUMMARY_PATCHES: Record<TaskId, SummaryPatch> = {
brush, wash, feed, dress, bath, sleep, tidy, sport, emotion, learn,
};
ensureSummaries('en') 动态 import 这个小 chunk(产物 summaries-D2eEEpw6.js,1,590 B),把十条补丁合并进缓存,并重建 Map<TaskId, TaskSummary>。之后 taskSummaries('en') 与 getTaskSummary(id, 'en') 返回的就是英文标题、习惯、日记与提示。
packages/content/src/localize.test.ts 有 5 个用例,覆盖的是合并结果而不是单个字段:中英各 10 条且 id / emoji / 模板 / 收益 / 时段一致;英文标题与文案零中文;英文关卡脚本里的可读文案确实翻了(睡觉含 Pull curtain、情绪含 How does Bamboo feel now、学习含 Which one is red);taskSummaries('en') 与 getTaskSummary('brush', 'en') 返回 Brush Teeth;loadTask() 按语言返回不同的标题与关卡脚本。
空补丁会让英文界面静默显示中文。这种问题最终由下面的零中文校验兜住:只要提供了 i18n/en/<id>.json,实际上就至少要有一个翻译字段,否则合并后的英文内容仍含中文,content:check 会拒绝。十条英文摘要补丁当前都是完整的四个字段。hasSummaryPatch() 是源码里为这种判断准备的小工具,当前还没有调用点,属于预留能力。
单条英文摘要的样子:
{
"title": "Brush Teeth",
"habit": "Brush your teeth morning and night",
"diary": "I helped Bamboo brush his teeth — they are shiny!",
"tip": "Brush the top, the bottom and the inside too."
}
详情补丁多两个能力:语音文案(intro / done / retry)与整份英文关卡脚本(levelScript)。mergeTaskDetail() 逐字段合并,英文缺哪个字段就回落中文;content:check 会在构建期要求英文补齐,但运行期仍然保留回落,避免一个漏字段把整局游戏弄崩。
loadTask() 是外壳拿到完整内容的唯一入口:
export async function loadTask(id: TaskId, locale: Locale = DEFAULT_LOCALE): Promise<TaskDetail | null> {
const summary = getTaskSummary(id, locale);
if (!summary) return null;
const extra = await ZH_LOADERS[id]();
if (locale !== 'en') return mergeTaskDetail(summary, extra, undefined);
try {
const patch = await EN_LOADERS[id]();
return mergeTaskDetail(summary, extra, patch);
} catch {
return mergeTaskDetail(summary, extra, undefined);
}
}
每条英文详情是独立 chunk,实测 236–704 B:sport.game 236 B、sleep.game 369 B、dress.game 395 B、brush.game 478 B、feed.game 512 B、emotion.game 543 B、bath.game 562 B、learn.game 619 B、tidy.game 704 B。只有孩子真的进入那个玩法,才下载那一关的英文脚本。
注意英文详情里也包含 levelScript。刷牙这一关的中英文脚本结构完全一致(六个 germ 点、rubs: 2、finish: rinse 5),差别只在可能出现的文字字段;但项目仍然为每条任务单独维护英文脚本,因为关卡里的步骤名、食物名、情绪名最终都会上屏或进语音。把 levelScript 也当作文案的一部分,是“零漏翻”检查能成立的前提。
12.4 语言状态:?lang= > 上次选择 > 中文
apps/site/src/logic/locale.ts 定义优先级与副作用:
export function initialLocale(): { locale: Locale; fromUrl: boolean } {
const fromUrl = localeFromLocation();
if (fromUrl !== null) return { locale: fromUrl, fromUrl: true };
return { locale: savedLocale() ?? 'zh-CN', fromUrl: false };
}
?lang= 同时认 search 与 hash query:
export function localeFromLocation(): Locale | null {
return localeFromSearch(window.location.search) ?? localeFromSearch('?' + hashQuery());
}
这样 /?lang=en 与 /#/play/brush?lang=en 都能用。解析规则在 @bamboo/i18n:
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;
}
en-US 归到 en,zh、zh-CN、zh-TW 都归到 zh-CN;fr 之类返回 null,继续走默认。把 zh-TW 也映射到简体不是完美做法,但当前只有一套中文文案,比“识别不了就回英文”更合理。
上次选择存在 localStorage 的 bamboo.locale,写入用 try/catch 包住——隐私模式写不了也不影响本次会话。applyLocaleDocument() 同步两个容易被忽略的 DOM 属性:
document.documentElement.lang = locale === 'en' ? 'en' : 'zh-CN';
document.title = createTranslator(locale)('app.title');
<html lang> 让读屏软件用正确语言发音,document.title 让浏览器标签页与分享卡片显示当前语言的产品名(Bamboo Buddy / 熊猫宝宝的一天)。
Pinia 的 locale store 负责“切换前先就位”:
async function set(next: Locale): Promise<void> {
if (next === current.value) return;
await Promise.all([ensureSummaries(next), ensureMessages(next)]);
current.value = next;
persistLocale(next);
applyLocaleDocument(next);
}
先等两份英文资源加载完,再换状态。否则会出现“界面已经英文、任务标题还是中文”的半截状态。两份都失败时 Promise 仍然 resolve(内部 catch),语言照样切换,只是内容回落到中文——这比卡在加载中好。
toggle() 只是 set(current === 'en' ? 'zh-CN' : 'en')。切回中文不需要加载任何东西(中文常驻),所以那一步几乎是瞬时的;切到英文才需要等两个 chunk。这个不对称是有意设计:中文用户是绝大多数,不能让他们的首屏为英文付体积;英文用户多等几百毫秒,但只等一次。
顶栏的语言按钮只显示目标语言:中文界面显示 EN,英文界面显示 中文。它同时带 aria-label(“切换中英文” / “Switch language”),不靠颜色或图标单独表达。按钮不显示当前语言,而是显示“点了会变成什么”,减少孩子与家长的理解成本。
切语言时不会整页刷新。Vue 的响应式会重渲染所有使用 t() 的组件;玩法页由 key 触发重挂;<html lang> 与标题即时更新。用户体验是“点一下,整页文字换了”,而不是“等一下,页面重新加载”。
12.5 启动与运行时:游戏完全不知道语言
main.ts 在挂载应用前就把初始语言的两份资源准备好:
const initial = initialLocale();
void Promise.all([
ensureSummaries(initial.locale),
ensureMessages(initial.locale),
]).finally(() => {
if (initial.fromUrl) persistLocale(initial.locale);
applyLocaleDocument(initial.locale);
createApp(App).use(createPinia()).mount('#app');
});
?lang=en 打开时,首屏不会先闪一屏中文标题再变英文;中文用户则完全不下载英文 chunk。.finally() 而不是 .then() 是刻意的:即使英文资源加载失败,应用也必须挂载,不能白屏。
组件里统一用 useI18n():
export function useI18n() {
const locale = useLocale();
const t = (key: MessageKey, params?: MessageParams): string =>
createTranslator(locale.current)(key, params);
return { locale, t };
}
每次调用都读当前语言,所以模板渲染时会订阅 locale.current,切换后自动重渲染。游戏内不用这个 composable——Phaser 场景没有 Vue 的响应式上下文,它们用 ctx.locale 自己建取词器:
const tr = createTranslator(ctx.locale);
但更多时候,游戏连取词器都不需要。外壳在 createGameContext() 里已经调用 loadTask(manifest.id, locale),交给玩法的是合并好的 TaskDetail:标题、语音文案、关卡脚本里的步骤名与食物名全是当前语言。场景只读 ctx.task,不判断 locale。这样“新增一条英文内容”不需要改任何 Phaser 代码。
有一条例外:玩法里的通用按钮(“点这里”“拖到这里”“再试一次”“跳过 n / total”)不属于任何一条任务内容,它们走 ctx.locale 建的取词器。这些键都在 game.* 命名空间下,和任务内容分开,避免同一条文案在十个玩法里各写一遍、各翻一遍。
唯一会跟着语言重挂的是玩法宿主:
<PlayView
v-if="route.name === 'player' && route.taskId"
:key="route.taskId + ':' + locale.current"
:task-id="route.taskId"
/>
key 带上语言,切换语言时会销毁旧 Phaser 实例、重新挂载,避免“半中半英”的场景。这个重挂不是浪费:一个已经加载了中文关卡脚本的场景,无法只换几行文字就变成英文布局。
12.6 语音:把 en 映射成 en-US
// packages/game-kit/src/contract.ts
export function speechLang(locale: Locale): string {
return locale === 'en' ? 'en-US' : 'zh-CN';
}
Web Speech 与原生 TTS 的 lang 都期望完整的语音标签。项目不直接传 'en',而是统一映射成 en-US 与 zh-CN:一来与系统语音包的命名一致,二来 getVoices() 返回的 voice.lang 基本都是“语言-地区”形式,用完整标签匹配更稳定。
平台层的 speak() 按语言做三件事:
- 设
utterance.lang; - 在
speechSynthesis.getVoices()里按前缀找嗓音:英文找startsWith('en'),中文找startsWith('zh'); - 语速略不同:英文 0.95、中文 0.92,音高 1.08、音量 0.8。
原生 TTS(安卓壳)走同一套参数,只是通过 @capacitor-community/text-to-speech 的桥调用。语言不匹配时不会报错,最坏情况是系统用默认嗓音读英文——比没有声音好。
英语家庭的设备上,系统可能只装了 en-US 嗓音;中文家庭可能只装了 zh-CN。按前缀找嗓音能覆盖大多数情况,找不到就用系统默认。真正没装任何语音包时,done 以 unavailable 结束,游戏照常可玩,只是没有旁白——这条降级路径在英文与中文下都必须成立。
12.7 端到端:语言切换到底断言了什么
e2e/language.spec.ts 只有两条用例,但覆盖了整条链路。
第一条:英文界面。
page.goto('/?lang=en'),等home-view可见;app-title同时包含Bamboo Buddy与A Panda’s Day;home-stage包含Bamboo(阶段徽章显示 “Bamboo now · Panda Cub”);page.title()是Bamboo Buddy,document.documentElement.lang是en;- 点
nav-tasks,task-card-brush同时包含Brush Teeth与45 sec(时长单位也跟着切); localStorage.getItem('bamboo.locale')是en;- 刷新后仍是
Brush Teeth; - 点
nav-lang切回中文,任务卡变回“刷牙”、document.title变“熊猫宝宝的一天”、lang变zh-CN。
第二条:英文玩法。
await page.goto('/?lang=en#/play/sleep?autoplay=1');
await waitTaskComplete(page, 'sleep', 40_000);
const save = await page.evaluate(() => window.__bamboo?.save ?? null);
expect(save?.tasks.sleep.completions ?? 0).toBeGreaterThanOrEqual(1);
expect(await page.title()).toBe('Bamboo Buddy');
它验证的是“英文会话里,玩法照常完成、事件照常发出、存档照常写入”。语言只影响内容与文案,不影响游戏逻辑与存档结构——这也是为什么 SaveData 里没有任何语言字段。
两条用例都尽量用 data-testid 与文本包含断言,而不是截图比对:英文文案以后可能微调(例如 “Brush Teeth” 改成 “Brushing Teeth”),截图会天天失败;而“包含 Brush Teeth”表达的是产品要求,不是像素要求。45 sec 这条断言还额外覆盖了参数化文案——如果 tasks.duration 的占位符写错,英文会显示 {n} sec。
12.8 漏翻防线:构建期与运行期各一道
内容层的 content:check 对英文做了六类检查:
- 英文任务数量必须等于中文(当前各 10 条);合并后的英文内容要重新通过
TaskDetailSchema,不能靠中文原值“碰巧能过”; - 英文关卡脚本必须能过对应模板的 zod schema;
- 英文禁止词检查:
fail、stupid、loser、punish(中文对应“失败 / 惩罚 / 笨蛋 / 输了 / 淘汰”); - 英文标题不能与中文标题完全相同(疑似漏翻);
findCjk()递归扫描title/habit/diary/tip/intro/done/retry/levelScript,任何一处残留中文都失败;- 中英标题各自唯一;模板与时段覆盖按中文内容检查,英文结构由
localize.test.ts的“结构字段一致”用例保证。
findCjk() 递归遍历字符串、数组与对象,返回所有仍含 [\u4e00-\u9fff] 的字段路径。它的存在让“英文关卡脚本里混进一句中文提示”这种问题在 CI 就暴露,而不是等英文用户截图反馈。
检查范围收窄到真正需要翻译的字段:title、habit、diary、tip、intro、done、retry 以及 levelScript。id、emoji、结构常量不参与,避免把 emoji 的扩展区字符或 id 里的英文误判成“漏翻”;源码里的 LOCALIZED_ONLY_FIELDS 常量记录的正是这组字段。
界面文案层的防线在 messages.test.ts,共 8 个用例:
- 中英两份表的键集完全一致(类型之外再跑一道运行期断言);
- 每条文案都不是空串;
- 英文表里没有中文,唯一例外是
locale.switch——它的值就是“中文”,因为那是“切回中文”的按钮; - 抽样确认英文不是中文的复制粘贴;
fillTemplate缺参数时保留原文;localeFromSearch的六种输入(en、en-US、zh、zh-TW、fr、空)各有断言。
12.9 版面与几个真实的坑
英文比中文长,这是双语产品最常见的布局问题。“Bamboo Leaf Hat”比“竹叶帽”宽一倍,“Next: Turn off light”比“现在:关灯”长得多。项目里的应对:
- 装扮名允许换行(
leading-tight),卡片高度随内容增长,不截断; - 玩法 HUD 的步骤提示走 i18n 模板(
game.nextStep),不把中文冒号写死在场景里; - 食物与情绪标签用短词,长词时缩小一号;
- 按钮用
min-h-12而不是固定高度,英文两行也不会溢出; - 任务卡的标题与习惯分行显示,英文习惯更长时自然折行,不挤掉星级与“开始”按钮;
- 家长页的安全说明用完整段落,容器高度随语言内容变化,不写死行数;
- 弯引号 “A Panda’s Day” 在 CJK 字体里是全身宽,最初渲染成 “A Panda’ s Day”;修法是把拉丁字体排在字体栈最前,让中文自动回落到 Noto Sans SC / 雅黑。
另一个坑是“英文 chunk 误入首屏”。如果 en.ts 被静态 import,首屏体积会从 56.8 KB 涨到 60 KB 上下,正好顶到 60 KB 预算线。所以英文界面表、英文摘要、英文详情必须是三个层次的动态 import;体积门禁在打包后守住这条线,eslint 规则则在写代码时就禁止外壳 import @bamboo/content/full。后者很关键:一句静态 import 就能把 zod 与全部详情拖进首屏,等体积门禁报警时,懒加载已经白做了。
从产物上可以直观看到这三层:en-*.js 只有 6,260 B(界面表),summaries-*.js 1,590 B(十条摘要补丁),<id>.game-*.js 每条几百字节(单条详情)。它们都不在首屏的 index-*.js 里。中文用户不下载,英文用户按需下载,谁都不为对方的语言付钱。
这也解释了为什么英文文案没有采用“一个大 JSON 全量加载”的简单做法:英语家庭确实会下载全部英文内容,但仍应只下载当前玩法的关卡脚本;而中文家庭完全不该为英文付任何字节。按层拆 chunk 让两种用户都拿到最合理的加载曲线。
12.10 小结
- 双语分三层:界面文案、内容(摘要 + 详情 + 关卡脚本)、语音;游戏代码不感知语言;
- 语言清单只在
ids.ts定义一次,Locale类型被所有包复用; - 界面表 174 个键,
MessageKey联合类型 +Record<MessageKey, string>把漏翻变成编译错误; - 内容用补丁而非第二份完整内容,结构字段不重复维护;英文摘要与英文详情都是懒加载 chunk;
- 语言优先级是
?lang=>localStorage> 中文,切换前先等资源就位,<html lang>与标题同步; - 语音把
en映射成en-US,按语言挑嗓音,参数偏慢、偏轻; - 语言偏好存在
bamboo.locale,不进入存档;切换不刷新页面,玩法通过key重挂; - 漏翻有两道防线:构建期
content:check的零中文校验,运行期messages.test.ts的键集与内容校验;两者都在pnpm verify里,不通过就不算完成。
下一章进入实战:把这一章与前面所有章节的知识合起来,从零新增一条日常任务——写摘要 JSON、写关卡脚本、选模板、登记注册表、补双语文案与语音、接日记与奖励,最后用单测、内容校验、端到端与体积门禁逐项自检。