站内的 Godot 本地化与字体实现 解决的是引擎侧怎么切语言、怎么处理字形——一份译文进来,怎么绑到某个 locale、怎么让字体覆盖到那些字形。本章与它划界明确:本章做的是文本表与键的一致性——键有没有漏、变体有没有覆盖、复数形式有没有缺;本章不评价译文质量:一段翻译准不准、顺不顺,既不在本套范围内,也需要真实业务环境与母语审校,命令行不可复现。
12.1 文本表与键
卷一把「文本是规格的渲染产物」这句话讲完了:同一条 dialogue 记录,在不同状态下渲染出不同文本。卷二这一章要处理的是它的工程侧:文本既然要外置、要送审、要过翻译,就必须先有键。键是文本表与规格之间唯一的连接点——键在,文本才在;键漏了,那个位置的文本在运行时无处可取。
文本表因此不是一个新数据结构,它就是一张 键 → 文本 的扁平映射。本节要做的三件事:把键的命名规则定死、把规格里全部文本抽成表、把「漏键」与「悬空键」两类不一致变成可数的量。
键命名规则
规格里能出文本的位置只有三处,键因此只有三种形态。命名用点分命名空间:dlg 段管对话节点,cho 段管选项。
| 键的形态 | 取值字段 | 出现条件 | 本规格数量 |
|---|---|---|---|
dlg.<node_id>.line | dialogue[].line | 每个节点恒有 | 48 |
dlg.<node_id>.else | dialogue[].line_else | 仅当声明了 line_else | 1 |
cho.<choice_id>.text | dialogue[].choices[].text | 每个选项恒有 | 115 |
| 合计 | 164 |
第二行是本节最容易漏的一处:line_when 为真时渲染的仍是 line(走 when 分支),不另开一条键;只有 line_else 是独立文本,才需要一条 .else 键。把这点写进规则,是为了让「漏键」成为一个能自动查出来的量,而不是靠人肉对照。
把规则落成一份可校验的文法声明,是本节的 D 层产物:
{
"namespace": "ne_12_1",
"key_forms": [
{ "form": "dlg.<node_id>.line", "source": "dialogue[].line", "arity": "恒有" },
{ "form": "dlg.<node_id>.else", "source": "dialogue[].line_else", "arity": "仅当声明 line_else" },
{ "form": "cho.<choice_id>.text", "source": "dialogue[].choices[].text", "arity": "恒有" }
],
"expected_counts": { "dlg_line": 48, "dlg_else": 1, "cho_text": 115, "total": 164 },
"invariants": [
"键全局唯一",
"每个 dlg.<id>.line 的 id 必须出现在 dialogue[].id 中",
"每个 dlg.<id>.else 的 id 必须有非空 line_else",
"每个 cho.<id>.text 的 id 必须出现在某个 choices[].id 中"
],
"residuals": { "missing_keys": 0, "dangling_keys": 0 }
}
P 层:抽键与检测
core/src/inkwell/ne_12_1_text_table.ts 只做四件事:按规则抽出文本键、拼成表、算出渲染器的引用集、比对两者得到漏键与悬空键。引用集与表同源(都从规格推出),所以正例里两者恒等——这正是要的:抽键规则一旦写对,残差就该是 0;规则写错,残差立刻冒出来。
// file: core/src/inkwell/ne_12_1_text_table.ts
// tier: P
// verify: node --test 'core/test/ne_12_1_*.test.ts'
// 卷二 12.1 文本表与键:从规格 dialogue 段抽出全部文本键,建成「键 -> 文本」的表。
// 键命名规则(本节唯一口径):
// dlg.<node_id>.line 每个对话节点的基础台词(base / when / fallback 都读它)
// dlg.<node_id>.else 仅当节点声明了 line_else 时存在(else 分支)
// cho.<choice_id>.text 每个选项的文本
// 只做文本的**结构**:抽键、计数、漏键 / 悬空键检测。不评价文本内容。
export type TextKey = { key: string; kind: "dlg.line" | "dlg.else" | "cho.text"; id: string; text: string };
export type TextTable = Record<string, string>;
type Node = { id: string; line: string; line_when?: unknown; line_else?: string; choices?: { id: string; text: string }[] };
type Spec = { dialogue: Node[] };
/** 从规格抽出全部文本键(唯一权威口径)。 */
export function extractTextKeys(spec: Spec): TextKey[] {
const rows: TextKey[] = [];
for (const n of spec.dialogue) {
rows.push({ key: `dlg.${n.id}.line`, kind: "dlg.line", id: n.id, text: n.line });
if (typeof n.line_else === "string" && n.line_else.length > 0) {
rows.push({ key: `dlg.${n.id}.else`, kind: "dlg.else", id: n.id, text: n.line_else });
}
for (const c of n.choices ?? []) {
rows.push({ key: `cho.${c.id}.text`, kind: "cho.text", id: c.id, text: c.text });
}
}
return rows;
}
/** 键 -> 文本。 */
export function buildTable(spec: Spec): TextTable {
const t: TextTable = {};
for (const r of extractTextKeys(spec)) t[r.key] = r.text;
return t;
}
/** 渲染器可能查找的键集合(引用集):line 恒被读,else 仅当声明了 line_else,选项文本恒被读。 */
export function rendererRefs(spec: Spec): string[] {
const refs: string[] = [];
for (const n of spec.dialogue) {
refs.push(`dlg.${n.id}.line`);
if (typeof n.line_else === "string" && n.line_else.length > 0) refs.push(`dlg.${n.id}.else`);
for (const c of n.choices ?? []) refs.push(`cho.${c.id}.text`);
}
return refs;
}
/** 漏键 = 被引用但表里没有;悬空键 = 表里有但无人引用。 */
export function checkKeys(table: TextTable, refs: string[]): { missing: string[]; dangling: string[] } {
const keySet = new Set(Object.keys(table));
const refSet = new Set(refs);
const missing = [...refSet].filter((k) => !keySet.has(k)).sort();
const dangling = [...keySet].filter((k) => !refSet.has(k)).sort();
return { missing, dangling };
}
core/test/ne_12_1_text_table.test.ts 在正例之外各给一条负例——删掉一条键看漏键报不报、加一条没人引用的键看悬空键报不报。负例是「检测器真的会随表变化而变」的证据。
// file: core/test/ne_12_1_text_table.test.ts
// tier: P
// verify: node --test 'core/test/ne_12_1_*.test.ts'
// 卷二 12.1:文本键的抽取与计数,漏键 / 悬空键的正例与负例。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { extractTextKeys, buildTable, rendererRefs, checkKeys } from "../src/inkwell/ne_12_1_text_table.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8"));
function counts(rows: ReturnType<typeof extractTextKeys>) {
return {
line: rows.filter((r) => r.kind === "dlg.line").length,
else_: rows.filter((r) => r.kind === "dlg.else").length,
text: rows.filter((r) => r.kind === "cho.text").length,
};
}
test("文本键总数 = 48 line + 1 else + 115 text = 164", () => {
const rows = extractTextKeys(spec);
const c = counts(rows);
assert.deepEqual(c, { line: 48, else_: 1, text: 115 });
assert.equal(rows.length, 164);
assert.equal(new Set(rows.map((r) => r.key)).size, 164, "键必须唯一");
});
test("正例:表与引用集同源,漏键 0 / 悬空键 0", () => {
const table = buildTable(spec);
const { missing, dangling } = checkKeys(table, rendererRefs(spec));
assert.deepEqual(missing, []);
assert.deepEqual(dangling, []);
assert.equal(Object.keys(table).length, 164);
console.log(`正例:表 ${Object.keys(table).length} 键,漏键 ${missing.length} / 悬空键 ${dangling.length}`);
});
test("负例 · 漏键:删掉 else 键,漏键报出 dlg.d_tc_2.else", () => {
const table = buildTable(spec);
delete table["dlg.d_tc_2.else"];
const { missing, dangling } = checkKeys(table, rendererRefs(spec));
assert.deepEqual(missing, ["dlg.d_tc_2.else"]);
assert.deepEqual(dangling, []);
console.log(`负例·漏键:漏键 ${missing.length} ${JSON.stringify(missing)}`);
});
test("负例 · 悬空键:加一个无人引用的键,悬空键报出", () => {
const table = buildTable(spec);
table["dlg.__ghost__.line"] = "无人引用";
const { missing, dangling } = checkKeys(table, rendererRefs(spec));
assert.deepEqual(missing, []);
assert.deepEqual(dangling, ["dlg.__ghost__.line"]);
console.log(`负例·悬空键:悬空键 ${dangling.length} ${JSON.stringify(dangling)}`);
});
test("落盘:config/ 与 out/ 各写一份键 -> 文本", () => {
const table = buildTable(spec);
const rows = extractTextKeys(spec);
const c = counts(rows);
const doc = {
counts: { ...c, total: rows.length },
keys_unique: new Set(rows.map((r) => r.key)).size,
table,
};
const body = JSON.stringify(doc, null, 2) + "\n";
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out/ne_12_1_text_table.json"), body);
writeFileSync(join(ROOT, "config/ne_12_1_text_table.json"), body);
console.log(`落盘:${rows.length} 键(line ${c.line} / else ${c.else_} / text ${c.text})`);
});
本机实跑 node --test 'core/test/ne_12_1_*.test.ts':
正例:表 164 键,漏键 0 / 悬空键 0
负例·漏键:漏键 1 ["dlg.d_tc_2.else"]
负例·悬空键:悬空键 1 ["dlg.__ghost__.line"]
落盘:164 键(line 48 / else 1 / text 115)
✔ 文本键总数 = 48 line + 1 else + 115 text = 164 (1.899542ms)
✔ 正例:表与引用集同源,漏键 0 / 悬空键 0 (0.704708ms)
✔ 负例 · 漏键:删掉 else 键,漏键报出 dlg.d_tc_2.else (0.205917ms)
✔ 负例 · 悬空键:加一个无人引用的键,悬空键报出 (0.183958ms)
✔ 落盘:config/ 与 out/ 各写一份键 -> 文本 (11.145459ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ duration_ms 873.256208
判读:164 是三个计数的和
「164 键」 拆开看是 48 + 1 + 115。48 条 line 每个对话节点一条;1 条 else 来自 d_tc_2(规格里唯一声明了 line_else 的节点);115 条选项文本。这三个数都不是本节编的,是规格的 meta.scale 里 dialogue / choices 两项的实测值——文本表的规模必须与规格规模对齐,这是第一层一致性。
「正例漏键 0 / 悬空键 0」 是抽键规则正确的直接后果。注意这个 0 是有前提的:引用集与表由同一套规则推出。如果哪天渲染器新增一种查找(比如给旁白加 dlg.<id>.narr),引用集就会多出一条,而表没有——漏键立刻从 0 变 1。这正是要的:把「规则变了但表没跟上」变成一次可回归的失败。
两条负例各挡一面。 删掉 dlg.d_tc_2.else 后漏键报出恰好这一条——证明漏键检测不是恒返回空;加一条 dlg.__ghost__.line 后悬空键报出它——证明悬空键检测也真的在工作。只报不删是悬空键的口径:表里多出来的键未必是错的(可能是预留),所以它只报告,不判 FAIL。
表里长什么样
config/ne_12_1_text_table.json 由 P 层测试落盘,结构是「计数 + 键唯一性 + 键 → 文本」。下面截取头部几行看格式,完整文件 164 键:
{
"counts": {
"line": 48,
"else_": 1,
"text": 115,
"total": 164
},
"keys_unique": 164,
"table": {
"dlg.d_pier_1.line": "先生,您是来等船的吗?这三天没船了。",
"cho.c_pier_1a.text": "问他灯塔为什么没亮",
注意 dlg.d_tc_2.else 与 dlg.d_tc_2.line 并存:同一个节点有两条键,分别装 else 分支与基础台词。这不是重复,是两条不同的渲染路径各取各的文本。
本章的做与不做
| 本套做(文本的结构一致性) | 本套不做(需真实业务环境,命令行不可复现) |
|---|---|
| 键有没有漏、有没有悬空 | 译文准不准、顺不顺 |
| 变体有没有覆盖、复数形式有没有缺 | 字体字形覆盖是否完整 |
| 占位符名字与声明变量对不对得上 | 配音是否到位、成本多少 |
右边一列在整章里不会出现在任何断言中。文本表与键的一致性可以在命令行上复现;译文质量与字体覆盖不能——后者要真实业务环境与人工审校。本章只做左边。
自检表
| 检查项 | 合格标准 |
|---|---|
| 命名规则唯一 | 键只有 dlg.<id>.line / dlg.<id>.else / cho.<id>.text 三族 |
| 与规格对齐 | 164 键 = 48 line + 1 else + 115 text,与 meta.scale 一致 |
| 漏键可查 | 被引用但表里没有的键被列出,正例为 0 |
| 悬空键只报告 | 表里有但无人引用的键只报告,不判 FAIL |
| 不做译文评价 | 只数键、只比名字,不比较文本内容 |
下一节把键放到条件与数量上:同一条键在不同状态下可能要多份文本,复数形式则是数量维度上的变体——而规格里已经有一处「声明了条件却没声明另一分支」的欠声明,正好用变体矩阵照出来。
阅读导航:上一节:11.3 快照的向前兼容 · 下一节:12.2 变体与复数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。