Skip to content

游戏本地化与国际化

预计阅读 25 分钟
版本信息
最后更新:2026-07-09 · 适用基础库:≥ 3.9.0(最低兼容 ≥ 2.30.0)

本地化(Localization / l10n)是指让游戏适配不同语言和地区的用户——不仅是翻译文本,还包括日期格式、货币符号、字体排版、甚至 UI 布局方向。Web 前端有成熟的 i18nextreact-intl 等方案,而小游戏的本地化需要从资源加载、运行时切换、字体回退等多个维度设计。本章从前端视角出发,构建一套适合微信小游戏的本地化架构。


为什么小游戏需要本地化?

即使你的目标市场只有国内用户,本地化思维仍然有价值:

  • 出海准备:越来越多微信小游戏面向港澳台、东南亚、欧美发行
  • 简繁转换:大陆(简体中文)和港澳台(繁体中文)之间存在用语差异(如「鼠标」→「滑鼠」、「软件」→「軟體」)
  • 多语言用户:微信有大量海外华人和国际用户
  • 代码可维护性:将所有字符串集中管理,比散落在代码中的硬编码更易维护和修改
前端类比
  • i18n 库(i18next) ≈ 游戏中的字符串表(JSON key-value)
  • Intl.DateTimeFormat ≈ 游戏中的日期格式化模块
  • <html lang="zh-CN"> ≈ 游戏中的语言选择 + 字体切换
  • CSS direction: rtl ≈ 游戏中针对阿拉伯语等 RTL 语言的 UI 镜像

架构设计:字符串表方案

最简单也最实用的方案是键值对字符串表——将所有用户可见文本存入 JSON 文件,按语言分目录管理。

assets/
└── i18n/
    ├── zh-CN.json    # 简体中文
    ├── zh-TW.json    # 繁体中文
    ├── en.json       # 英语
    ├── ja.json       # 日语
    └── ko.json       # 韩语

字符串表示例

json
// zh-CN.json
{
  "game.title": "疯狂小鸟",
  "game.start": "开始游戏",
  "game.pause": "暂停",
  "game.score": "得分:{0}",
  "game.bestScore": "最高分:{0}",
  "shop.title": "商店",
  "shop.buy": "购买",
  "shop.price": "¥{0}",
  "result.win": "恭喜过关!",
  "result.lose": "再接再厉!",
  "setting.language": "语言"
}
json
// en.json
{
  "game.title": "Crazy Bird",
  "game.start": "Start",
  "game.pause": "Pause",
  "game.score": "Score: {0}",
  "game.bestScore": "Best: {0}",
  "shop.title": "Shop",
  "shop.buy": "Buy",
  "shop.price": "${0}",
  "result.win": "You Win!",
  "result.lose": "Try Again!",
  "setting.language": "Language"
}

本地化管理器

typescript
// i18n/I18nManager.ts
type LangCode = 'zh-CN' | 'zh-TW' | 'en' | 'ja' | 'ko'

class I18nManager {
  private static instance: I18nManager
  private currentLang: LangCode = 'zh-CN'
  private translations: Record<string, string> = {}
  private fallbackLang: LangCode = 'zh-CN'
  private fallbackTranslations: Record<string, string> = {}

  static getInstance(): I18nManager {
    if (!I18nManager.instance) {
      I18nManager.instance = new I18nManager()
    }
    return I18nManager.instance
  }

  /** 初始化:加载当前语言和回退语言的字符串表 */
  async init(lang?: LangCode): Promise<void> {
    // 优先使用传入语言,其次系统语言,最后回退语言
    const targetLang = lang ?? this.getSystemLang()
    await this.loadLanguage(targetLang)
  }

  /** 切换语言(运行时动态切换) */
  async setLanguage(lang: LangCode): Promise<void> {
    this.currentLang = lang
    await this.loadLanguage(lang)
    // 持久化选择
    wx.setStorageSync('game_language', lang)
    // 通知所有 UI 组件刷新文本(发布-订阅或全局事件)
    this.notifyLanguageChanged()
  }

  /** 获取翻译文本 */
  t(key: string, ...args: (string | number)[]): string {
    let template = this.translations[key]
    if (!template) {
      // 当前语言缺失时回退
      template = this.fallbackTranslations[key] ?? key
    }
    // 替换占位符 {0}, {1}, ...
    return template.replace(/\{(\d+)\}/g, (_, index) => {
      return String(args[parseInt(index)] ?? '')
    })
  }

  getCurrentLang(): LangCode {
    return this.currentLang
  }

  // --- 内部实现 ---

  private async loadLanguage(lang: LangCode): Promise<void> {
    try {
      // 从分包或网络加载 JSON 字符串表
      const jsonPath = `i18n/${lang}.json`
      const translations = await this.loadJSON(jsonPath)

      if (lang === this.fallbackLang) {
        this.fallbackTranslations = translations
      } else {
        this.translations = translations
        // 首次加载时同时加载回退语言
        if (Object.keys(this.fallbackTranslations).length === 0) {
          this.fallbackTranslations = await this.loadJSON(`i18n/${this.fallbackLang}.json`)
        }
      }
    } catch (err) {
      console.error(`加载语言 ${lang} 失败`, err)
    }
  }

  private loadJSON(path: string): Promise<Record<string, string>> {
    return new Promise((resolve, reject) => {
      wx.request({
        url: `https://cdn.example.com/game/${path}`,
        success: (res) => {
          try {
            resolve(typeof res.data === 'string' ? JSON.parse(res.data) : res.data)
          } catch {
            reject(new Error('JSON parse error'))
          }
        },
        fail: reject,
      })
    })
  }

  private getSystemLang(): LangCode {
    const sysInfo = wx.getSystemInfoSync()
    const lang = sysInfo.language ?? 'zh_CN'
    // 微信返回的是 zh_CN(下划线),转为 zh-CN
    const normalized = lang.replace('_', '-')
    const supported: LangCode[] = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko']
    return supported.includes(normalized as LangCode) ? (normalized as LangCode) : this.fallbackLang
  }

  private listeners: Array<() => void> = []

  onChange(listener: () => void): void {
    this.listeners.push(listener)
  }

  private notifyLanguageChanged(): void {
    this.listeners.forEach((fn) => fn())
  }
}

// 便捷导出(全局单例)
export const i18n = I18nManager.getInstance()
export const t = i18n.t.bind(i18n)

在游戏代码中使用

typescript
// 初始化(游戏入口 game.js)
import { i18n } from './i18n/I18nManager'

// 读取用户上次选择的语言
const savedLang = wx.getStorageSync('game_language') as string | undefined
await i18n.init(savedLang as any)

// --- 业务代码中使用 ---
import { t } from '../i18n/I18nManager'

// 简单文本
const startLabel = t('game.start') // "开始游戏" / "Start"

// 带参数的文本
const scoreLabel = t('game.score', 9999) // "得分:9999" / "Score: 9999"
const priceLabel = t('shop.price', 6) // "¥6" / "$6"

// UI 组件中监听语言切换
i18n.onChange(() => {
  this.updateAllTexts() // 刷新所有 Label 组件
})

运行时语言切换

语言切换不应该要求重启游戏——玩家在设置页切换语言后,所有 UI 应立即更新。

typescript
// ui/LocalizedLabel.ts — 自动响应语言切换的 Label 组件
import { _decorator, Component, Label } from 'cc'
import { i18n as i18nManager } from '../i18n/I18nManager'
const { ccclass, property } = _decorator

@ccclass('LocalizedLabel')
export class LocalizedLabel extends Component {
  @property
  i18nKey: string = ''

  private label: Label | null = null
  private unbind: (() => void) | null = null

  start() {
    this.label = this.getComponent(Label)
    this.refresh()
    this.unbind = i18nManager.onChange(() => this.refresh())
  }

  refresh() {
    if (this.label && this.i18nKey) {
      this.label.string = i18nManager.t(this.i18nKey)
    }
  }

  onDestroy() {
    this.unbind?.()
  }
}

字体回退策略

不同语言需要不同的字体——中文用中文字体,日文用日文字体,阿拉伯文用支持连写的字体。

策略 1:系统字体(推荐)

微信小游戏直接使用系统字体即可覆盖大多数语言:

css
/* 全局字体栈(在 Label 或样式系统中配置) */
font-family:
  'PingFang SC', 'Microsoft YaHei', 'Hiragino Sans', 'Noto Sans JP', 'Apple SD Gothic Neo', 'Arial',
  sans-serif;

策略 2:按语言加载位图字体

如果你的游戏使用位图字体(BMFont),需要为每种语言准备独立的字体文件:

typescript
async function loadFontForLanguage(lang: string): Promise<void> {
  const fontMap: Record<string, string> = {
    'zh-CN': 'fonts/zh-CN-common',
    'zh-TW': 'fonts/zh-TW-common',
    en: 'fonts/en-common',
    ja: 'fonts/ja-common',
    ko: 'fonts/ko-common',
  }

  const fontPath = fontMap[lang]
  if (!fontPath) return

  // 通过微信文件系统加载位图字体
  const fs = wx.getFileSystemManager()
  const fontDir = `${wx.env.USER_DATA_PATH}/fonts/`

  try {
    // 确保字体缓存目录存在
    try {
      fs.accessSync(fontDir)
    } catch {
      fs.mkdirSync(fontDir, true)
    }

    // 从代码包中读取位图字体文件(.fnt 文本 + .png 纹理图集)
    const fntCodePath = `fonts/${fontPath}.fnt`
    const pngCodePath = `fonts/${fontPath}.png`
    const fntTargetPath = `${fontDir}${fontPath}.fnt`
    const pngTargetPath = `${fontDir}${fontPath}.png`

    // 读取并缓存 .fnt 配置文件
    const fntContent = fs.readFileSync(fntCodePath, 'utf8')
    fs.writeFileSync(fntTargetPath, fntContent, 'utf8')

    // 读取并缓存 .png 纹理图集(二进制读取)
    const pngContent = fs.readFileSync(pngCodePath) // 返回 ArrayBuffer
    fs.writeFileSync(pngTargetPath, pngContent)

    console.log(`字体已缓存: ${fontPath}`)
  } catch (err) {
    console.warn(`字体加载失败 ${fontPath},回退到系统字体`, err)
  }
}

RTL(从右到左)语言支持

如果你的游戏需要支持阿拉伯语、希伯来语等 RTL 语言,需要额外处理 UI 镜像。

typescript
// i18n/rtl.ts — RTL 语言列表
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']

export function isRTL(lang: string): boolean {
  return RTL_LANGUAGES.includes(lang.split('-')[0])
}

// UI 组件中根据 RTL 调整布局
function adjustLayoutForRTL(node: Node, lang: string): void {
  if (isRTL(lang)) {
    // Cocos Creator 中通过 scale.x = -1 镜像整个 UI 子树
    node.setScale(-1, 1, 1)
  } else {
    node.setScale(1, 1, 1)
  }
}

⚠️ 注意:RTL 不只是镜像 UI——文字的对齐方式也应调整(右对齐而非左对齐),数字和标点符号的显示方向也需要正确处理。如果你的目标市场不包括 RTL 地区,可以暂不处理。


伪本地化测试

伪本地化(Pseudolocalization)是一种测试技术——将源文本转换为带重音符号的长字符串,用于发现硬编码文本和 UI 溢出问题,而不需要真正翻译。

typescript
// i18n/pseudo.ts — 伪本地化工具
function pseudoLocalize(text: string): string {
  return (
    text
      .replace(/[aeiou]/gi, (match) => {
        // 元音加长并加重音
        const accented: Record<string, string> = {
          a: 'àáâãäå',
          e: 'èéêë',
          i: 'ìíîï',
          o: 'òóôõö',
          u: 'ùúûü',
          A: 'ÀÁÂÃÄÅ',
          E: 'ÈÉÊË',
          I: 'ÌÍÎÏ',
          O: 'ÒÓÔÕÖ',
          U: 'ÙÚÛÜ',
        }
        const options = accented[match] ?? match
        return options[Math.floor(Math.random() * options.length)].repeat(2)
      })
      // 前后加标记方便识别
      .replace(/^(.*)$/, '[⟪ $1 ⟫]')
  )
}

// 开发环境中启用伪本地化
if (typeof wx !== 'undefined' && wx.getAccountInfoSync) {
  const envVersion = wx.getAccountInfoSync().miniProgram?.envVersion
  if (envVersion === 'develop') {
    i18n.setPseudoLocalize(true, pseudoLocalize)
  }
}

用伪本地化测试可以发现:

  • 硬编码文本:没有经过 t() 的文本不会变长也不会加重音,一眼就能发现
  • UI 溢出:文本长度通常膨胀 30-50%,能暴露固定宽度的 Label 溢出问题
  • 拼接问题t('score') + ': ' + score 这种拼接在伪本地化后顺序混乱

复数规则与 ICU 消息格式

简单占位符替换 t('enemies', count) 在单语言场景下够用,但真实的国际化需要处理不同语言的复数规则和更复杂的格式化需求。

为什么简单替换不够

typescript
// 问题:不同语言对「数量」的表达逻辑不同
// 中文:「{count} 个敌人」→ 不管 count 是多少,格式都一样
// 英语:「{count} enemy」→ count=1 是 "1 enemy",count>1 是 "2 enemies"
// 俄语:有 3 种复数形式(1, 2-4, 5-20...),逻辑完全不同!

// 简单占位符需要这样处理:
if (lang === 'en') {
  text = count === 1 ? t('enemy.singular', count) : t('enemy.plural', count)
} else if (lang === 'ru') {
  // 三种形式...
}
// 这正是 ICU 消息格式要解决的问题

轻量 ICU 实现

为微信小游戏实现一个轻量的 ICU 消息解析器(避免引入完整的 intl-messageformat 包,增加约 50KB):

typescript
// i18n/icu.ts — 轻量 ICU 消息格式支持
type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'

/**
 * 解析简化版的 ICU 消息格式
 *
 * 支持的格式:
 * - 基础替换: `Hello, {name}!`
 * - 复数: `{count, plural, =0 {No items} one {One item} other {# items}}`
 * - 选择: `{gender, select, male {His} female {Her} other {Their}}`
 */
function parseICU(template: string, variables: Record<string, string | number>): string {
  return template.replace(
    /\{(\w+)(?:,\s*(plural|select),\s*([^}]+))?\}/g,
    (match, key, type, options) => {
      if (!type) {
        // 简单替换: {name} → variables.name
        return String(variables[key] ?? `{${key}}`)
      }

      if (type === 'select') {
        return parseSelect(options, String(variables[key] ?? 'other'))
      }

      if (type === 'plural') {
        const count = Number(variables[key]) || 0
        return parsePlural(options, count)
      }

      return match
    }
  )
}

/** 解析 select 子句: `male {His} female {Her} other {Their}` */
function parseSelect(options: string, value: string): string {
  const parts = parseOptions(options)
  return parts.get(value) ?? parts.get('other') ?? value
}

/** 解析 plural 子句: `=0 {No items} one {One item} other {# items}` */
function parsePlural(options: string, count: number): string {
  const parts = parseOptions(options)

  // 精确匹配(=0, =1, =2...)优先
  const exact = parts.get(`=${count}`)
  if (exact) return exact.replace('#', String(count))

  // 复数类别匹配
  const category = getPluralCategory(count, 'en') // 默认英语规则
  const result = parts.get(category) ?? parts.get('other') ?? String(count)
  return result.replace('#', String(count))
}

/** 解析 `key {value}, key2 {value2}` 格式的选项字符串 */
function parseOptions(options: string): Map<string, string> {
  const result = new Map<string, string>()
  // 匹配:  key { value }  或  =0 { value }
  const regex = /(=?\w+)\s*\{([^}]*)\}/g
  let match
  while ((match = regex.exec(options)) !== null) {
    result.set(match[1], match[2].trim())
  }
  return result
}

/** 获取复数类别(简化实现,仅支持常用语言) */
function getPluralCategory(count: number, lang: string): PluralCategory {
  // 中文:只有 other
  if (
    lang.startsWith('zh') ||
    lang.startsWith('ja') ||
    lang.startsWith('ko') ||
    lang.startsWith('vi')
  ) {
    return 'other'
  }

  // 英语/德语/西班牙语:one vs other
  if (lang.startsWith('en') || lang.startsWith('de') || lang.startsWith('es')) {
    return count === 1 ? 'one' : 'other'
  }

  // 俄语/乌克兰语:one vs few vs many
  if (lang.startsWith('ru') || lang.startsWith('uk')) {
    const n = count % 10
    const n100 = count % 100
    if (n === 1 && n100 !== 11) return 'one'
    if (n >= 2 && n <= 4 && !(n100 >= 12 && n100 <= 14)) return 'few'
    return 'many'
  }

  // 阿拉伯语:规则更复杂(zero/one/two/few/many)
  // 此处简化处理

  return 'other' // 默认 fallback
}

集成到 I18nManager

typescript
// 扩展现有的 I18nManager.t() 方法
class I18nManagerWithICU extends I18nManager {
  // 原有 t(key, ...args) 方法保持不变(简单场景)
  // 新增支持 ICU 格式的方法
  tICU(key: string, variables?: Record<string, string | number>): string {
    const template = this.getTemplate(key)
    if (!variables || Object.keys(variables).length === 0) {
      return template
    }
    return parseICU(template, variables)
  }

  private getTemplate(key: string): string {
    // 与原有 t() 方法相同的查找逻辑
    return this.translations[key] ?? this.fallbackTranslations[key] ?? key
  }
}

字符串表示例(使用 ICU 格式)

json
// zh-CN.json — 中文只需基本格式
{
  "enemy.count": "{count} 个敌人",
  "item.found": "找到了 {count} 件物品"
}

// en.json — 英语需要区分单复数
{
  "enemy.count": "{count, plural, one {# enemy} other {# enemies}}",
  "item.found": "{count, plural, =0 {Found nothing} one {Found # item} other {Found # items}}"
}

// ru.json — 俄语需要多种复数形式
{
  "enemy.count": "{count, plural, one {# враг} few {# врага} many {# врагов}}",
  "item.found": "{count, plural, one {Найден # предмет} few {Найдено # предмета} many {Найдено # предметов}}"
}
复数规则使用建议
  1. 先评估需求:如果目标市场只有简体/繁体中文,不需要 ICU 复数格式——中文无复数变化。
  2. 使用成熟的库:如果包体积允许(< 200KB),直接使用 intl-messageformat 而非自己造轮子。
  3. 与翻译团队协作:ICU 格式需要翻译人员理解 {count, plural, ...} 语法,建立翻译规范文档。

最佳实践总结

原则说明
永远不要硬编码文本所有面向用户的字符串都放入字符串表
使用占位符而非拼接t('score', 100) 而非 t('score') + ': 100'
保持字符串表扁平避免深度嵌套的 JSON 结构,shop.item.title 不如 shop_item_title
翻译文本预留空间英语→德语可能膨胀 30%,UI 设计时预留弹性空间
日期/数字使用本地化 API不要自己格式化为固定格式的字符串
测试每种语言至少启动一次每种支持的语言,检查 UI 是否正常
分离内容与代码字符串表可通过 CDN 动态下发,不随游戏包更新

📝 课后练习

练习 1:实现语言切换不重启

题目: 在你的 Flappy Bird 项目中实现一个语言设置页,支持中英文切换,切换后所有 UI 文本立即更新(不重启游戏)。

要求:

  1. 至少翻译开始按钮、分数显示、游戏结束提示三个文本
  2. 切换语言后当前游戏状态保持不变(不会从零重新开始)
  3. 下次启动游戏时记住上次选择的语言

参考答案: 使用发布-订阅模式,所有文本组件注册 onLanguageChanged 回调。语言设置通过 wx.setStorageSync 持久化。

练习 2:伪本地化兼容性扫描

题目: 为你的游戏启用伪本地化模式,并修复发现的所有问题。

检查清单:

  • [ ] 所有文本是否都通过了 t() 函数?
  • [ ] 是否有因文本溢出导致的 UI 错位?
  • [ ] 是否有字符串拼接导致的语序问题?
  • [ ] 数字和货币格式是否正确?

参考答案: 逐一修复:对硬编码文本补充 t() 调用;对溢出文本缩小字号或扩展 Label 宽度;将拼接文本改为占位符模板。


📚 相关阅读

用心学习,持续实践