《Cocos 游戏开发实战》11.3 多语言与字体

11.1 的存档里留了个 settings.language 没实现。本节把 UI 文案外置成 JSON 资源,用 sys.languageCode 探测系统语言、允许玩家手动覆盖,写一个带插值的 i18n 核心和一个跟随语言的 Label 组件,最后对比系统字体、TTF 与位图字体三种方案,并说清微信小游戏对字体的真实限制。

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 + fontFamily0中文、多语言,首选
TTF 动态字体font 引用 Font 资源中(整个字体文件)少量固定文案、需要特定字形
位图字体font 引用 BitmapFont小(只含用到的字)数字、分数等固定字符集

对《方块冲刺》的取舍很直接:中文 UI 一律用系统字体(useSystemFont = true,fontFamily 留 Arial 或平台默认),因为一个中文字体文件动辄好几 MB,塞进 4 MB 的首包会直接压垮微信端;数字与分数用位图字体,字形少、渲染快、风格可控。

微信小游戏的字体限制要单独记住三条:(1)不能用 @font-face 加载远程字体,小游戏环境不支持;(2)自定义字体必须打进包体,所以要严格控制体积;(3)中文不要用 TTF,要么系统字体,要么把用到的字子集化成只含几十个字的位图字体。子集化的代价是:文案一旦新增汉字,字体图集就得重新生成——所以它只适合数字、字母这类字符集固定的场景。

位图字体子集化的流程是固定四步,照做即可:

  1. 从文案表里抽出该字体实际要显示的全部字符(比如 HUD 里的 0-9、:、m、+)。
  2. 用字体工具把这几百个字形导出成一张 PNG 图集。
  3. 用工具生成配套的 .fnt 描述文件,记录每个字形的坐标与尺寸。
  4. 把 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 登录与分享 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画