站内的 Godot 本地化与字体实现 解决的是引擎侧怎么切语言、怎么处理字形——一份译文进来,怎么绑到某个 locale、怎么让字体覆盖到那些字形。本章与它划界明确:本章做的是文本表与键的一致性——键有没有漏、变体有没有覆盖、复数形式有没有缺;本章不评价译文质量:一段翻译准不准、顺不顺,既不在本套范围内,也需要真实业务环境与母语审校,命令行不可复现。
12.2 变体与复数
上一节把键立了起来:一个位置一条键。但一条键未必只对应一条文本——它可能按状态分流(条件变体),也可能按数量分流(复数)。本节把这两类分流都变成可数的覆盖矩阵。
| 变体维度 | 触发条件 | 本规格实测 |
|---|---|---|
| 条件变体 | 同一节点在不同状态下渲染不同台词 | 2 个节点带 line_when,其中 1 个带 line_else |
| 复数变体 | 同一句话按数量 n 选不同形式 | 中文单类;对照语言 en-US 两类 |
条件变体复用引擎内核 condOk / initState(同源基准),不另立一套求值。复数则给一份最小规则集,并且必须把中文的适用边界写死。
条件变体:从定义域枚举到覆盖矩阵
一个节点只要声明了 line_when,渲染器就有四条分支:base(无 line_when)、when(条件真)、else(条件假且有 line_else)、fallback(条件假且无 line_else)。把条件里的自由变量在整个定义域上枚举一遍,就能算出这个节点实际会走到几种分支、渲染出几条不同文本。
core/src/inkwell/ne_12_2_variant_plural.ts 做两件事:条件变体的覆盖矩阵,与最小复数规则的选择与渲染。
// file: core/src/inkwell/ne_12_2_variant_plural.ts
// tier: P
// verify: node --test 'core/test/ne_12_2_*.test.ts'
// 卷二 12.2 变体与复数:条件变体(同一节点按状态渲染不同台词)与数量变体(复数)。
// 条件变体复用引擎内核 condOk / initState(同源基准),不另立一套求值。
// 复数只给最小规则集:中文退化为单类 other,对照语言 en-US 分 one / other。
// 只做文本的**结构**:分支覆盖与复数类别计数。不评价译文质量。
import { condOk, initState, type State } from "./engine.ts";
type Node = { id: string; line: string; line_when?: unknown; line_else?: string };
type Spec = Parameters<typeof initState>[0] & { dialogue: Node[] };
export type MatrixRow = {
id: string;
branches: string[];
distinct_texts: number;
declared: number;
status: "complete" | "under_declared";
};
/** 条件里的自由变量(kind, name),去重保序。 */
export function freeVars(c: unknown, out: [string, string][] = []): [string, string][] {
if (!c) return out;
const a = c as unknown[];
const k = a[0] as string;
if (k === "and" || k === "or") {
freeVars(a[1], out);
freeVars(a[2], out);
} else if (k === "not") {
freeVars(a[1], out);
} else if (k === "flag" || k === "item" || k === "aff") {
const pair: [string, string] = [k, a[1] as string];
if (!out.some((p) => p[0] === pair[0] && p[1] === pair[1])) out.push(pair);
}
return out;
}
export function domainOf(kind: string): number[] {
if (kind === "aff") return Array.from({ length: 101 }, (_, i) => i);
return [0, 1];
}
/** 四分支渲染:base / when / else / fallback,与引擎同语义。 */
export function renderLine(node: Node, st: State): [string, string] {
if (node.line_when == null) return [node.line, "base"];
if (condOk(node.line_when as never, st)) return [node.line, "when"];
if (node.line_else) return [node.line_else, "else"];
return [node.line, "fallback"];
}
function cartesian(vars: [string, string][]): number[][] {
let acc: number[][] = [[]];
for (const [kind] of vars) {
const d = domainOf(kind);
acc = acc.flatMap((row) => d.map((v) => [...row, v]));
}
return acc;
}
function withAssign(spec: Spec, vars: [string, string][], values: number[]): State {
const st = initState(spec);
vars.forEach(([kind, name], i) => {
if (kind === "aff") st.aff[name] = values[i];
else if (kind === "flag") st.flags[name] = values[i];
else st.items[name] = values[i];
});
return st;
}
/** 变体覆盖矩阵:每个带 line_when 的节点,枚举定义域得到分支与不同文本数。 */
export function variantMatrix(spec: Spec): MatrixRow[] {
const rows: MatrixRow[] = [];
for (const n of spec.dialogue) {
if (!n.line_when) continue;
const vars = freeVars(n.line_when);
const branches = new Set<string>();
const texts = new Set<string>();
for (const values of cartesian(vars)) {
const [text, branch] = renderLine(n, withAssign(spec, vars, values));
branches.add(branch);
texts.add(text);
}
const b = [...branches].sort();
rows.push({
id: n.id,
branches: b,
distinct_texts: texts.size,
declared: n.line_else ? 2 : 1,
status: n.line_else && texts.size === b.length ? "complete" : "under_declared",
});
}
return rows;
}
export type Rule = "other_only" | "one_other";
/** 复数类别选择:other_only 恒 other;one_other 分 n==1 与否。 */
export function pluralCategory(rule: Rule, n: number): string {
if (rule === "other_only") return "other";
return n === 1 ? "one" : "other";
}
export function renderMessage(msg: Record<string, string>, rule: Rule, n: number): string {
const cat = pluralCategory(rule, n);
return (msg[cat] ?? msg.other).replace("{n}", String(n));
}
复数:最小规则集与中文边界
中文没有复数——「1 件线索」与「3 件线索」用的是同一个「件」。这不是「复数规则可以省」,而是「中文的复数规则退化成只有 other 一个类别」。要把它写成一份可对照的规则集,就必须至少有一个会分类的对照语言:
{
"namespace": "ne_12_2",
"langs": {
"zh-CN": { "rule": "other_only", "categories": ["other"] },
"en-US": { "rule": "one_other", "categories": ["one", "other"] }
},
"messages": {
"you_hold_clues": {
"zh-CN": { "other": "你持有 {n} 件线索。" },
"en-US": { "one": "You hold {n} clue.", "other": "You hold {n} clues." }
},
"you_met_chars": {
"zh-CN": { "other": "你已经见过 {n} 个人。" },
"en-US": { "one": "You have met {n} person.", "other": "You have met {n} people." }
}
},
"boundary": "zh-CN 只有 other 一个复数类,复数规则在中文下退化为单类;数量变体只在对照语言 en-US 上可区分。"
}
规则集里 rule 只有两个值:other_only(恒单类)与 one_other(n==1 分一类)。这足以覆盖「中文 + 一个会分类的对照语言」这个最小场景,也足以说明一件事:复数缺不缺,只有在会分类的语言上才看得出来。中文文本里「件」用对了,不代表 en-US 的 clue / clues 就齐了——后者要另算。
P 层:正例与负例
// file: core/test/ne_12_2_variant_plural.test.ts
// tier: P
// verify: node --test 'core/test/ne_12_2_*.test.ts'
// 卷二 12.2:条件变体的覆盖矩阵与最小复数规则集。
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 { variantMatrix, pluralCategory, renderMessage, type Rule } from "../src/inkwell/ne_12_2_variant_plural.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8"));
const plural = JSON.parse(readFileSync(join(ROOT, "config/ne_12_2_plural_rules.json"), "utf8"));
test("条件变体:2 个节点,1 完整 + 1 欠声明", () => {
const rows = variantMatrix(spec);
assert.equal(rows.length, 2);
const by = Object.fromEntries(rows.map((r) => [r.id, r]));
assert.deepEqual(by["d_tc_2"].branches, ["else", "when"]);
assert.equal(by["d_tc_2"].distinct_texts, 2);
assert.equal(by["d_tc_2"].status, "complete");
assert.deepEqual(by["d_lt_2"].branches, ["fallback", "when"]);
assert.equal(by["d_lt_2"].distinct_texts, 1);
assert.equal(by["d_lt_2"].status, "under_declared");
console.log(`条件变体矩阵:${rows.map((r) => `${r.id}(${r.branches.join("/")}) ${r.distinct_texts}变体 ${r.status}`).join(";")}`);
});
test("复数:zh-CN 恒 other,en-US 分 one / other", () => {
const zh: Rule = plural.langs["zh-CN"].rule;
const en: Rule = plural.langs["en-US"].rule;
assert.deepEqual([0, 1, 2, 5].map((n) => pluralCategory(zh, n)), ["other", "other", "other", "other"]);
assert.deepEqual([0, 1, 2, 5].map((n) => pluralCategory(en, n)), ["other", "one", "other", "other"]);
console.log(`复数类别:zh-CN ${[0, 1, 2, 5].map((n) => pluralCategory(zh, n)).join(",")};en-US ${[0, 1, 2, 5].map((n) => pluralCategory(en, n)).join(",")}`);
});
test("复数渲染:中文单类、英文两类,同一 {n} 占位", () => {
const msg = plural.messages.you_hold_clues;
assert.equal(renderMessage(msg["zh-CN"], plural.langs["zh-CN"].rule, 1), "你持有 1 件线索。");
assert.equal(renderMessage(msg["zh-CN"], plural.langs["zh-CN"].rule, 3), "你持有 3 件线索。");
assert.equal(renderMessage(msg["en-US"], plural.langs["en-US"].rule, 1), "You hold 1 clue.");
assert.equal(renderMessage(msg["en-US"], plural.langs["en-US"].rule, 3), "You hold 3 clues.");
console.log(`渲染:zh(1)=${renderMessage(msg["zh-CN"], plural.langs["zh-CN"].rule, 1)} en(1)=${renderMessage(msg["en-US"], plural.langs["en-US"].rule, 1)} en(3)=${renderMessage(msg["en-US"], plural.langs["en-US"].rule, 3)}`);
});
test("落盘:变体覆盖矩阵写 config/ 与 out/", () => {
const rows = variantMatrix(spec);
const doc = {
nodes: rows,
summary: {
conditional_nodes: rows.length,
complete: rows.filter((r) => r.status === "complete").length,
under_declared: rows.filter((r) => r.status === "under_declared").length,
variant_residual: rows.filter((r) => r.status === "under_declared").length,
},
};
const body = JSON.stringify(doc, null, 2) + "\n";
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out/ne_12_2_variant_matrix.json"), body);
writeFileSync(join(ROOT, "config/ne_12_2_variant_matrix.json"), body);
console.log(`落盘:条件节点 ${rows.length},欠声明 ${doc.summary.under_declared}`);
});
本机实跑 node --test 'core/test/ne_12_2_*.test.ts':
条件变体矩阵:d_tc_2(else/when) 2变体 complete;d_lt_2(fallback/when) 1变体 under_declared
复数类别:zh-CN other,other,other,other;en-US other,one,other,other
渲染:zh(1)=你持有 1 件线索。 en(1)=You hold 1 clue. en(3)=You hold 3 clues.
落盘:条件节点 2,欠声明 1
✔ 条件变体:2 个节点,1 完整 + 1 欠声明 (2.204792ms)
✔ 复数:zh-CN 恒 other,en-US 分 one / other (0.155334ms)
✔ 复数渲染:中文单类、英文两类,同一 {n} 占位 (0.121167ms)
✔ 落盘:变体覆盖矩阵写 config/ 与 out/ (4.378666ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ duration_ms 743.543208
判读:覆盖矩阵里的一处欠声明
d_tc_2 是完整样本。 它的定义域只有 2 个状态(cave_explored 取 0 与 1),走到 2 种分支(else / when),渲染出 2 条不同文本——声明了几条就给几条,status = complete。
d_lt_2 是欠声明样本。 它的定义域有 101 个状态(yan 取 0–100),分支是 when(71 个状态)与 fallback(30 个状态),但两种分支渲染出的是同一条文本——因为规格没写 line_else,fallback 按声明规则回落到 line。于是 distinct_texts = 1 而分支有 2 种,status = under_declared。
这不是缺陷数据,是规格层面的欠声明。 修法是给 d_lt_2 补一条 line_else,不是改台词。本节把它标成 under_declared 而不判 FAIL——欠声明是要报的事实,不是要拦的错。
覆盖矩阵落在 config/ne_12_2_variant_matrix.json,两行各自的 branches / distinct_texts / declared / status 都在里面。下一节把它并进四类残差一次算齐。
复数的边界(X 层)
| 事项 | 能否命令行复现 | 说明 |
|---|---|---|
| 复数类别的选择与计数 | 能 | other_only / one_other 是纯函数,可断言 |
| 译文里复数形式是否真的对 | 不能 | 需真实业务环境与母语审校,命令行不可复现 |
| 字体字形是否覆盖到某个复数形式 | 不能 | 需真实业务环境,命令行不可复现 |
| 配音成本随语种的变化 | 不能 | 需真实业务环境,命令行不可复现 |
本节能断言的是「中文只有一个复数类、英文有两个」这个结构事实;至于译文里 clue 是不是真的该写成 clues、字体有没有那个字形——那要真实业务环境,命令行给不出。
自检表
| 检查项 | 合格标准 |
|---|---|
| 条件变体同源 | 求值复用引擎内核 condOk / initState,不在本节另立一套 |
| 枚举有界 | 定义域来自自由变量(布尔取 0/1,好感度取 0–100) |
| 覆盖矩阵可读 | 每个条件节点给出分支、不同文本数、声明数与状态 |
| 欠声明只报告 | d_lt_2 标 under_declared,不判 FAIL |
| 中文边界写死 | 明确中文退化为单类,复数只在对照语言上可区分 |
| 不做译文评价 | 只算类别与条数,不判断译文里复数形式对不对 |
下一节把键、变体、占位符三件事并到一起,做四类一致性校验,把残差一次算齐。
阅读导航:上一节:12.1 文本表与键 · 下一节:12.3 文本的一致性校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。