站内那篇独立游戏全球财务与多币种架构 讲的是钱怎么收、怎么结、怎么合规——收款通道、多币种账户、税务与分成。本节不碰那些,只管一件更小的事:同一笔钱、同一个日期,在不同地区该怎么写出来。这是「翻译之外」的第二层本地化:文字全对,格式全错,玩家一样读不懂,甚至会把价格读成另一个数。
上一节的游戏本地化与多语言指南 解决的是「说什么语言」,本节解决的是「用什么格式写」。
格式是翻译之外的第二层
一个真实事故的骨架:商店页写 07/04/2026,美区玩家读成 7 月 4 日,欧区玩家读成 4 月 7 日;同一页面写 1,234.56,德区玩家读成一千二百三十四点五六,还是读成 1.23456?标点符号的语义在地区之间是互换的。日期、数字、货币、方向这四个轴,每一个都有「看似通用、实则局部」的默认值。
| 轴 | 变化的量 | 踩坑示例 |
|---|---|---|
| 日期 | 顺序 MDY/DMY/YMD、分隔符 | 07/04/2026 与 2026/01/09 |
| 数字分组 | 最右组位数、其余组位数 | 印度 1,23,45,678(3+2),其余 12,345,678 |
| 小数点 / 千分位 | 两个分隔符互换 | 德区 1.234,56 |
| 货币 | 最小单位、符号位置、空格 | 日元/韩元没有小数位:¥193,825 |
| 方向 | dir=rtl 时整段右起 | 阿语段落里的数字串会被双向算法重排 |
一张可机读的 locale 表
把上述五个轴写成纯数据。groupFirst 是最右一组的位数,groupRest 是其余每组的位数(印度是 3+2);currency.minorPerUnit 是「1 个大单位等于多少个最小单位」(日元/韩元为 1,其余为 100);fx.ratePermille 是「1 源币大单位兑多少目标币大单位」的千分比整数:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "日期 / 数字 / 货币 / RTL 格式化表。口径是「公开可观察机制」的建模假设:把各地区公开的书写习惯整理成可机读的 locale 表。dir=ltr/rtl;dateOrder=MDY/DMY/YMD;dateSep/groupSep/decimalSep=分隔符;groupFirst=最右分组位数、groupRest=其余分组位数(印度用 3+2);currency.minorPerUnit=1 元最小单位数(日元/韩元为 1,其余 100);symbolFirst=货币符号是否在数字前;symbolSpace=符号与数字间是否留空格。fx.ratePermille=1 源币大单位兑多少目标币大单位(千分比,整数)。真实汇率逐秒变动、真实地区书写习惯需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"locales": [
{ "id": "en_US", "name": "英语(美国)", "dir": "ltr", "dateOrder": "MDY", "dateSep": "/", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "USD", "minorPerUnit": 100, "symbol": "$", "symbolFirst": true, "symbolSpace": false } },
{ "id": "en_GB", "name": "英语(英国)", "dir": "ltr", "dateOrder": "DMY", "dateSep": "/", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "GBP", "minorPerUnit": 100, "symbol": "£", "symbolFirst": true, "symbolSpace": false } },
{ "id": "de_DE", "name": "德语(德国)", "dir": "ltr", "dateOrder": "DMY", "dateSep": ".", "groupSep": ".", "decimalSep": ",", "groupFirst": 3, "groupRest": 3, "currency": { "code": "EUR", "minorPerUnit": 100, "symbol": "€", "symbolFirst": false, "symbolSpace": true } },
{ "id": "ja_JP", "name": "日语(日本)", "dir": "ltr", "dateOrder": "YMD", "dateSep": "/", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "JPY", "minorPerUnit": 1, "symbol": "¥", "symbolFirst": true, "symbolSpace": false } },
{ "id": "ko_KR", "name": "韩语(韩国)", "dir": "ltr", "dateOrder": "YMD", "dateSep": ".", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "KRW", "minorPerUnit": 1, "symbol": "₩", "symbolFirst": true, "symbolSpace": false } },
{ "id": "in_IN", "name": "印地语(印度)", "dir": "ltr", "dateOrder": "DMY", "dateSep": "/", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 2, "currency": { "code": "INR", "minorPerUnit": 100, "symbol": "₹", "symbolFirst": true, "symbolSpace": false } },
{ "id": "ar_SA", "name": "阿拉伯语(沙特)", "dir": "rtl", "dateOrder": "DMY", "dateSep": "/", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "SAR", "minorPerUnit": 100, "symbol": "ر.س", "symbolFirst": false, "symbolSpace": true } },
{ "id": "he_IL", "name": "希伯来语(以色列)", "dir": "rtl", "dateOrder": "DMY", "dateSep": ".", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "ILS", "minorPerUnit": 100, "symbol": "₪", "symbolFirst": true, "symbolSpace": false } }
],
"fx": [
{ "from": "USD", "to": "JPY", "ratePermille": 157000 },
{ "from": "GBP", "to": "EUR", "ratePermille": 1170 },
{ "from": "EUR", "to": "USD", "ratePermille": 1080 },
{ "from": "JPY", "to": "KRW", "ratePermille": 8600 },
{ "from": "KRW", "to": "JPY", "ratePermille": 116 },
{ "from": "INR", "to": "USD", "ratePermille": 12 },
{ "from": "SAR", "to": "USD", "ratePermille": 267 },
{ "from": "ILS", "to": "USD", "ratePermille": 270 }
],
"samples": [
{ "id": "us", "locale": "en_US", "amountMinor": 123456, "y": 2026, "m": 7, "d": 4, "convertTo": "ja_JP" },
{ "id": "uk", "locale": "en_GB", "amountMinor": 99999, "y": 2026, "m": 12, "d": 31, "convertTo": "de_DE" },
{ "id": "de", "locale": "de_DE", "amountMinor": 123456, "y": 2026, "m": 3, "d": 1, "convertTo": "en_US" },
{ "id": "jp", "locale": "ja_JP", "amountMinor": 193825, "y": 2026, "m": 1, "d": 9, "convertTo": "ko_KR" },
{ "id": "kr", "locale": "ko_KR", "amountMinor": 45000, "y": 2026, "m": 5, "d": 5, "convertTo": "ja_JP" },
{ "id": "in", "locale": "in_IN", "amountMinor": 12345678, "y": 2026, "m": 8, "d": 15, "convertTo": "en_US" },
{ "id": "sa", "locale": "ar_SA", "amountMinor": 98765, "y": 2026, "m": 2, "d": 14, "convertTo": "en_US" },
{ "id": "il", "locale": "he_IL", "amountMinor": 5000, "y": 2026, "m": 9, "d": 3, "convertTo": "en_US" }
]
}
P 层:纯整数的格式化内核
核心只有一条纪律:金额一律用「最小单位整数」承载,汇率一律用「千分比整数」承载。分组用字符串切分而非 toLocaleString(后者依赖运行时 ICU 数据,跨机器会漂),换算用整数乘除加地板除,绝不出现浮点中间值:
// file: core/src/g06/format.ts
// tier: P
// verify: node --test 'core/test/g06_*.test.ts'
//
// 日期 / 数字 / 货币 / RTL 格式化内核:与 sim/g06_format_ref.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g06_format.json;全整数、地板除、无浮点、无随机、禁 Math.random。
// 金额一律用「最小单位整数」承载,汇率用「千分比整数」承载,杜绝浮点误差。
export interface Currency {
readonly code: string; readonly minorPerUnit: number; readonly symbol: string;
readonly symbolFirst: boolean; readonly symbolSpace: boolean;
}
export interface Locale {
readonly id: string; readonly name: string; readonly dir: string;
readonly dateOrder: string; readonly dateSep: string; readonly groupSep: string;
readonly decimalSep: string; readonly groupFirst: number; readonly groupRest: number;
readonly currency: Currency;
}
export interface Fx { readonly from: string; readonly to: string; readonly ratePermille: number }
export interface Sample {
readonly id: string; readonly locale: string; readonly amountMinor: number;
readonly y: number; readonly m: number; readonly d: number; readonly convertTo: string;
}
export interface FormatConfig {
readonly version: number; readonly game: string;
readonly locales: readonly Locale[]; readonly fx: readonly Fx[]; readonly samples: readonly Sample[];
}
export const LRI = "\u2066"; // LTR isolate
export const PDI = "\u2069"; // pop directional isolate
/** 从右往左分组:最右一组 first 位,其余每组 rest 位。 */
export function groupDigits(digits: string, sep: string, first: number, rest: number): string {
if (digits.length <= first) return digits;
const head = digits.slice(0, digits.length - first);
const tail = digits.slice(digits.length - first);
const groups: string[] = [];
let h = head;
while (h.length > rest) { groups.push(h.slice(h.length - rest)); h = h.slice(0, h.length - rest); }
if (h.length > 0) groups.push(h);
groups.reverse();
return [...groups, tail].join(sep);
}
function pad(n: number, width: number): string {
return String(n).padStart(width, "0");
}
/** 按 locale 格式化金额(最小单位整数 → 展示串)。 */
export function formatAmount(amountMinor: number, loc: Locale): string {
const per = loc.currency.minorPerUnit;
const major = Math.floor(amountMinor / per);
const minor = amountMinor % per;
let num = groupDigits(String(major), loc.groupSep, loc.groupFirst, loc.groupRest);
if (per > 1) {
const width = String(per).length - 1;
num = `${num}${loc.decimalSep}${pad(minor, width)}`;
}
const gap = loc.currency.symbolSpace ? " " : "";
const sym = loc.currency.symbol;
return loc.currency.symbolFirst ? `${sym}${gap}${num}` : `${num}${gap}${sym}`;
}
/** 按 locale 的 dateOrder(MDY/DMY/YMD) 重排日期、补零、按 dateSep 连接。 */
export function formatDate(y: number, m: number, d: number, loc: Locale): string {
const parts: Record<string, string> = { M: pad(m, 2), D: pad(d, 2), Y: pad(y, 4) };
return loc.dateOrder.split("").map((c) => parts[c]).join(loc.dateSep);
}
/** 跨币种换算,落到目标币种最小单位;全整数地板除,无浮点。 */
export function convertMinor(amountMinor: number, src: Locale, dst: Locale, ratePermille: number): number {
return Math.floor((amountMinor * ratePermille * dst.currency.minorPerUnit)
/ (1000 * src.currency.minorPerUnit));
}
/** RTL 段落里嵌 LTR 数字串:用 LRI…PDI 隔离,防止双向算法重排。 */
export function isolateLtr(text: string): string {
return `${LRI}${text}${PDI}`;
}
export function dirOf(loc: Locale): string {
return loc.dir;
}
function rateOf(cfg: FormatConfig, from: string, to: string): number {
const fx = cfg.fx.find((f) => f.from === from && f.to === to);
return fx === undefined ? 0 : fx.ratePermille;
}
/** 跨语言对拍摘要:与 sim/g06_format_ref.py 的 digest 逐字符一致。 */
export function digest(cfg: FormatConfig): string {
const byId = new Map(cfg.locales.map((l) => [l.id, l]));
return cfg.samples.map((s) => {
const loc = byId.get(s.locale)!;
const tgt = byId.get(s.convertTo)!;
const conv = convertMinor(s.amountMinor, loc, tgt,
rateOf(cfg, loc.currency.code, tgt.currency.code));
return `${loc.id}:${formatAmount(s.amountMinor, loc)}:${formatDate(s.y, s.m, s.d, loc)}`
+ `:${formatAmount(conv, tgt)}`;
}).join(";");
}
硬断言与跨语言对拍
测试不测「看起来像不像」,测的是跨语言逐字符一致:TS 内核复算的摘要必须等于 Python 参考实现 sim/g06_format_ref.py 落盘的金标准,外加分组、无小数币种、符号位置、日期重排、整数换算与 RTL 隔离六项定点断言:
// file: core/test/g06_format.test.ts
// tier: P
// verify: node --test 'core/test/g06_*.test.ts'
//
// 硬断言:TS 格式化内核复算的样本摘要,与 Python 侧 sim/g06_format_ref.py 落盘的金标准
// 逐字符一致;并验分组(含印度 3+2)、无小数币种、符号位置、RTL 隔离与整数换算。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { digest, formatAmount, formatDate, convertMinor, groupDigits, isolateLtr,
dirOf, LRI, PDI, type FormatConfig, type Locale } from "../src/g06/format.ts";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "g06_format.json"), "utf8")) as FormatConfig;
const loc = (id: string): Locale => cfg.locales.find((l) => l.id === id)!;
// Python 侧同机实跑落盘的金标准摘要
const EXPECTED = "en_US:$1,234.56:07/04/2026:¥193,825;en_GB:£999.99:31/12/2026:1.169,98 €;"
+ "de_DE:1.234,56 €:01.03.2026:$1,333.32;ja_JP:¥193,825:2026/01/09:₩1,666,895;"
+ "ko_KR:₩45,000:2026.05.05:¥5,220;in_IN:₹1,23,456.78:15/08/2026:$1,481.48;"
+ "ar_SA:987.65 ر.س:14/02/2026:$263.70;he_IL:₪50.00:03.09.2026:$13.50";
test("跨语言一致:TS 格式化摘要 == Python 金标准", () => {
assert.equal(digest(cfg), EXPECTED);
});
test("分组:西式 3 位一隔 vs 印度 3+2 式", () => {
assert.equal(groupDigits("12345678", ",", 3, 3), "12,345,678");
assert.equal(groupDigits("12345678", ",", 3, 2), "1,23,45,678");
});
test("无小数币种:日元 / 韩元不带小数位", () => {
assert.equal(formatAmount(193825, loc("ja_JP")), "¥193,825");
assert.equal(formatAmount(45000, loc("ko_KR")), "₩45,000");
});
test("符号位置:德语符号在后带空格,阿拉伯语同理", () => {
assert.equal(formatAmount(123456, loc("de_DE")), "1.234,56 €");
assert.equal(formatAmount(98765, loc("ar_SA")), "987.65 ر.س");
assert.equal(formatAmount(123456, loc("en_US")), "$1,234.56");
});
test("日期重排:MDY / DMY / YMD 三种顺序", () => {
assert.equal(formatDate(2026, 7, 4, loc("en_US")), "07/04/2026");
assert.equal(formatDate(2026, 7, 4, loc("en_GB")), "04/07/2026");
assert.equal(formatDate(2026, 1, 9, loc("ja_JP")), "2026/01/09");
assert.equal(formatDate(2026, 5, 5, loc("ko_KR")), "2026.05.05");
});
test("整数换算:USD 分 → JPY 円,地板除无浮点", () => {
const usd = loc("en_US"); const jpy = loc("ja_JP");
assert.equal(convertMinor(123456, usd, jpy, 157000), 193825);
assert.equal(formatAmount(convertMinor(123456, usd, jpy, 157000), jpy), "¥193,825");
});
test("RTL:两个 rtl locale,且数字串用 LRI…PDI 隔离", () => {
assert.equal(dirOf(loc("ar_SA")), "rtl");
assert.equal(dirOf(loc("he_IL")), "rtl");
assert.equal(dirOf(loc("en_US")), "ltr");
const wrapped = isolateLtr("¥1,234");
assert.equal(wrapped, `${LRI}¥1,234${PDI}`);
assert.equal(wrapped.codePointAt(0), 0x2066);
assert.equal(wrapped.codePointAt(wrapped.length - 1), 0x2069);
});
同机实跑(Node 26,strip-only 直跑 .ts):
$ node --test core/test/g06_format.test.ts
✔ 跨语言一致:TS 格式化摘要 == Python 金标准
✔ 分组:西式 3 位一隔 vs 印度 3+2 式
✔ 无小数币种:日元 / 韩元不带小数位
✔ 符号位置:德语符号在后带空格,阿拉伯语同理
✔ 日期重排:MDY / DMY / YMD 三种顺序
✔ 整数换算:USD 分 → JPY 円,地板除无浮点
✔ RTL:两个 rtl locale,且数字串用 LRI…PDI 隔离
ℹ tests 7
ℹ pass 7
ℹ fail 0
Python 参考实现(sim/g06_format_ref.py)对同一份配置产出的金标准摘要,逐字段格式为 locale:金额:日期:换算后金额:
en_US:$1,234.56:07/04/2026:¥193,825;en_GB:£999.99:31/12/2026:1.169,98 €;de_DE:1.234,56 €:01.03.2026:$1,333.32;ja_JP:¥193,825:2026/01/09:₩1,666,895;ko_KR:₩45,000:2026.05.05:¥5,220;in_IN:₹1,23,456.78:15/08/2026:$1,481.48;ar_SA:987.65 ر.س:14/02/2026:$263.70;he_IL:₪50.00:03.09.2026:$13.50
为什么必须纯整数
看一行真实输出就够了:1.15 * 100 在 IEEE-754 双精度下等于 114.99999999999999,Math.floor 之后是 114,而不是 115。也就是说,只要价格在系统里以「元」为单位的浮点数流转一次,1.15 元 就会在转成「分」时凭空少 1 分。这不是理论洁癖:0.1 + 0.2 在同样的表示下等于 0.30000000000000004。本节的做法是从入口就用最小单位整数(123456 表示 1234.56 元),汇率用千分比整数(157000 表示 157.000),换算全程整数乘除后地板除一次——没有任何中间浮点,结果与语言、平台、CPU 无关。
RTL 不只是「右对齐」
dir=rtl 只解决段落方向。真正的坑在于双向文本算法:一段阿语里嵌了一串 LTR 数字(价格、日期、ID),如果不用 LRI(U+2066,左到右隔离)与 PDI(U+2069,弹出隔离)把数字串包起来,数字内部的符号、负号、括号可能被算法重排到视觉上错误的位置——玩家看到的 $1,234 会变成 1234$ 之类。内核里 isolateLtr() 就干这一件事,测试里用 codePointAt 直接断言首尾确实是 U+2066 / U+2069,而不是看起来像隔离符的普通字符。表里的 ar_SA 与 he_IL 两个 locale 都标 rtl,dirOf() 把它们与其余六个 ltr 区分开。
这些数不能靠命令行复现(X)
locale 表与汇率表是**「公开可观察机制」的建模假设**,不是权威数据源:真实汇率逐秒变动,ratePermille 只是把某个观察时点(observedAt: 2026-10)的近似关系冻结成整数;真实地区书写习惯、平台支付渠道的最小单位与取整规则、以及真机上字体的缺字与断行表现,都需要真实业务环境与真实设备,命令行无法复现,标为 X。真正能复现的,是给定这张表之后的格式化与换算结果——那部分本节的 TS 内核与 Python 参考实现逐字符一致。
自检
| 检查项 | 本节结论 |
|---|---|
| 是否用浮点承载金额 | 否,全程最小单位整数;1.15*100 反例已实测 |
| 汇率是否整数 | 是,千分比整数,换算后地板除一次 |
| 是否依赖运行时 ICU | 否,分组为字符串切分,跨机器稳定 |
| RTL 是否只做右对齐 | 否,数字串用 U+2066/U+2069 隔离并断言 |
| 跨语言是否一致 | 是,TS 摘要 == Python 金标准,7/7 通过 |
本节把「格式」从「翻译的附属品」变成了一张可机读、可对拍的 locale 表。下一节进入 7.1,我们用伪本地化在开发期就把溢出与截断问题逼出来,而不是等上线后靠玩家截图。
阅读导航:上一节:6.2 美术与素材的本地化变体 · 下一节:7.1 伪本地化与溢出检测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。