11.2 台词的条件渲染
客户端任务与对话分支 关心的是客户端拿到一段文本之后怎么把它显示出来——逐字打字、自动换行、点按继续。本节在它之前一步:同一份规格记录,在不同的状态上应该渲染出哪一条台词。这是数据结构层的事,与用哪种打字机效果无关。
1.3 已经把口径立住了:文本是规格的渲染产物,line 字段不是「那段要说的话」,而是「在当前条件下该说的那句话」。本节把这句抽象落成一条可枚举的规则,并数清本案例里它到底分出几支。
渲染规则:四分支
节点上有两个字段参与渲染:line_when(条件)与 line_else(备选)。规则只有四条,按 line_when 的取值分流:
line_when | line_else | 渲染 | 分支名 |
|---|---|---|---|
| 为空 | 不参与 | line | base |
| 成立 | 不参与 | line | when |
| 不成立 | 有 | line_else | else |
| 不成立 | 无 | line | fallback |
第三、四行的差别是本节的关键。条件不成立时,有 line_else 就渲染它,没有就回落到 line——注意 fallback 渲染的仍然是 line,也就是「条件成立时该说的那句」。这不是渲染器出了错,而是规格没说这时该说什么:它声明了一个条件,却没有给出条件不成立时的台词。
把回落定义成 line 而不是空字符串或报错,是一条工程选择:对话不能因为规格少写一个字段就中断;缺省值要能让流程走下去,同时把「这里缺东西」这件事留给校验去报。
S 层:枚举全部渲染分支
脚本对 48 个节点逐个造两个状态——一个使 line_when 为假、一个使其为真——再把两支渲染出来。46 个无条件节点只有 base 一支;2 个带条件的节点各两支,合计 50 个渲染分支。
#!/usr/bin/env python3
# file: sim/na_11_2_line_render_branches.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_11_2_line_render_branches.py
"""卷一 11.2 台词的条件渲染(S 层):把「同一节点在不同状态下渲染出哪条台词」
枚举成一张渲染分支表。
渲染规则(本套对条件文本的唯一口径):
line_when 为 null -> 渲染 line 分支 base
line_when 为真 -> 渲染 line 分支 when
line_when 为假且有 line_else -> 渲染 line_else 分支 else
line_when 为假且无 line_else -> 渲染 line 分支 fallback(规格未给 else)
对 48 个节点逐个枚举渲染分支:46 个无条件节点各 1 支,2 个带 line_when 的节点各 2 支。
d_tc_2 给了 line_else(when / else 两支文本不同);d_lt_2 没给 line_else(when / fallback
落到同一条文本)——这是**规格层面的欠声明**,修法是补 line_else。
产出 out/na_11_2_line_render.csv。整数不变量:全部节点的 line 非空。
"""
from __future__ import annotations
import csv
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CSV_PATH = os.path.join(OUT, "na_11_2_line_render.csv")
def load_spec():
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def eval_cond(c, st):
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, op, n = st["aff"].get(c[1], 0), c[2], c[3]
return (v >= n) if op == ">=" else (v < n) if op == "<" else (v == n) if op == "==" else (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)
return not eval_cond(c[1], st)
def init_state(spec):
aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in sorted(spec["state"]["vars"])}
return {"aff": aff, "flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])}}
def render(node, st):
"""返回 (分支名, 渲染出的台词)。"""
if node.get("line_when") is None:
return "base", node["line"]
if eval_cond(node["line_when"], st):
return "when", node["line"]
if node.get("line_else"):
return "else", node["line_else"]
return "fallback", node["line"]
def force(c, st, want):
"""把状态改造成使条件 c 求值为 want。本规格的 line_when 只有两种叶子:
flag 叶子与 aff>= 叶子;其余形式一旦出现就断言失败(规格演化时的显式护栏)。"""
if c[0] == "flag":
st["flags"][c[1]] = c[2] if want else 1 - c[2]
else:
assert c[0] == "aff" and c[2] == ">=", f"未预期的 line_when 形式 {c}"
st["aff"][c[1]] = c[3] if want else max(0, c[3] - 1)
def main() -> int:
spec = load_spec()
dlg = spec["dialogue"]
base = init_state(spec)
cond_nodes = [n for n in dlg if n.get("line_when")]
rows, branch_total = [], 0
for n in dlg:
if n.get("line_when") is None:
branch_total += 1
rows.append({"node": n["id"], "branch": "base", "line_when": "", "text_len": len(n["line"])})
continue
low = {"aff": dict(base["aff"]), "flags": dict(base["flags"]), "items": dict(base["items"])}
high = {"aff": dict(base["aff"]), "flags": dict(base["flags"]), "items": dict(base["items"])}
force(n["line_when"], low, False)
force(n["line_when"], high, True)
b_low, t_low = render(n, low)
b_high, t_high = render(n, high)
branch_total += len({b_low, b_high})
for br, tx in ((b_low, t_low), (b_high, t_high)):
rows.append({"node": n["id"], "branch": br,
"line_when": json.dumps(n["line_when"], ensure_ascii=False), "text_len": len(tx)})
same = t_low == t_high
print(f"条件节点 {n['id']}:line_when={json.dumps(n['line_when'], ensure_ascii=False)}"
f" line_else={'有' if n.get('line_else') else '无'}")
print(f" 条件不成立 -> 分支 {b_low}({len(t_low)} 字):{t_low}")
print(f" 条件成立 -> 分支 {b_high}({len(t_high)} 字):{t_high}")
print(f" 两支文本相同:{same}"
+ ("(规格欠声明:声明了 line_when 却没给 line_else,回落到 line)" if same else ""))
print(f"节点 {len(dlg)} 个,其中带 line_when 的 {len(cond_nodes)} 个 {[n['id'] for n in cond_nodes]}")
print(f"渲染分支总数 {branch_total}(46 个无条件节点各 1 支 + 2 个条件节点各 2 支 = 50)")
assert branch_total == 50, f"分支总数应为 50,实为 {branch_total}"
for n in dlg:
assert n["line"].strip(), f"{n['id']} 的 line 为空"
print("整数不变量:48 个节点 line 均非空;line_when 的 aff 门限 ∈ [0,100] 由 force 断言兜住")
os.makedirs(OUT, exist_ok=True)
with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["node", "branch", "line_when", "text_len"])
w.writeheader()
w.writerows(rows)
print(f"wrote out/na_11_2_line_render.csv rows={len(rows)}")
print("OK na_11_2_line_render_branches:渲染分支枚举完整,欠声明节点已点名")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_11_2_line_render_branches.py:
条件节点 d_tc_2:line_when=["flag", "cave_explored", 1] line_else=有
条件不成立 -> 分支 else(19 字):石壁上刻着名字,一个接一个,刻得很浅。
条件成立 -> 分支 when(34 字):石壁深处传来一句话,像有人在念自己刻的字:十七个。你是第十八个来的。
两支文本相同:False
条件节点 d_lt_2:line_when=["aff", "yan", ">=", 30] line_else=无
条件不成立 -> 分支 fallback(15 字):你来晚了三天。不过,也不算晚。
条件成立 -> 分支 when(15 字):你来晚了三天。不过,也不算晚。
两支文本相同:True(规格欠声明:声明了 line_when 却没给 line_else,回落到 line)
节点 48 个,其中带 line_when 的 2 个 ['d_tc_2', 'd_lt_2']
渲染分支总数 50(46 个无条件节点各 1 支 + 2 个条件节点各 2 支 = 50)
整数不变量:48 个节点 line 均非空;line_when 的 aff 门限 ∈ [0,100] 由 force 断言兜住
wrote out/na_11_2_line_render.csv rows=50
OK na_11_2_line_render_branches:渲染分支枚举完整,欠声明节点已点名
怎么造出「条件成立」的状态
要枚举渲染分支,就得对每个条件节点造出两个状态:一个使它成立、一个使它不成立。这里有个容易踩的坑:直觉做法是「把所有好感度拉满」,但那只对 aff 条件有效。本案例的 d_tc_2 条件是旗标 cave_explored,把好感度拉满不会让它成立——第一版脚本正是这样写的,两个状态都落到 else 分支,分支数少算了一支。
脚本因此改成按条件叶子分别置值:flag 叶子写它期望的值,aff 叶子写门限或门限减一。同时加了一条护栏——line_when 一旦出现这两种叶子之外的形式(比如嵌套的 and),就断言失败,提示需要扩展造状态的逻辑。这比悄悄算错要安全。
渲染分支表
枚举结果落到 out/na_11_2_line_render.csv,每行一个渲染分支,四个列是节点、分支名、条件原文、渲染出的台词字数:
node,branch,line_when,text_len
d_pier_1,base,,18
d_pier_2,base,,35
...
d_tc_2,else,"[""flag"", ""cave_explored"", 1]",19
d_tc_2,when,"[""flag"", ""cave_explored"", 1]",34
...
d_lt_2,fallback,"[""aff"", ""yan"", "">="", 30]",15
d_lt_2,when,"[""aff"", ""yan"", "">="", 30]",15
看最后两行:d_lt_2 的 fallback 与 when 两支的 text_len 都是 15。字数相同不等于台词相同,但在这一对上是同一句——这正是欠声明在表上的样子。有了这张表,「哪些条件真的分出了两支」就不再靠读代码,而是靠数表里的行。
判读:两个条件节点,两种成色
输出里 2 个条件节点的对比,正好把「渲染规则的四分支」和「规格是否写全」区分开。
d_tc_2 是写全的:条件是「洞窟探索过」,不成立时渲染 19 字的 line_else(石壁上刻着名字),成立时渲染 34 字的 line(石壁深处传来念字声)。两支不同,条件真的改变了玩家看到的东西——这是一个有内容的分支。
d_lt_2 是欠声明的:条件是「砚的好感度到 30」,但它没有 line_else。于是条件不成立时走 fallback,渲染出来的仍是 line(你来晚了三天),与条件成立时的 when 分支落到同一条台词。两支文本相同——换句话说,这个 line_when 在当前规格下没有产生可观察的差异。
这里要避免一个误读:这不是渲染器的缺陷,也不是「写错了台词」。修法只有一个方向——补 line_else,把「好感度不到 30 时砚该说什么」写出来。改台词本身解决不了问题,因为问题不在台词,在于规格少声明了一条分支。
| 观察 | 数字 | 读法 |
|---|---|---|
带 line_when 的节点 | 2 / 48 | 条件台词是稀例,不是常态 |
显式给 line_else 的 | 1 / 2 | 只有一半的条件节点写全了两支 |
| 渲染分支总数 | 50 | 46 个 base + 2 组条件两支 |
| 条件无观察差异的节点 | 1(d_lt_2) | 规格层面的欠声明,非渲染错误 |
与「文本是渲染」的呼应
1.3 说文本是渲染产物,本节给出了这句话最小的可执行版本:渲染规则是四分支的、可枚举的、状态驱动的。把 48 个节点的渲染分支全部枚举出来只得到 50 支——这个数字本身就是一个结构量:它说明这份规格里「同一句话在不同状态下有不同说法」的写法还没被大量使用。
这不评价文本好坏,也不评价分支设计得够不够。它只回答一个可判定的问题:每一条 line_when 都真的分出了两支吗? 本案例的答案是 2 个条件节点里 1 个分出了、1 个没有。后者的处置方式(补 line_else)落在规格上,落在 13 章的语义校验里会再次出现。
自检表
| 检查项 | 合格标准 |
|---|---|
| 渲染规则封闭 | 只有 base / when / else / fallback 四支 |
| 回落有定义 | 无 line_else 时渲染 line,不报错也不出空串 |
| 分支可枚举 | 每个节点的渲染分支能穷举,总数可对账 |
| 欠声明可点名 | 声明了条件却没给备选台词的节点能被单独列出 |
| 不评价文本 | 只报「有没有分出两支」,不评台词写得好不好 |
台词的渲染规则定完了,节点与选项的连接也校验过了。但一张图光有边还不够——还要问它能不能走通、有没有绕不出去的圈、有没有走进死路。下一节把 48 个节点当成有向图整体遍历一遍。
阅读导航:上一节:11.1 对话节点、选项与跳转 · 下一节:11.3 对话树的遍历与校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。