《叙事策划与叙事工艺》卷三《叙事体验与路径度量》目录

《叙事策划与叙事工艺》卷三《叙事体验与路径度量》目录:16 章 48 节,把玩家路径变成可度量的对象——路径的表示与采样、分支覆盖率与缺口、结局分布熵、张力曲线与 MAE、后果可见性、节奏密度、好感度曲线形状、一致性残差,以及反推与演化回归。本页给出分工说明、六档可运行性口径与全书 16 章 48 节完整清单。

当前复现状态(2026-10-17)

已恢复完整故事规格、公共输入与测试辅助函数,提供列明 228 条命令的三卷累计工程及独立验收入口。此前 227 条运行快照保留;随后独立审算法发现 11.3 将 BFS 层序误作拓扑序,已改为 Kahn 算法并补四项反例/参考回归,最长路径应为31条边,最大最短距离为22。旧快照的通过不能替代变化后的源码验收,完整结果按对应输入哈希与独立包报告核对。

指定故事规格此前完整 BFS 得到 9,895,423 个状态,去重键为节点、好感度、旗标和物品,不含路径历史。115 个选项与 56 条去重跳转是不同分母:单条示例路径覆盖 25/56 = 446‰,按选项计为25/115 = 217‰;全覆盖也需说明口径。

运行环境为 Python 3.14.6、Node 26 及所列浏览器依赖。按章演进、全部练习与全卷事实另验;熵、张力和 MAE 是代理指标,不能直接认证叙事文本或真人体验质量。

这是《叙事策划与叙事工艺》三卷本的第三卷,也是收官卷。卷一回答「一个故事能不能先被写成一份规格」,卷二回答「这份规格能不能被一台机器确定性地执行」;这一卷回答的是:执行出来的那些路径,能不能被度量——而且度量得诚实?

站内的叙事内容基本落在两端:一端是引擎怎么实现(game/client/ 的打字机、立绘口型、过场系统),一端是散文式的叙事经验(game/indie/ 的分支对话长文、game/industry/ 的叙事设计概览)。**中间那一段——把「这段叙事体验好不好」还原成覆盖率、分布熵、曲线 MAE 这类可算的整数,并且明确说清它们不能声称什么——没有人占。**这一卷补的就是这一段。

做法还是那句话:一份叙事规格,三处消费。config/story_spec.json 是唯一真源,卷一让它「可穷举」,卷二让它「可执行」,这一卷让它「可度量」——路径采样、覆盖缺口、结局分布、张力曲线、一致性残差。改一个阈值或一个节拍标注,度量结果同步改变,且仍能用同一套命令复现。贯穿全书的是虚构的三夜悬疑叙事 《雾港夜航》(工程名 mistport)与工具链 inkwell:sim/ 是 Python 侧的路径采样与曲线计算,core/ 是 TypeScript 的度量内核,web/ 把路径批跑挂到无头浏览器上,out/ 收全部 JSON / CSV / SVG 产物。

这套书与站内既有内容的分工

站内已经有大量相邻内容,写清分工比重复写一遍更有价值。下面七处边界是本卷最容易混淆的,务必分清:

  • 《游戏设计》卷二《策略涌现与体验度量》 与本卷最像,也最容易被混为一谈。它的真源是机制数值与规则,度量对象是一局战斗的动力学(回合数、动作密度、资源守恒残差),张力来自 HP / 资源 / 回合节奏,玩家侧建模的是动因与类型。本卷的真源是叙事结构与条件,度量对象是玩家路径的形状(覆盖率、结局分布、节拍张力曲线),张力来自作者标注的叙事节拍沿路径的序列,玩家侧只做路径选择策略、不建模动因。一句话:那一套证明「这份规则自洽且可解」,本卷证明「这张故事图可达、可覆盖、走出来的情感形状符合设计意图」。两者共享「整数化 + 确定性 + proxy 口径」的工程纪律,但不共享任何规格字段。
  • 独立游戏的叙事设计与分支对话 (约 1076 行)是散文式的叙事经验:分支怎么写、好感度怎么调,配的是清单与示意图,代码不可跑。本卷第 1、2、4、7、8 章把同一批问题变成可算的数——一次通关变成可编码的路径,分支覆盖变成千分比,假选择变成可判定的检测,节奏变成密度序列。
  • 《游戏设计》卷一《玩法机制与可执行规格》 与本套卷二给出的是可执行的真源与内核;本卷第 6、14、15 章接住它们的输出做度量与回归——这一卷不新造规格字段,只读已有的。
  • 《游戏设计》卷三《原型可玩性与品类》 做的是通用可玩性测试设计;本卷第 12 章收口径为叙事路径覆盖 + 结局分布的批跑与断言,真人测试标 X。
  • 游戏叙事设计概览 是概论级短文,讲叙事设计有哪些环节;本卷第 9 章做的是设定与行为对齐的回归——把「叙事失调」写成一个可算的残差并给出告警阈值。
  • 《游戏设计》卷二 的玩家动因与类型建模;本卷第 11 章做的是模拟玩家的路径策略(随机 / 贪心 / 覆盖导向),用途是采样,不建模动因、不做玩家分型,§11.3 标题即写死这一分工。
  • 《游戏设计》卷二 的情绪曲线与张力来自机制动力学;本卷第 5 章的张力来自叙事节拍结构(beats[].tension)沿玩家路径的序列,§5.3 标题即写死「与机制张力的分工」。

一句话:本卷做的是「这些路径的形状能不能被算出来、算出来的数说明了什么、以及这些数不能说明什么」,不是「这段故事写得好不好」,也不是「这份规则好不好玩」。

明确不在本卷范围内

为了让边界清楚,下面这些这一卷不讲:

  • 叙事文本的创作质量(文笔、修辞、人物是否动人)——见《孢子三部曲创作手记》。本卷只对文本做计数(选项数、字数密度),不评价文本本身。
  • 机制层的张力、动因与体验度量——见《游戏设计》卷二《策略涌现与体验度量》。本卷的张力输入是叙事节拍,不是数值动力学。
  • 引擎里的对话渲染与演出——见 game/client/ 各篇。
  • 服务端的对话状态同步、断线重连与持久化——见 game/server/ 各篇。本卷的路径全部在单机内核上跑。
  • 叙事规格怎么设计(字段怎么定、角色怎么写、弧光怎么排)——那是卷一的内容;引擎怎么执行——那是卷二的内容。本卷假定规格已存在、内核已可用。
  • 真实玩家路径数据、真人测试、真实译文与配音成本:本机没有,一律标 X,正文逐处显式标注「需真实业务环境,命令行不可复现」。
  • 任何第三方素材:贯穿案例为虚构叙事,所有 SVG / 图表均由 xml.etree 程序化生成。

可运行性六档口径

本套沿用站内书稿的六档可运行性,每一节至少有一个 S / P / W / B 档的代码块:

档算什么产出断言
SPython 侧度量:路径采样 → 覆盖率与缺口 → 结局分布熵 → 张力曲线与 MAE → 一致性残差 → 反推与演化回归JSON / CSV / 纯 xml.etree SVG脚本 exit 0;SVG 可回读;整数不变量:覆盖率 ∈ [0,1000]‰、熵 ≥ 0、好感度 ∈ [0,100]
PTypeScript 度量内核(路径编码 / 覆盖统计 / 曲线计算 / 反推)core/test/nm*.test.tsnode --test '<glob>' fail == 0;同一路径序列在 Python 与 TypeScript 下算出的整数指标逐位一致
W单文件 HTML 内联规格与内核,Playwright 批跑路径并断言web/dist/nm*.html + out/shots/*.png全绿 + 两遍确定性 + DOM 已绘制 + 结局分布断言
D指标定义表、口径声明、可比性清单、反推参数表JSON / Markdown 表lint,必须与 S / P / W 同节搭配
X真人玩家测试 / 真实译文与配音成本 / 真实团队评审流程无正文逐处显式标注「需真实业务环境,命令行不可复现」
B采样规模 / 阈值 / 节拍密度 / 参数扰动的相变扫描JSON软告警不硬失败;exit 0;数字按对应实验输入与环境解读

「叙事质量」的操作化——本卷全部指标都是 proxy:分支覆盖率(走过的对话边 / 全部对话边)、结局分布熵、张力曲线 MAE(实际路径曲线 vs 目标曲线)、一致性残差(设定-行为失配计数)、整数不变量残差(好感度越界、tension 越界,恒为 0)。这些数只做相对比较,禁「更感人」「提升 N 倍」「玩家满意度 X%」这类表述。第 15 章专门写「指标不能声称什么」,把这一条讲透。

贯穿案例《雾港夜航》总览

故事:雾港被浓雾与潮汐围困,灯塔守望者「砚」在三夜前失踪。玩家扮演抵达雾港的信使,在三夜之间于 6 个角色、4 个派系(船帮 / 教会 / 商会 / 守望者工会)之间做出选择,收集线索、推进关系,最终分岔到 5 个结局。「三夜」既是三幕结构的自然映射,又是可回放的三段路径——这是让「结构」「分支」「曲线」三者都可算的最小骨架。

固定规模(全书常量):

项数量
幕 / 夜3
场景节点14
角色6
节拍 beat16(tension 之和 = 52,取值 1–5 整数)
对话节点48(其中 5 个是结局节点)
对话边(选项)115
flag18
好感度变量6 个角色均带 affinity 声明;其中 4 个(yan / hui / shen / mu)进状态表并被效果与条件引用
线索物品9
结局5(灯守 / 离港 / 真相 / 缄默 / 沉没)

本卷关心的度量侧数字(历史基线 seed=20261016;当前验证范围见页首):

指标值
单条路径(确定性首选项策略)覆盖对话边446‰
该路径覆盖场景9 / 14
该路径覆盖节拍11 / 16
该路径到达的结局1 / 5
该路径的张力 MAE0 毫(阈值 1000 毫)
结局熵上界(5 结局均匀分布)2321 毫比特(阈值 1500 毫比特)
全部对话边(BFS 口径,卷二实测)115 / 115,即 1000‰
可达状态数(BFS 完整枚举,卷二实测)9895423

这组数就是全卷的动机:一条路径只能覆盖 44.6% 的对话边、9 / 14 的场景、11 / 16 的节拍,只到达 1 / 5 的结局——而 BFS 口径下全部 115 条边都是可达的。单条路径的覆盖远远不够,所以必须采样;采样到什么程度才算够,就是第 2 章要回答的问题。

规格的两处真实特征(不是缺陷,正文按实测发现呈现):① beats[].tension 与 metrics.target_tension_curve 在真源里逐项相同,所以当前规格下张力 MAE 恒为 0——这不是指标失效,而是说明标注与目标此刻是一致的;MAE 真正起作用是在规格演化时(第 14 章),改了节拍标注而没同步目标曲线,它就会抬起来。② 条件文法声明了 4 个比较算子,实测只用到 >= 一种——度量代码必须对未用到的形态也有定义,否则规格一改就崩。

底座:度量侧的公共入口

三处消费共享同一组入口,本卷只读它们、不新造字段:

入口文件本卷位置
规格真源(含 beats / metrics)config/story_spec.json全卷
路径推进与规范串core/src/inkwell/engine.ts、sim/ne_02_*.py第 1 章、第 14 章
可达状态集与覆盖率基准sim/ne_05_*.py、sim/ne_06_*.py第 2 章、第 3 章
可玩原型与批跑入口web/build_ne09.mjs、web/verify_ne10.mjs第 12 章
纯 xml.etree SVG 出图sim/na_*.py(卷一既有)第 10 章

本卷的地基是「同一条路径在三处算出的整数指标逐位一致」:Python 采样器与 TypeScript 度量内核必须对同一条路径给出同一个覆盖率、同一个 MAE。这里只对整数做比对,不对文本做——文本涉及编码与换行,把「文本逐字相等」当作判据是不可靠的(这是卷一第 1.3 节定下的口径)。

一页度量摘要:一条路径的形状量出来

目录篇不给完整教程,只给一个秒级可跑的入口,让读者先确认「一条路径的形状确实能被算出来,而且算出来的是整数」。脚本读 config/story_spec.json,用「总是选第一个可用选项」的确定性策略走完一条路径,量出它的张力曲线、分支覆盖率与结局熵上界:

#!/usr/bin/env python3
# file: sim/nm_00_toc_metric.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_00_toc_metric.py
"""目录篇的一页度量摘要:走一条确定性路径,量出它的张力曲线与分支覆盖率。

与 ne_00_toc_engine.py 分工:那个量「引擎跑不跑得通」,本脚本量「跑出来这条路
的体验形状」。只量一条(卷二 ne_02 采样 500 条),给出三个整数:
张力 MAE(千分位)、分支覆盖率(千分位)、结局熵(毫比特)。
全部走定点整数,不出现浮点。
"""
import json
import os
import sys
from math import log2

AFF_LO, AFF_HI = 0, 100
HERE = os.path.dirname(os.path.abspath(__file__))


def cond_ok(c, s):
    if c is None:
        return True
    k = c[0]
    if k == "flag":
        return s["flags"].get(c[1], 0) == c[2]
    if k == "item":
        return s["items"].get(c[1], 0) == c[2]
    if k == "aff":
        v, op, n = s["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 cond_ok(c[1], s) and cond_ok(c[2], s)
    if k == "or":
        return cond_ok(c[1], s) or cond_ok(c[2], s)
    return not cond_ok(c[1], s)          # not


def node(spec, nid):
    return next(n for n in spec["dialogue"] if n["id"] == nid)


def init_state(spec):
    aff = {v: next(c for c in spec["characters"] if c["id"] == v)["affinity"]["init"]
           for v in sorted(spec["state"]["vars"])}
    return {"node": spec["meta"]["start"], "aff": aff,
            "flags": {f: 0 for f in sorted(spec["state"]["flags"])},
            "items": {i: 0 for i in sorted(spec["state"]["items"])},
            "path": [spec["meta"]["start"]]}


def step(spec, s, c):
    eff = c.get("effects") or {}
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k, v in (eff.get("set") or {}).items():
        flags[k] = v
    for k, v in (eff.get("add") or {}).items():
        aff[k] = min(AFF_HI, max(AFF_LO, aff.get(k, 0) + v))
    for k, v in (eff.get("grant") or {}).items():
        items[k] = v
    for k in (eff.get("revoke") or {}):
        items[k] = 0
    return {"node": c["goto"], "aff": aff, "flags": flags, "items": items,
            "path": s["path"] + [c["goto"]]}


def walk(spec):
    """确定性策略:每步选第一个条件成立的选项。同 ne_00_toc_engine.py。"""
    s, trail = init_state(spec), []
    for _ in range(64):
        cur = node(spec, s["node"])
        if cur.get("ending"):
            break
        opts = [c for c in (cur.get("choices") or []) if cond_ok(c.get("when"), s)]
        if not opts:
            break
        trail.append(opts[0]["id"])
        s = step(spec, s, opts[0])
    return s, trail


def main() -> int:
    with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
        spec = json.load(fh)

    s, _ = walk(spec)
    path = s["path"]
    m = spec["metrics"]

    # 张力曲线:路径经过的场景 → 该场景的节拍;目标值取 target_tension_curve 同下标
    scenes = {node(spec, nid)["scene"] for nid in path}
    pairs = [(b["tension"], m["target_tension_curve"][i])
             for i, b in enumerate(spec["beats"]) if b["scene"] in scenes]
    mae_milli = sum(abs(a - b) for a, b in pairs) * 1000 // len(pairs)

    # 分支覆盖率:走过的对话边 / 全部对话边(千分位)
    dl = spec["dialogue"]
    all_edges = {(n["id"], c["goto"]) for n in dl for c in (n.get("choices") or [])}
    walked = {(path[i], path[i + 1]) for i in range(len(path) - 1)}
    cov_milli = len(walked & all_edges) * 1000 // len(all_edges)

    # 结局分布熵:这条路径只到一个结局,故按「5 结局均匀分布」给出上界作参照
    ends = spec["endings"]
    ent_milli_bits = int(log2(len(ends)) * 1000) if ends else 0

    print(f"起点         {spec['meta']['start']}")
    print(f"节点数       {len(path)}")
    print(f"终局节点     {s['node']}")
    print(f"结局         {node(spec, s['node']).get('ending', '(未终局)')}")
    print(f"覆盖场景     {len(scenes)} / {len(spec['scene_graph']['nodes'])}")
    print(f"覆盖节拍     {len(pairs)} / {len(spec['beats'])}")
    print(f"结局覆盖     1 / {len(ends)}")
    print(f"张力曲线     {' '.join(str(b['tension']) for b in spec['beats'] if b['scene'] in scenes)}")
    print(f"目标曲线     {' '.join(str(m['target_tension_curve'][i]) for i, b in enumerate(spec['beats']) if b['scene'] in scenes)}")
    print(f"张力 MAE     {mae_milli} 毫(阈值 {m['tension_mae_max'] * 1000} 毫)")
    print(f"分支覆盖率   {cov_milli} 千分(目标 {m['coverage_target_permille']} 千分)")
    print(f"结局熵上界   {ent_milli_bits} 毫比特(阈值 {m['ending_entropy_min_milli_bits']} 毫比特)")

    bad = []
    if mae_milli > m["tension_mae_max"] * 1000:
        bad.append("张力 MAE 超阈值")
    if cov_milli < m["coverage_target_permille"]:
        bad.append("分支覆盖率未达目标")
    if ent_milli_bits < m["ending_entropy_min_milli_bits"]:
        bad.append("结局熵上界低于阈值")
    for b in bad:
        print(f"WARN {b}")
    if len(bad) >= 2:
        return 1
    print("OK nm_00_toc_metric:一条路径的张力曲线、覆盖率与熵上界已量出")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑输出:

起点         d_pier_1
节点数       26
终局节点     e_lit
结局         灯守
覆盖场景     9 / 14
覆盖节拍     11 / 16
结局覆盖     1 / 5
张力曲线     1 2 3 2 3 4 4 3 4 4 2
目标曲线     1 2 3 2 3 4 4 3 4 4 2
张力 MAE     0 毫(阈值 1000 毫)
分支覆盖率   446 千分(目标 850 千分)
结局熵上界   2321 毫比特(阈值 1500 毫比特)
WARN 分支覆盖率未达目标
OK nm_00_toc_metric:一条路径的张力曲线、覆盖率与熵上界已量出

判读:四个数各有各的读法。张力 MAE = 0 说明这条路径经历过的 11 个节拍上,作者的标注与设计目标完全吻合——不是指标没用,是当前规格自洽;一旦改了节拍标注而没同步目标曲线,这个数立刻抬起来(第 14 章)。分支覆盖率 446‰ 远低于 850‰ 的目标,脚本给出 WARN 但仍 exit 0——这正是 B 层口径:单条路径达不到全局覆盖目标,不是错误,是采样必要性的证据。结局熵上界 2321 毫比特是按「5 个结局均匀分布」算的理论上界,单条路径只到 1 个结局,所以它是上界而不是实测值——这个区分在第 3 章会展开。

脚本只量一条(第 2 章采样 500 条、第 3 章做 BFS 全量口径),并且全程不出现浮点:MAE 与覆盖率都按千分位取整数地板除,熵按毫比特取整。

全书目录(16 章 48 节)

第一部分 · 路径、覆盖与平衡(第 1–4 章)

第二部分 · 曲线、张力与一致性(第 5–9 章)

第三部分 · 可视化、模拟与测试(第 10–12 章)

第四部分 · 反推、演化与收官(第 13–16 章)

怎么读

  • 想先看主张:读第 1 章 的路径表示与第 15.3 节 的「指标不能声称什么」,那两节把本卷的口径讲完。
  • 想直接动手:从第 2 章 的采样与覆盖率开始写,再依次做第 3 章的结局分布与第 5 章的张力曲线。
  • 想拿结论去说服人:看第 9.1 节 的一致性残差与第 15.1 节 的指标定义总表——这两节把「这份叙事的体验形状」变成可核对的数。
  • 想先看图 / 跑真路径:直接跳到第 10 章 用纯 xml.etree 把张力曲线与路径热力图出成 SVG,再看第 12.2 节 的 W 层批跑。
  • 接着卷一/卷二:卷一《叙事结构与可执行规格》给出规格,卷二《叙事引擎与分支系统》给出内核,本卷接住「度量」。三卷的接口见第 16.1 节 的装配检查表。

可运行性总表(各章档位分布)

章章名主档位
1玩家路径S
2路径的采样与覆盖S + B
3结局分布与分支平衡S + B
4选择的权重与后果可见性S
5叙事张力与释放S + D
6情感曲线的代理指标S
7叙事节奏与密度S
8好感度曲线的形状S + B
9叙事一致性的回归S + B
10曲线的可视化S
11模拟玩家与路径策略S + B + D
12叙事规格的可玩性测试W + S + X
13从度量反推设计S + B
14叙事规格演化与回归S + B
15叙事质量的代理指标总表S + D
16装配:三卷总收官S + P + W + B

S = Python 侧度量与曲线,P = TypeScript 度量内核(node --test),W = 浏览器路径批跑(Playwright 无头真玩),D = 指标定义与清单,X = 需真实环境、命令行不可复现(正文逐处标注),B = 参数扰动与相变扫描(软告警不硬失败)。

完整项目配套

下载 inkwell 三卷累计教学工程 。包内含当前正文、完整规格和源码来源/哈希、明确命令清单及 verify.py。解压后准备依赖并运行 python3 verify.py --browser;完整 BFS 与指标扫描需要较长时间,未运行或超时不会计作通过。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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