Overworld
指南

i18n 内容组织

内容字段存 key、渲染层翻译的多语言约定

原则:所有文案字段都是"不透明字符串"

框架里一切 text / title / description / name / message 字段,引擎都从不解释、 从不翻译——只原样存储、原样透传。这意味着游戏有两种等价用法:

  • 小项目:直接存字面量(title: '初来乍到'),零成本,starter 示例就是这么做的。
  • 多语言项目:存 i18n key(title: 'quest.welcome.title'),UI 渲染时用 i18next 的 t() 翻译。

引擎逻辑(触发器匹配、进度累计、持久化)只认 id,不认文案,所以两种用法可以随时切换, 甚至混用——不需要改任何框架代码。

推荐 key 约定

按"内容类型 + 内容 id + 字段"组织,与内容数据的结构一一对应:

内容key 模式例子
任务quest.<id>.title / .description / .objectives.<objId>quest.welcome.title
对话dialogue.<tree>.<node>.text / .responses.<id>dialogue.guide-intro.hello.text
物品item.<id>.name / .descriptionitem.crystal.name
NPCnpc.<id>.namenpc.guide.name
成就achievement.<id>.title / .descriptionachievement.first-steps.title
UIui.<area>.<key>ui.hud.interact-hint

最小示例(starter 风格)

内容文件存 key,行为定义(trigger/rewards)完全不变:

// game/content.ts —— 文案字段从字面量换成 key,其余不动
export const QUESTS: QuestDefinition[] = [
  {
    id: 'welcome',
    category: 'tutorial',
    title: 'quest.welcome.title',
    description: 'quest.welcome.description',
    autoStart: true,
    objectives: [
      {
        id: 'walk',
        description: 'quest.welcome.objectives.walk',
        target: 20,
        trigger: { event: 'player:moved', amountFrom: 'distance' },
      },
    ],
    rewards: [{ type: 'gold.add', params: { amount: 50 } }],
  },
]

UI 组件在渲染时翻译(对比 starter 的 QuestTracker,只多了 t()):

// ui/HUD.tsx
import { useTranslation } from 'react-i18next'

function QuestTracker() {
  const { t } = useTranslation()
  const active = useStore(quests, (s) => s.active)
  const definitions = useStore(quests, (s) => s.definitions)

  return (
    <div>
      {Object.values(active).map((quest) => {
        const def = definitions[quest.questId]
        if (!def) return null
        return (
          <div key={quest.questId}>
            <div>{t(def.title)}</div>
            {def.objectives.map((obj) => (
              <div key={obj.id}>
                {t(obj.description)}({Math.floor(quest.objectives[obj.id]?.current ?? 0)}/{obj.target})
              </div>
            ))}
          </div>
        )
      })}
    </div>
  )
}

语言切换就是 i18n.changeLanguage('en')——react-i18next 自己触发重渲染, zustand 完全不参与:store 里存的是 key,key 不随语言变,所以不需要任何存档迁移、 不需要在语言切换时改写状态。

注意事项:不要在 emit 时翻译

Toast、事件载荷、任何会被存下来的消息里,只放 key 或结构化数据,翻译永远留在渲染层:

// ❌ 错:emit 时翻译 —— 切语言后,历史 Toast/消息还停留在旧语言
toasts.push({ message: t('quest.welcome.completed-toast') })

// ✅ 对:载荷放 key(或结构化数据),渲染 Toast 列表的组件里再 t()
toasts.push({ message: 'quest.welcome.completed-toast' })
// 渲染处:{t(String(toast.message))}

同理,quest:completed 等事件的订阅者如果要展示文案,应携带 questId, 由 UI 侧查定义、取 key、再翻译。框架的 notifications 包把 message 定义为 不透明值,正是为了支持这条链路。

目录组织(参考 degener-city)

degener-city 用单一 translation 命名空间 + 顶层前缀划分模块 (quests.*dialogues.*npcs.*ui.*achievements.*……每个前缀一个 JSON 文件, 在 i18n/config.ts 合并)。这个模式可以直接沿用,规模更小时可以合并文件:

src/i18n/
  config.ts            # i18next 初始化 + resources 合并
  locales/
    zh/
      quests.json      # { "welcome": { "title": "初来乍到", ... } }
      dialogues.json
      items.json
      ui.json
    en/
      quests.json
      ...

要点:locale 文件的嵌套结构与 key 约定对齐(quest.welcome.titlequests.json 里的 welcome.title,配置时挂到 quest 前缀下); 新增内容时先写内容文件里的 key,再补齐各语言 JSON,缺失 key 用 i18next 的 fallbackLng 兜底,不会崩游戏。

本页目录