11.2 台词的条件渲染

同一节点在不同状态下渲染出不同台词,靠的是 line_when 与 line_else 两个字段。本节把渲染规则写成四分支表,用 S 层脚本枚举全部 50 个渲染分支:48 个节点里只有 2 个带 line_when,其中 d_lt_2 声明了条件却没给 line_else,属规格层面的欠声明,回落到 line。

11.2 台词的条件渲染

客户端任务与对话分支 关心的是客户端拿到一段文本之后怎么把它显示出来——逐字打字、自动换行、点按继续。本节在它之前一步:同一份规格记录,在不同的状态上应该渲染出哪一条台词。这是数据结构层的事,与用哪种打字机效果无关。

1.3 已经把口径立住了:文本是规格的渲染产物,line 字段不是「那段要说的话」,而是「在当前条件下该说的那句话」。本节把这句抽象落成一条可枚举的规则,并数清本案例里它到底分出几支。

渲染规则:四分支

节点上有两个字段参与渲染:line_when(条件)与 line_else(备选)。规则只有四条,按 line_when 的取值分流:

line_whenline_else渲染分支名
为空不参与linebase
成立不参与linewhen
不成立有line_elseelse
不成立无linefallback

第三、四行的差别是本节的关键。条件不成立时,有 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只有一半的条件节点写全了两支
渲染分支总数5046 个 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 对话树的遍历与校验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长