11.3 多语言与字体
11.1 的存档里留了一个 settings.language 字段,但一直没实现。这一节把它补上:让 UI 文案随语言切换,并处理一个更容易被忽略的问题——换语言不只是换文字,还可能要换字体。中文和英文对字体的要求完全不同,而微信小游戏对字体的限制又比 H5 更严。
11.3.1 先决定:要不要做 i18n
不是所有项目都值得做多语言。判断线很简单:
- 只发中文市场、文案少于 50 条:硬编码在脚本里更快,别过度设计。
- 目标市场多于一个,或文案会长到几百条:越早把文案外置越好。
《方块冲刺》属于后者——它要发到 H5、微信、原生三端,且英文是默认会做的第二种语言。所以从这一节起,所有面向玩家的字符串都不再写死在代码里,而是通过键去文案表里查。
11.3.2 文案表的结构
文案表就是一份扁平键值的 JSON,放在 assets/resources/i18n/ 下,用 resources.load 按需加载:
{
"ui.settings.title": "设置",
"ui.settings.sound": "音效",
"ui.settings.haptics": "震动",
"hud.coins": "金币:{count}",
"hud.distance": "最远:{meters} 米",
"result.coins_earned": "本局获得 {count} 枚金币"
}
{
"ui.settings.title": "Settings",
"ui.settings.sound": "Sound",
"ui.settings.haptics": "Haptics",
"hud.coins": "Coins: {count}",
"hud.distance": "Best: {meters} m",
"result.coins_earned": "Earned {count} coins"
}
三条约定:键名用点分层级(ui.settings.title),便于按模块归类;插值统一用 {name},不用位置占位符,翻译时不会错位;键名本身是英文,中文值也放在 zh.json 里,绝不把中文当键。键在 zh.json 里存在、在 en.json 里缺失时,运行时会回退到中文——所以中文表要当基准表,先补全它。
键在两份表之间必须一一对应,缺一个就会在界面上暴露成键名。与其靠人眼比,不如写一个纯 TS 校验器,接进构建前检查:
// scripts/check-i18n.ts
type Table = Record<string, string>;
export function missingKeys(base: Table, target: Table): string[] {
return Object.keys(base)
.filter((key) => !(key in target))
.sort();
}
export function extraKeys(base: Table, target: Table): string[] {
return Object.keys(target)
.filter((key) => !(key in base))
.sort();
}
export function validate(base: Table, target: Table): void {
const missing = missingKeys(base, target);
const extra = extraKeys(base, target);
if (missing.length) throw new Error(`缺少翻译:${missing.join(', ')}`);
if (extra.length) throw new Error(`多余键:${extra.join(', ')}`);
}
这段代码不碰引擎,可以直接用 Node 跑:读入两份 JSON、调用 validate,任何一边多键少键都抛错。把它挂进发布流水线,翻译同事漏翻的键在打包前就会被拦住。
关于复数:英文有 1 coin / 2 coins 的区别,中文没有。别为它在引擎里造一套复杂的复数规则,把复数做成两个键(result.coins_one、result.coins_other)在业务层按数量选,比引入 ICU 规则简单得多,也更好翻译。
11.3.3 语言探测与选择
默认语言从系统读,玩家可在设置里覆盖。系统语言用 sys.languageCode 拿 ISO 639-1 码(如 zh-cn、en-us),它比 sys.language 枚举更细:
function detectLanguage(): string {
const code = (sys.languageCode || '').toLowerCase();
if (code.startsWith('zh')) return 'zh';
if (code.startsWith('en')) return 'en';
return 'zh';
}
sys.language 返回的是 sys.Language 枚举(CHINESE、ENGLISH 等),适合做粗粒度分支;sys.languageCode 返回字符串,适合按前缀匹配。选择优先级是:存档里的 settings.language > 系统语言 > 兜底 zh。玩家覆盖值一旦写入 11.1 的存档,下次启动就优先用它。
支持的语言要显式列出来,别让 languageCode 直接当文件夹名:zh-tw、zh-hk 这类细分码会被前缀匹配归到 zh,而 en-gb、en-au 归到 en。维护一张「语言码 → 表名」的映射表,遇到未收录的码统一回退到 zh,这样新增语言时只改映射,不动加载逻辑。
11.3.4 i18n 核心(L2)
核心只有三件事:加载表、查键插值、记住当前语言:
// assets/scripts/core/i18n.ts
import { JsonAsset, resources, sys, warn } from 'cc';
type Table = Record<string, string>;
const FALLBACK = 'zh';
let table: Table = {};
let current = FALLBACK;
export function detectLanguage(): string {
const code = (sys.languageCode || '').toLowerCase();
if (code.startsWith('zh')) return 'zh';
if (code.startsWith('en')) return 'en';
return FALLBACK;
}
export function t(key: string, vars?: Record<string, string | number>): string {
let text = table[key] ?? key;
if (vars) {
for (const name of Object.keys(vars)) {
text = text.split(`{${name}}`).join(String(vars[name]));
}
}
return text;
}
export function loadTable(lang: string): Promise<void> {
return new Promise((resolve) => {
resources.load(`i18n/${lang}`, JsonAsset, (err, asset) => {
if (err || !asset.json) {
warn('[i18n] 文案表加载失败,回退基准语言', lang, err);
resolve();
return;
}
table = asset.json as Table;
current = lang;
resolve();
});
});
}
export function getLanguage(): string {
return current;
}
t() 找不到键时返回键名本身(table[key] ?? key),而不是空串——这样缺翻译会在界面上直接暴露成 ui.settings.title,一眼就能发现,比默默显示空白强得多。插值用 split().join() 而不是正则替换,是为了避免文案里的 $ 等字符被当成正则元字符。
11.3.5 让 Label 跟随语言(L2)
UI 上每个需要翻译的 Label 挂一个轻量组件,它只存一个键、在 onLoad 时把结果写进 Label.string:
// assets/scripts/ui/I18nLabel.ts
import { _decorator, Component, Label } from 'cc';
const { ccclass, property } = _decorator;
declare function t(key: string, vars?: Record<string, string | number>): string;
@ccclass('I18nLabel')
export class I18nLabel extends Component {
@property
key = '';
private label: Label | null = null;
onLoad(): void {
this.label = this.getComponent(Label);
this.refresh();
}
refresh(): void {
if (!this.label || !this.key) return;
this.label.string = t(this.key);
}
}
@property key = '' 让这个键在属性面板里可编辑——策划改文案键不用动代码。切换语言的流程刻意做得「重」:改完存档里的 settings.language 后直接重载当前场景(director.loadScene),让所有 I18nLabel 重新走一遍 onLoad。比起维护一套「语言变更事件订阅」,重载场景多花几百毫秒、但逻辑少一个数量级,对低频操作完全划算。
11.3.6 字体:系统字体、TTF、位图字体
文字能不能显示、好不好看,取决于字体资源的形态。三种方案各有适用场景:
| 方案 | Cocos 里的设置 | 包体代价 | 适用 |
|---|---|---|---|
| 系统字体 | useSystemFont = true + fontFamily | 0 | 中文、多语言,首选 |
| TTF 动态字体 | font 引用 Font 资源 | 中(整个字体文件) | 少量固定文案、需要特定字形 |
| 位图字体 | font 引用 BitmapFont | 小(只含用到的字) | 数字、分数等固定字符集 |
对《方块冲刺》的取舍很直接:中文 UI 一律用系统字体(useSystemFont = true,fontFamily 留 Arial 或平台默认),因为一个中文字体文件动辄好几 MB,塞进 4 MB 的首包会直接压垮微信端;数字与分数用位图字体,字形少、渲染快、风格可控。
微信小游戏的字体限制要单独记住三条:(1)不能用 @font-face 加载远程字体,小游戏环境不支持;(2)自定义字体必须打进包体,所以要严格控制体积;(3)中文不要用 TTF,要么系统字体,要么把用到的字子集化成只含几十个字的位图字体。子集化的代价是:文案一旦新增汉字,字体图集就得重新生成——所以它只适合数字、字母这类字符集固定的场景。
位图字体子集化的流程是固定四步,照做即可:
- 从文案表里抽出该字体实际要显示的全部字符(比如 HUD 里的
0-9、:、m、+)。 - 用字体工具把这几百个字形导出成一张 PNG 图集。
- 用工具生成配套的
.fnt描述文件,记录每个字形的坐标与尺寸。 - 把 PNG 与
.fnt一起导入工程,编辑器会生成BitmapFont资源,再拖到Label的font属性上。
一旦字符集变化(新增了汉字),这四步要重来一遍——所以把「字符集清单」也当配置文件管起来,改文案时顺手更新它,比事后排查「某个字变成方块」省事。
11.3.7 预制体里的 Label 序列化
UI 上的 Label 在场景/预制体文件里是序列化的。以下是 SettingsPanel.prefab 中标题节点的 Label 组件摘录。需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
{
"__type__": "cc.Label",
"_name": "",
"node": { "__id__": 3 },
"_enabled": true,
"_string": "设置",
"_fontSize": 24,
"_lineHeight": 30,
"_overflow": 0,
"_enableWrapText": true,
"_useSystemFont": true,
"_fontFamily": "Arial",
"_horizontalAlign": 1,
"_verticalAlign": 1
}
在编辑器里打开 assets/prefabs/SettingsPanel.prefab,选中标题节点,属性检查器会显示:String 填的是「设置」、Font Size 为 24、Line Height 为 30、Overflow 为 NONE、Use System Font 已勾选、Font Family 为 Arial、水平与垂直对齐都是居中。注意 _string 里存的是中文基准文案——运行时 I18nLabel.onLoad 会用 t('ui.settings.title') 把它覆盖成当前语言。也就是说,序列化值是「编辑器里所见」,真实显示由代码决定,两者不冲突。
11.3.8 收口:本章多了什么
第 11 章到此结束,工程里新增:assets/resources/i18n/{zh,en}.json(文案表)、assets/scripts/core/i18n.ts、assets/scripts/ui/I18nLabel.ts,以及 11.1、11.2 的存档与云同步模块。可配置项是「支持的语言列表」和「各语言的字体策略」,建议放 configs/ 统一管理。
下一章把游戏真正接到平台上:登录、分享、广告、内购——那些只有在微信或应用商店里才能跑起来的能力。
阅读导航:上一节:11.2 云存档与冲突消解 · 下一节:12.1 登录与分享 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。