游戏里的复数总翻错?用 ICU MessageFormat Plural 一次修好多语言复数
英语加 s、中文不变、俄语 4 种、阿拉伯语 6 种——复数在每种语言里规则都不同。讲清 ICU MessageFormat Plural,以及结构化复数编辑器如何让一条源文本自动展开成各语言正确的复数。
游戏里最不起眼、也最容易翻车的一句文案,往往是「你有 {count} 条新消息」。英语开发者顺手写成 You have {count} new message(s),或者更糙一点的 {count} messages——在英语里凑合能看,一进多语言就全线崩:俄语要按数字尾数在 4 种词形里挑,阿拉伯语有 6 种复数类别,中文一种都不用变却被硬塞了个英语的 s。这不是翻译质量问题,是复数这件事本身就没法用「拼字符串」解决。ICU MessageFormat Plural 就是为这件事而生的,下面讲清它是什么、门槛在哪,以及我们怎么让它在游戏本地化里真正跑起来。
为什么「{count} 个」在别的语言里会崩
英语开发者对复数的直觉只有两档:1 个是单数,其余是复数,加个 s 就完事。但这只是英语的规则。CLDR(Unicode 通用本地化数据)把每种语言需要区分的复数类别定成了一张标准表,各语言差异极大:
| 语言 | 复数类别 | 数量 |
|---|---|---|
| 中文 / 日语 / 韩语 | other | 1 种 |
| 英语 / 德语 | one other | 2 种 |
| 法语 / 西班牙语 / 葡萄牙语 | one many other | 3 种 |
| 俄语 / 波兰语 | one few many other | 4 种 |
| 阿拉伯语 | zero one two few many other | 6 种 |
「拼字符串」的做法——把 {count} 和一段固定文本简单拼在一起——默认全世界都是英语的两档规则。结果是:俄语里 21 本该用 сообщение、22 本该用 сообщения(尾数 1 和 2 属于不同复数类别),可拼字符串只会套同一种词形、把两者压平;阿拉伯语丢了「双数」和「零」的专用说法,中文平白多了个不该有的 s。玩家未必说得出哪里错,但一眼就觉得「这游戏不是给我做的」。占位符与变量类的翻车我们在游戏本地化自动化 QA 检查清单里系统盘过,而复数是其中最难靠肉眼发现、又最伤沉浸感的一类。
ICU MessageFormat Plural 是什么
ICU MessageFormat 是本地化行业的事实标准,它用一段结构化语法把「按数字选词形」这件事交给运行时,而不是交给字符串拼接。一条英语源文本长这样:
{count, plural, one {You have # new message} other {You have # new messages}}
# 是数字占位符,one / other 是 CLDR 类别。渲染时,运行时(我们前端用的是 next-intl,底层就是浏览器原生的 Intl.PluralRules)会按当前语言和 count 的实际值挑出正确的分支。翻译成俄语时,这条消息会展开成 4 个分支;翻成中文时塌缩成 1 个;翻成阿拉伯语时展开成 6 个——每种语言都拿到它语法上真正需要的那几档,一个不多一个不少。
这套机制的好处是:复数正确性从「靠译员和 QA 人肉盯」变成了「由数据和运行时保证」。但它有个现实门槛——没人愿意手写这段花括号。
手写 ICU 的门槛,和我们怎么绕开它
{count, plural, one {…} few {…} many {…} other {…}} 这种语法,让译员甚至开发者手写,几乎必错:少一个右括号、把 # 写成 {count}、漏掉某个必需类别,都会让 next-intl 在运行时直接抛错或退化。所以我们没有把 ICU 语法丢给人,而是做了一个结构化复数编辑器:
- 按源语言自动决定要填几个框。编辑器读取
Intl.PluralRules——和运行时选分支用的是同一个引擎——源语言是中文就只给 1 个other框,是俄语就给 4 个,阿拉伯语给 6 个。编辑器和运行时永远不会对「这门语言有几档」产生分歧。 - 每个框都带真实示例数字。
one框下面标着「1, 21, 31…」,few框标着「2, 3, 4…」,作者一眼就知道这一档管的是哪些数。 - 纯文本可以一键升级成复数。已经写好的
已选 {count} 种语言能直接「提升」为复数骨架,不用推倒重来。 - 实时预览 + 保存即校验。编辑时下方实时拼出完整 ICU;保存时用 next-intl 同款的
@formatjs解析器校验一遍,语法坏了根本存不进去——坏复数进不了源文本,也就不会流到翻译管线和运行时。
作者只管在带标签的框里填自然语言,ICU 由编辑器在保存时组装。源侧结构对了,后面每种目标语言才有正确展开的基础。 这就是它在产品里的样子——一个带示例数字的输入框,下方实时拼出完整 ICU:

一条源复数,自动展开成每种语言的复数
源文本结构化之后,剩下的交给 AI 翻译管线。它不会把整条 ICU 当普通字符串扔给模型翻——那正是复数被「翻塌」的经典事故(整句被剥成一个裸 {count})。管线做的是按目标语言各自的 CLDR 类别集展开:
- 源是中文(1 档)译成俄语时,管线知道俄语需要
one/few/many/other四档,让模型分别产出,而不是复制一份了事; - 源本身就是多档(比如俄语源),译成中文时正确塌缩成 1 档、译成阿拉伯语时正确重排成 6 档——多档源到多档目标的展开我们已实测校验过;
- 法语、西班牙语、葡萄牙语这类罗曼语族,CLDR 近年新增了
many类别(用于大数与紧凑写法),管线也会补齐,不会漏档。
在产品里就是这样:一条只有 other 一档的中文源「你有 {count} 条新消息」,翻成俄语后结果列自动展开成 one / few / many / other 四档,源侧一条、目标侧自动对齐各语言语法,AI 评分 100/100:

也就是说:作者只维护一条源复数,几十种语言各自语法正确的复数由管线负责生成。这套「先把游戏语境和结构喂给模型、再谈翻译」的思路,和我们在AI 看不懂游戏里讲的是同一条主线——复数只是其中最结构化、最能被机器保证的一环。
两道闸门:让翻错的复数进不了译文
复数即便展开对了,也可能在翻译途中被弄坏——最常见的是撇号转义丢失('{0}' 被译成带引号的活参数)或某档分支被漏译。所以我们在翻译管线上设了自动闸门:
- 复数对齐校验。每条目标译文都会核对它的复数类别集是否和该语言的 CLDR 要求一致;缺档、串档、把复数翻塌成裸占位符的,会被打回重译,而不是静默放行。
- 转义与占位符护栏。针对 ICU 撇号转义丢失、占位符被顺手翻译等活参数事故做专门拦截,避免运行时报错或显示成乱码。
复数错误不像错别字,通常要到「真实语言 + 真实数字」下才暴露,特别适合游戏内实时修复兜底:真被玩家撞见了,也能定位到具体字符串、分钟级改掉,不必等下一个版本。但更省事的是让它在源头就对——这正是上面这套编辑器加自动质检要做的事。
结论
复数是本地化里最典型的「英语直觉害人」问题:加个 s 的心智模型对英语成立,对俄语、阿拉伯语、中文全不成立。解决它的正确姿势不是让译员手写花括号,而是把「按语言选词形」交还给标准(ICU MessageFormat Plural)和运行时(Intl.PluralRules),再用结构化编辑器降低作者门槛、用管线自动展开各语言、用校验闸门挡住翻坏的分支。一句可执行的建议:从今天起,凡是带数字的 UI 文案,源侧就按复数结构写,别再拼 {count} 个 这种字符串——省下的是几十种语言逐一返工的成本。