1.3 文本是渲染:规格与文本的分工

本套的核心主张:台词文本不是规格的输入端,而是规格的一种渲染产物。同一条 dialogue 记录(line 加 line_when 加 line_else)在不同状态下渲染出不同台词。本节给出四分支渲染规则、用 d_tc_2 做最小演示、反向断言文本不进状态,并写死边界——只做文本的结构,不评价文本质量。

站内的独立游戏的叙事设计与分支对话 (1076 行)是散文式叙事经验:它教你怎么把一段对白写得有味道,代码是 Twine 片段、跑不起来。本节与它分工明确——本节一个字都不评价台词写得好不好。本套把叙事形式化为规格字段,文本在这套体系里的身份不是「内容」而是渲染产物:同一条规格记录,配不同的状态,渲染出不同的台词。《孢子三部曲创作手记》 做的是小说文本创作,那是文字本身的工艺;本节只做文本的结构,两者不重叠。

这句话是本套的核心主张,值得说慢一点:规格是唯一的真源,文本是它的一种输出。 换个角度说——如果把所有台词从规格里删掉,故事的结构(谁在什么时候出现、哪些选项可见、状态怎么变)完好无损;反过来,如果只留台词、删掉条件与跳转,这个故事就什么都不剩了。文本是叶子,结构是枝干。

1.3 文本是渲染:规格与文本的分工

一条 dialogue 记录在规格里长这样:一个 line,一个可选的 line_when,一个可选的 line_else。这三样东西不是「台词」,是渲染参数。把它们交给渲染器与一份当前状态,才得到玩家真正读到的那一句。

line_whenline_else渲染哪一条分支名
为 null无所谓linebase
为真无所谓linewhen
为假存在line_elseelse
为假不存在linefallback

四条分支里,base / when / else 都是规格明确写出来的意图;只有 fallback 不是——它是渲染器在规格没写 else 时的兜底行为。第 13 章会把它当成一类残差来统计:声明了条件却没有声明另一个分支,是一个可以被程序数出来的结构缺口。

按这三条分支把 48 个对话节点分一次类,得到三种文本形态:

文本形态节点数例子
静态文本(line_when 为 null)46绝大多数台词
完整条件文本(line_when + line_else)1d_tc_2(潮汐洞窟)
条件文本缺 else(line_when,无 line_else)1d_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走哪条分支渲染出的台词
A0else石壁上刻着名字,一个接一个,刻得很浅。
B1when石壁深处传来一句话,像有人在念自己刻的字:十七个。你是第十八个来的。

规格没有变,变的是状态。 这就是「文本是渲染」的全部含义:文本不是被作者「写进」某个分支的,而是被规格 + 状态算出来的。作者写的是那三行渲染参数,不是两个句子。

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 的字段全表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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