前两节把键设计好、把包加载对了,这一节处理出海 UI 最容易翻车的地方:同一条文案在不同语言里长度不一样。站内《独立游戏多语言本地化指南》 在流程层面提醒过「译文长度会变、要留白」,本节把它落成可跑、可断言的工程:一份设计期估算内核,加一个在真实浏览器里测量溢出的可玩原型。上一节5.2 语言包加载、回退与体积 算的是「包多大」,这一节算的是「字多宽」——一个压包体、一个压排版,都是多语言管线上绕不开的量化。
文案长度不是常数
中文是信息密度最高的书写系统之一:两个字能说完的意思,英文要六七个字母,德文可能拼出一个长复合词。这带来一个反直觉的事实——基准语言(往往是中文)的界面,在别的语言下会「膨胀」。
- 按钮「确认」:中文 2 字,英文
Confirm,德文Bestätigen,俄文Подтвердить。 - 同一段提示语,英文通常比中文长 30%
40%,德文能长到 1.52 倍。 - 数字与日期(第 6 章展开)还会再叠一层格式差异。
如果 UI 是按中文稿排的固定宽度按钮,德文一上就撑破。所以文案膨胀必须在设计期就被量化,而不是等母语用户截图投诉。
膨胀率:同一句话,五种长度
我们给每种语言一个相对基准的膨胀率(expansionPermille):基准语言是 1000‰,英文 1400‰,日文 1900‰,德文 2400‰,俄文 3000‰。再配一个基准宽度(中文「确认」在 16px 下约 32px),就能估算每种语言的文案宽度,并判断它是否超过容器宽度。这当然是个粗模型——它不含字体度量,也不区分 CJK 与拉丁字形的实际宽窄——但它的价值在于在写完 UI 之前就把「哪些语言会撑破」标出来。
P 层内核:设计期估算
下面这段是 P 层(内核层)代码 core/src/g05/textfit.ts。它读容器宽度、基准宽度与各语言膨胀率,估算宽度并判断溢出。全整数、无浮点。
// file: core/src/g05/textfit.ts
// tier: P
// verify: node --test 'core/test/g05_textfit.test.ts'
//
// 文案膨胀与 UI 适配内核:给定容器宽度与各语言相对基准的膨胀率,估算文案宽度并
// 判断是否溢出容器。这是**设计期**的整数估算模型(不含字体度量),用来在写完 UI
// 之前先把「哪些语言会撑破按钮」标出来;真实排版宽度由 W 层在浏览器里实测。
// 全整数、无浮点、无 Math.random(只用可擦除语法,禁 enum / namespace)。
export interface Label {
readonly locale: string;
readonly text: string;
readonly expansionPermille: number;
}
export interface TextFitConfig {
readonly baseLocale: string;
readonly containerWidthPx: number;
readonly fontSizePx: number;
readonly baseWidthPx: number;
readonly labels: readonly Label[];
}
export interface FitRow {
readonly locale: string;
readonly expansionPermille: number;
readonly estWidthPx: number;
readonly overflow: boolean;
}
/** 估算宽度:基准宽度 × 膨胀率(千分比),整数地板除。 */
export function estWidthPx(cfg: TextFitConfig, label: Label): number {
return Math.floor((cfg.baseWidthPx * label.expansionPermille) / 1000);
}
/** 逐语言估算:宽度与是否溢出容器。 */
export function fit(cfg: TextFitConfig): FitRow[] {
return cfg.labels.map((l) => {
const w = estWidthPx(cfg, l);
return { locale: l.locale, expansionPermille: l.expansionPermille, estWidthPx: w,
overflow: w > cfg.containerWidthPx };
});
}
/** 溢出语言列表(按 config 顺序)。 */
export function overflowing(cfg: TextFitConfig): string[] {
return fit(cfg).filter((r) => r.overflow).map((r) => r.locale);
}
/** 摘要:与浏览器侧实测结果逐字符比对的确定性指纹。 */
export function digest(cfg: TextFitConfig): string {
return fit(cfg).map((r) => `${r.locale}:${r.estWidthPx}:${r.overflow ? 1 : 0}`).join("|");
}
参数放在 config/g05_textfit.json:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "文案膨胀与 UI 适配模型。口径是「公开可观察机制」的建模假设,不是任何真机字体的实测。baseLocale=基准语言;containerWidthPx=按钮/标签容器宽度(像素);fontSizePx=字号;baseWidthPx=基准文案在基准字号下的估算宽度(像素);labels=各语言的样例文案与相对基准的膨胀率(千分比,基准=1000)。膨胀率与基准宽度是设计期估算,真实排版宽度由字体与渲染引擎决定,须在真实浏览器里量(W 层)。全整数,无浮点。",
"baseLocale": "zh-CN",
"containerWidthPx": 72,
"fontSizePx": 16,
"baseWidthPx": 32,
"labels": [
{ "locale": "zh-CN", "text": "确认", "expansionPermille": 1000 },
{ "locale": "en-US", "text": "Confirm", "expansionPermille": 1400 },
{ "locale": "de-DE", "text": "Bestätigen", "expansionPermille": 2400 },
{ "locale": "ja-JP", "text": "確認する", "expansionPermille": 1900 },
{ "locale": "ru-RU", "text": "Подтвердить", "expansionPermille": 3000 }
]
}
W 层原型:浏览器里真实测量
设计期估算终归是估算。把同一份内核内联进单文件 HTML,就能在真实浏览器里渲染每种语言的文案、量它的实际宽度、判断它是否撑破 72px 容器。web/build_g05_text_fit.mjs 复用 web/pack.mjs,把 core/src/g05/textfit.ts 内联进单文件 HTML:
#!/usr/bin/env node
// file: web/build_g05_text_fit.mjs
// tier: W
// verify: node web/build_g05_text_fit.mjs
// 把 core/src/g05/textfit.ts 打成单文件 HTML:文案膨胀与 UI 适配原型。产物 web/dist/g05.html。
// 同一段按钮文案按各语言的膨胀率渲染进固定宽度容器,浏览器里真实测量是否溢出。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "g05_textfit.json"), "utf8"));
const js = bundle(ROOT, ["core/src/g05/textfit.ts"]);
const CONS = inlineConfig(cfg);
const boxes = cfg.labels.map((l) =>
` <div class="card"><div class="k">${l.locale} · 膨胀 ${l.expansionPermille / 10}%</div>
<div class="box" style="width:${cfg.containerWidthPx}px"><span class="lbl" id="lbl-${l.locale}">${l.text}</span></div></div>`).join("\n");
const script = `const C = ${CONS};
${js}
await document.fonts.ready;
const $ = (id) => document.getElementById(id);
const rows = C.labels.map((l) => {
const el = document.getElementById("lbl-" + l.locale);
const realWidthPx = Math.round(el.getBoundingClientRect().width);
const est = estWidthPx(C, l);
return { locale: l.locale, text: l.text, expansionPermille: l.expansionPermille,
estWidthPx: est, overflowEst: est > C.containerWidthPx,
realWidthPx, overflowReal: realWidthPx > C.containerWidthPx };
});
const cv = $("cv"); cv.width = 720; cv.height = 24 + rows.length * 34;
const ctx = cv.getContext("2d");
ctx.fillStyle = "#111827"; ctx.fillRect(0, 0, cv.width, cv.height);
const maxW = Math.max(...rows.map((r) => r.realWidthPx)) || 1;
rows.forEach((r, i) => {
const y = 16 + i * 34, w = Math.max(1, Math.round(520 * r.realWidthPx / maxW));
ctx.fillStyle = r.overflowReal ? "#ef4444" : "#38bdf8"; ctx.fillRect(150, y, w, 20);
ctx.fillStyle = "#e2e8f0"; ctx.font = "12px sans-serif";
ctx.fillText(r.locale + " " + r.realWidthPx + "px" + (r.overflowReal ? " 溢出" : ""), 16, y + 15);
ctx.strokeStyle = "#64748b"; ctx.beginPath(); ctx.moveTo(150 + Math.round(520 * C.containerWidthPx / maxW), 12);
ctx.lineTo(150 + Math.round(520 * C.containerWidthPx / maxW), cv.height - 4); ctx.stroke();
});
globalThis.__pebble = { containerWidthPx: C.containerWidthPx, rows, digest: digest(C), ready: true };
`;
const css = `.box { display: inline-block; height: 28px; line-height: 28px; border: 1px solid #334155; border-radius: 6px; overflow: hidden; white-space: nowrap; }
.lbl { display: inline-block; white-space: nowrap; }`;
const body = ` <h1>《合成小岛》· 文案膨胀与 UI 适配</h1>
<div class="sub">复用 core/src/g05/textfit.ts:固定 ${cfg.containerWidthPx}px 容器,看哪国文案撑破按钮</div>
${boxes}
<div class="card"><div class="k">浏览器实测宽度(px,灰线=容器宽度 ${cfg.containerWidthPx}px)</div>
<canvas id="cv"></canvas></div>`;
const bytes = writePage(join(ROOT, "web/dist/g05.html"), page({ title: "《合成小岛》文案膨胀与 UI 适配 · g05", body, script, css }));
console.log(JSON.stringify({ html: "web/dist/g05.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
web/verify_g05_text_fit.mjs 用 Playwright 无头打开产物、量每种语言的实际宽度、断言溢出集合与设计期估算一致,并把结果截到 out/shots/。它先开一个预热页,规避冷启动首个页面测量与 canvas 像素恒空的抖动;canvas 断言放宽为 >= 1 px,不写死严格相等:
#!/usr/bin/env node
// file: web/verify_g05_text_fit.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_g05_text_fit.mjs web/dist/g05.html
// W 层验收:Playwright 无头打开原型,真实测量各语言文案宽度,断言溢出集合与 P 层估算一致。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/g05.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
// 冷启动首个页面字体/光栅可能未就绪:先开一个预热页,避免首个页面测量或 canvas 像素恒空。
const warm = await browser.newPage(); await warm.goto(url); await warm.close();
const errors = []; const page = await browser.newPage({ viewport: { width: 820, height: 900 } });
page.on("pageerror", (e) => errors.push(String(e))); await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.ready === true, null, { timeout: 15000 });
const state = await page.evaluate(() => globalThis.__pebble);
const shot = join(ROOT, "out/shots/g05_text_fit.png");
await page.screenshot({ path: shot, fullPage: true });
// 逐语言真实宽度与溢出标志(重试一次,规避冷启动测量抖动)。
const measure = () => page.evaluate(() => {
const out = [];
for (const r of globalThis.__pebble.rows) {
const el = document.getElementById("lbl-" + r.locale);
const w = Math.round(el.getBoundingClientRect().width);
out.push({ locale: r.locale, realWidthPx: w, overflowReal: w > globalThis.__pebble.containerWidthPx });
}
return out;
});
let measured = await measure();
if (measured.some((m) => m.realWidthPx === 0)) measured = await measure();
// canvas 非背景像素(#111827 为底),放宽为 >= 1。
const drawn = await page.evaluate(() => {
const cv = document.getElementById("cv"); const ctx = cv.getContext("2d");
const d = ctx.getImageData(0, 0, cv.width, cv.height).data; let n = 0;
for (let i = 0; i < d.length; i += 4) if (!(d[i] === 17 && d[i + 1] === 24 && d[i + 2] === 39)) n += 1;
return n;
});
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
const byLocale = Object.fromEntries(measured.map((m) => [m.locale, m]));
const overflowReal = measured.filter((m) => m.overflowReal).map((m) => m.locale);
// 真实排版与设计期估算一致:基准语言安全,德语与俄语撑破 72px 容器。
assert.equal(byLocale["zh-CN"].overflowReal, false, "基准语言不应溢出");
assert.equal(byLocale["de-DE"].overflowReal, true, "德语长词应溢出");
assert.equal(byLocale["ru-RU"].overflowReal, true, "俄语长词应溢出");
assert.deepEqual(overflowReal, ["de-DE", "ru-RU"], "实测溢出集合应与 P 层估算一致");
assert.ok(drawn >= 1, `canvas 应有绘制内容,实测 ${drawn} px`);
console.log(JSON.stringify({ page: "web/dist/g05.html", containerWidthPx: state.containerWidthPx,
est: state.rows.map((r) => [r.locale, r.estWidthPx, r.overflowEst]),
real: measured.map((m) => [m.locale, m.realWidthPx, m.overflowReal]),
overflowReal, canvasPx: drawn, screenshot: "out/shots/g05_text_fit.png" }, null, 2));
console.log("SMOKE-W-g05: PASS");
实测结果:估算 vs 真实
在本机跑 node web/build_g05_text_fit.mjs 产出 web/dist/g05.html,再用 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_g05_text_fit.mjs web/dist/g05.html 验收,输出如下(同机相对量级,绝对数值不可移植):
est: zh-CN 32 / en-US 44 / de-DE 76 / ja-JP 60 / ru-RU 96 (设计期估算,容器 72px)
real: zh-CN 32 / en-US 58 / de-DE 77 / ja-JP 64 / ru-RU 99 (浏览器实测)
overflowReal = ["de-DE", "ru-RU"] canvas 已绘制(断言 >= 1 px)
SMOKE-W-g05: PASS
两点值得看。溢出集合完全一致:设计期估算说 de-DE(76 > 72)与 ru-RU(96 > 72)溢出,浏览器实测(77、99)也正好是这两个——估算的溢出判断在容器边界附近是可靠的。但绝对值有偏差:en-US 估算 44px、实测 58px,ja-JP 估算 60px、实测 64px。这是因为粗模型按「基准宽度 × 膨胀率」线性放大,而拉丁字母实际比这个线性模型宽、CJK 比它窄。估算用来定「谁会溢出」的边界,实测用来定「差多少」的尺寸——两者配合,才是完整的适配流程。
自检表
| 检查项 | 合格标准 |
|---|---|
| 有膨胀率 | 每种语言相对基准的膨胀率进 config,不靠感觉 |
| 估算可复算 | 宽度与溢出标志逐位可复算,无浮点 |
| 浏览器实测 | 单文件 HTML 里真实测量,不只停在估算 |
| 溢出可断言 | 溢出集合被 Playwright 断言,不靠肉眼看图 |
| 截图留证 | 验收截图落盘,结果可回看 |
| 边界清晰 | 真实字体度量随设备变化,标 X,不写成「实测」 |
X 层:真实字体度量不可复现
边界必须划清。上面量的是原型环境里的宽度:给定字号与系统字体,浏览器怎么排。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真机字体度量(不同设备预装字体不同,同一段德文在 A 机与 B 机的换行位置可能不一样);
- 系统字号缩放与无障碍设置(用户把字号调大 150%,所有宽度都会变);
- 文本断行与连字符规则(德文长复合词是否允许断行、在哪里断,随排版引擎变化)。
工程上正确的做法是:把可复现的溢出边界与不可复现的真机排版分开——原型用来回答「哪些语言危险、要留多少余量」,真机排版靠伪本地化 + 真机截图兜底(第 7 章会专门讲伪本地化与溢出检测)。这一章到这里收口:键设计好了、包加载对了、文案也量过了,下一章把视角从「文字怎么放」转到「文化怎么合」——禁忌、符号与美术本地化。
阅读导航:上一节:5.2 语言包加载、回退与体积 · 下一节:6.1 文化禁忌与符号审查 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。