站内的独立游戏的叙事设计与分支对话 (1076 行)是散文式叙事经验:它教你怎么把一段对白写得有味道,代码是 Twine 片段、跑不起来。本节与它分工明确——本节一个字都不评价台词写得好不好。本套把叙事形式化为规格字段,文本在这套体系里的身份不是「内容」而是渲染产物:同一条规格记录,配不同的状态,渲染出不同的台词。《孢子三部曲创作手记》 做的是小说文本创作,那是文字本身的工艺;本节只做文本的结构,两者不重叠。
这句话是本套的核心主张,值得说慢一点:规格是唯一的真源,文本是它的一种输出。 换个角度说——如果把所有台词从规格里删掉,故事的结构(谁在什么时候出现、哪些选项可见、状态怎么变)完好无损;反过来,如果只留台词、删掉条件与跳转,这个故事就什么都不剩了。文本是叶子,结构是枝干。
1.3 文本是渲染:规格与文本的分工
一条 dialogue 记录在规格里长这样:一个 line,一个可选的 line_when,一个可选的 line_else。这三样东西不是「台词」,是渲染参数。把它们交给渲染器与一份当前状态,才得到玩家真正读到的那一句。
line_when | line_else | 渲染哪一条 | 分支名 |
|---|---|---|---|
为 null | 无所谓 | line | base |
| 为真 | 无所谓 | line | when |
| 为假 | 存在 | line_else | else |
| 为假 | 不存在 | line | fallback |
四条分支里,base / when / else 都是规格明确写出来的意图;只有 fallback 不是——它是渲染器在规格没写 else 时的兜底行为。第 13 章会把它当成一类残差来统计:声明了条件却没有声明另一个分支,是一个可以被程序数出来的结构缺口。
按这三条分支把 48 个对话节点分一次类,得到三种文本形态:
| 文本形态 | 节点数 | 例子 |
|---|---|---|
静态文本(line_when 为 null) | 46 | 绝大多数台词 |
完整条件文本(line_when + line_else) | 1 | d_tc_2(潮汐洞窟) |
条件文本缺 else(line_when,无 line_else) | 1 | d_lt_2(灯塔顶层) |
最小演示:一条记录,两条台词
d_tc_2 是全案例里唯一一个两个分支都写全的节点。它的规格原文是:
node : d_tc_2 scene: tide_cave speaker: narrator
line : 石壁深处传来一句话,像有人在念自己刻的字:十七个。你是第十八个来的。
line_when: ["flag", "cave_explored", 1]
line_else: 石壁上刻着名字,一个接一个,刻得很浅。
同一段 JSON,配两个不同的状态:
| 状态 | cave_explored | 走哪条分支 | 渲染出的台词 |
|---|---|---|---|
| A | 0 | else | 石壁上刻着名字,一个接一个,刻得很浅。 |
| B | 1 | when | 石壁深处传来一句话,像有人在念自己刻的字:十七个。你是第十八个来的。 |
规格没有变,变的是状态。 这就是「文本是渲染」的全部含义:文本不是被作者「写进」某个分支的,而是被规格 + 状态算出来的。作者写的是那三行渲染参数,不是两个句子。
S 层:渲染器与反向断言
sim/na_01_line_render.py 做两件事:正向按四分支规则渲染出两种台词;反向断言文本不进状态——把所有条件里的字符串叶子收集起来,与全部台词取交集,交集必须为空。
#!/usr/bin/env python3
# file: sim/na_01_line_render.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_01_line_render.py
"""「文本是规格的渲染产物」:同一条 dialogue 记录在不同状态下渲染出不同台词。
本脚本只做**结构**,不评价文本好坏、不比对文风:
1) 声明渲染规则(line_when → line / line_else / 回落),并逐字执行;
2) 用 d_tc_2 做最小演示:同一节点、两个状态、两条不同台词;
3) 反向断言:**文本不进状态**——所有条件里的字符串叶子没有一个等于任何台词。
"""
from __future__ import annotations
import json
import os
import sys
AFF_LO, AFF_HI = 0, 100
def load_spec() -> dict:
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "../config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def eval_cond(c, st: dict) -> bool:
"""与 na_00_spec_check.py 同一份文法(只求值,不判定可满足性)。"""
if c is None:
return True
k = c[0]
if k == "flag":
return st["flags"].get(c[1], 0) == c[2]
if k == "item":
return st["items"].get(c[1], 0) == c[2]
if k == "aff":
v = st["aff"].get(c[1], 0)
op, n = c[2], c[3]
if op == ">=":
return v >= n
if op == "<":
return v < n
if op == "==":
return v == n
return v != n
if k == "and":
return eval_cond(c[1], st) and eval_cond(c[2], st)
if k == "or":
return eval_cond(c[1], st) or eval_cond(c[2], st)
if k == "not":
return not eval_cond(c[1], st)
raise ValueError(f"unknown cond op: {k}")
def init_state(spec: dict) -> dict:
return {
"aff": {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in sorted(spec["state"]["vars"])},
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])},
}
def render_line(node: dict, st: dict) -> tuple[str, str]:
"""渲染规则(本套对「条件文本」的唯一口径):
line_when 为 null -> 渲染 line (分支 base)
line_when 为真 -> 渲染 line (分支 when)
line_when 为假且有 line_else -> 渲染 line_else (分支 else)
line_when 为假且无 line_else -> 渲染 line (分支 fallback,规格未给 else)
"""
if node.get("line_when") is None:
return node["line"], "base"
if eval_cond(node["line_when"], st):
return node["line"], "when"
if node.get("line_else"):
return node["line_else"], "else"
return node["line"], "fallback"
def cond_strings(c, out: list[str]) -> None:
"""收集条件里的全部字符串叶子,用于「文本不进状态」的反向断言。"""
if c is None:
return
if isinstance(c, str):
out.append(c)
return
if isinstance(c, list):
for x in c:
cond_strings(x, out)
def main() -> int:
spec = load_spec()
dlg = spec["dialogue"]
by_id = {n["id"]: n for n in dlg}
node = by_id["d_tc_2"]
print(f"演示节点 d_tc_2 line_when={node['line_when']}")
print(f" 规格里的 line :{node['line']}")
print(f" 规格里的 line_else :{node.get('line_else')}")
st_off = init_state(spec)
st_on = init_state(spec)
st_on["flags"]["cave_explored"] = 1
line_off, branch_off = render_line(node, st_off)
line_on, branch_on = render_line(node, st_on)
print(f" 状态 A(cave_explored=0)-> 分支 {branch_off}:{line_off}")
print(f" 状态 B(cave_explored=1)-> 分支 {branch_on}:{line_on}")
same = line_off == line_on
print(f" 两条台词是否相同:{same}(长度 {len(line_off)} / {len(line_on)})")
if same:
print("FAIL 同一节点在两个状态下渲染出了同一条台词")
return 1
cond_lines = [n["id"] for n in dlg if n.get("line_when")]
with_else = [n["id"] for n in dlg if n.get("line_else")]
no_else = [n["id"] for n in dlg if n.get("line_when") and not n.get("line_else")]
print(f"规格规模:对话节点 {len(dlg)},带 line_when 的 {len(cond_lines)} 个 {cond_lines}")
print(f" 其中显式给 line_else 的 {len(with_else)} 个,未给 else 的 {len(no_else)} 个 {no_else}")
# 反向断言:文本不进状态。条件里出现的字符串叶子不得等于任何台词。
texts = {n["line"] for n in dlg} | {n["line_else"] for n in dlg if n.get("line_else")}
leaves: list[str] = []
for n in dlg:
cond_strings(n.get("line_when"), leaves)
for c in (n.get("choices") or []):
cond_strings(c.get("when"), leaves)
for e in spec["scene_graph"]["edges"]:
cond_strings(e.get("when"), leaves)
for loc in spec["world"]["locations"]:
cond_strings(loc.get("unlock_when"), leaves)
for e in spec["endings"]:
cond_strings(e.get("when"), leaves)
hit = sorted(set(leaves) & texts)
print(f"条件字符串叶子 {len(set(leaves))} 个,与台词集合的交集 {len(hit)} 个 {hit}")
if hit:
print("FAIL 台词文本出现在条件里(文本进入了状态输入端)")
return 1
# 整数不变量
for c in spec["characters"]:
assert AFF_LO <= c["affinity"]["init"] <= AFF_HI, f"{c['id']} init 越界"
print("OK na_01_line_render:一条规格记录渲染出两条台词,文本不进状态")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_01_line_render.py:
演示节点 d_tc_2 line_when=['flag', 'cave_explored', 1]
规格里的 line :石壁深处传来一句话,像有人在念自己刻的字:十七个。你是第十八个来的。
规格里的 line_else :石壁上刻着名字,一个接一个,刻得很浅。
状态 A(cave_explored=0)-> 分支 else:石壁上刻着名字,一个接一个,刻得很浅。
状态 B(cave_explored=1)-> 分支 when:石壁深处传来一句话,像有人在念自己刻的字:十七个。你是第十八个来的。
两条台词是否相同:False(长度 19 / 34)
规格规模:对话节点 48,带 line_when 的 2 个 ['d_tc_2', 'd_lt_2']
其中显式给 line_else 的 1 个,未给 else 的 1 个 ['d_lt_2']
条件字符串叶子 29 个,与台词集合的交集 0 个 []
OK na_01_line_render:一条规格记录渲染出两条台词,文本不进状态
判读:三行输出各自说明了什么
「两条台词是否相同:False」 是正向结论:同一节点、同一份规格,仅状态不同,渲染结果就不同。注意这里比的是两条渲染产物不相等,而不是某种文本层面的等价性——本套不对文本做任何逐字比对,跨语言的摘要只对状态做。
「未给 else 的 1 个 [’d_lt_2’]」 是结构缺口。d_lt_2 声明了 line_when(好感度 yan >= 30),却没有写 line_else。渲染器按声明规则回落到 line,也就是说:条件不成立时,玩家读到的仍是被条件守护的那一句。这是规格层面的欠声明,不是文本层面的问题——修法是在规格里补一条 line_else,而不是重写台词。
「与台词集合的交集 0 个」 是反向断言:规格里 29 个条件字符串叶子(flag 名、变量名、物品名、算子),没有一个等于任何一句台词。文本只出现在 line 与 choices[].text 两个位置,从不参与状态推进。这条性质是「文本是渲染产物」的形式化表述:输入端只有标识符与整数,输出端才有句子。
边界:本套做文本的什么,不做什么
这条边界必须写死,否则后面 47 节都会被误读。
| 本套做(文本的结构) | 本套不做(文本的创作) |
|---|---|
| 模板与变量:文本里的占位怎么被值替换 | 用词是否漂亮、句式是否有节奏 |
| 条件文本与变体:一条记录怎么按状态分流 | 台词是否符合人物语言风格 |
| 文本表与键:文本怎么外置成可校验的表 | 台词的文学质量评价 |
| 变体的一致性校验:变体之间是否互相矛盾 | 配音、口型与演出(需真实业务环境,标 X) |
四行左边全部是可判定的:模板替换可以断言,变体分流可以穷举,文本表的键可以查缺失,变体矛盾可以算残差。四行右边全部不可判定,也不该被伪装成可判定。文本创作划界到 《孢子三部曲创作手记》 ——那一套做的是小说文字本身的工艺,本套一个字都不评价。
这条边界也约束了度量:卷三的所有指标(分支覆盖率、结局分布熵、张力曲线 MAE、一致性残差)都是结构量,没有一个是文本质量分。它们只用于相对比较,且都是代理指标——不能据此声称哪一版台词更好。
自检表
| 检查项 | 合格标准 |
|---|---|
| 渲染规则唯一 | 四分支(base / when / else / fallback)写死在渲染器里,不各章各写一套 |
| 正向可复现 | 同一条记录配不同状态,渲染出不同台词(实跑已断言) |
| 反向可断言 | 条件里的字符串叶子与台词集合的交集为空(实跑为 0) |
| 缺口被记录 | 声明了 line_when 却没写 line_else 的节点被显式数出来 |
| 边界写死 | 只做文本结构,不评价文本质量;不写文本层面的逐字比对 |
第一章到这里把地基铺完了:四个最小构成数清楚了,骨架的十段立起来了,文本在体系里的位置也定死了——它是输出,不是输入。下一节开始逐字段拆解 story_spec.json:从 meta 的规模声明到 metrics 的阈值,把每一段有哪些键、每个键是什么类型、谁消费它,列成一张能对着改的表。
阅读导航:上一节:1.2 从故事到结构:叙事规格的骨架 · 下一节:2.1 story_spec.json 的字段全表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。