5.3 文案膨胀与 UI 适配

同一句「确认」,中文两个字、德语十个字母、俄语更长。文案膨胀是出海 UI 最容易翻车的地方:按钮撑破、标签换行、数字溢出。这一节用一份 TypeScript 内核做设计期估算,再把它内联进单文件 HTML,在无头浏览器里真实测量哪国文案撑破容器,并用 Playwright 断言结果。

前两节把键设计好、把包加载对了,这一节处理出海 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 文化禁忌与符号审查 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制