《叙事策划与叙事工艺》卷一《叙事结构与可执行规格》目录

《叙事策划与叙事工艺》卷一《叙事结构与可执行规格》目录:16 章 48 节,把一个故事写成可执行规格 story_spec.json,让同一份规格被 S 层穷举、P 层确定性内核与 B 层参数扰动三处消费,产出逐位一致的状态摘要。本页给出七处分工说明、六档可运行性口径、《雾港夜航》规格总览与全书 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/server/ 的对话状态架构),一端是散文式的叙事经验(game/indie/ 的分支对话长文、game/industry/ 的叙事设计概览)。中间那一段——故事被形式化成什么、这些形式能不能被判定、能不能被穷举、能不能被度量——没有人占。 而这一段恰恰是可计算、可跑、可证伪的一段。这一卷补的就是这一段的地基。

做法只有一句话:一份叙事规格,三处消费。config/story_spec.json 是唯一真源(世界 / 角色 / 弧光 / 节拍 / 场景图 / 对话 / 条件 / 状态 / 结局 / 度量),它同时驱动三处——穷举(可达状态集、结局分布、死路与孤儿、分支宽度)、执行(对话状态机、条件求值、状态推进、确定性回放)、度量(张力曲线、覆盖率、一致性残差等代理指标)。三处读的是同一个文件,产出的是逐位相同的状态摘要。改一个条件或一个阈值,三处同步改变且仍然互相一致。

贯穿全书的是虚构的三夜悬疑叙事 《雾港夜航》(工程名 mistport)与工具链 inkwell:config/ 放规格,sim/ 是 Python 仿真与穷举,core/ 是 TypeScript 确定性内核,web/ 把内核内联进单文件 HTML 供无头浏览器真玩。

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

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

  • 独立游戏的叙事设计与分支对话 (约 1076 行)是散文式的叙事经验:角色怎么设计、分支怎么写、Twine 原型怎么搭,配的是清单与示意图,代码不可跑。本卷把同一批问题形式化为规格字段——角色变成 goals / fears / secret / affinity / arc,分支变成带条件的边,于是「可达」「可覆盖」「可满足」都成为可判定的性质。它讲「这段弧光写得动人」,本卷第 5 章回答的是「这段弧光在玩家实际走得到的路径上是否真的成立」。
  • 独立游戏关卡设计与环境叙事 (约 940 行)做的是空间与环境叙事:场景怎么引导、环境怎么讲事。本卷第 7 章把场景写成图——节点、带条件的边、入口条件——于是「可达性」「顺序约束」「解锁」都变成图上的可算量。凡「这个场景该怎么布置」翻那一篇,凡「这个场景在这张图里到底走不走得到」看本卷。
  • 游戏叙事设计概览 (约 93 行)是概论级短文,把叙事设计的几个概念串一遍。本卷是规格级长文,每一节至少有一个可跑的代码块,具体数值按各章记录的规格、种子、采样与环境解读;当前复跑范围见页首,不将历史数字视为新环境下的保证。
  • game/client/ 与 game/server/ 的对话系统文章做的是引擎实现与网络持久化:Phaser 分支叙事 与 Phaser 过场系统 是具体引擎 API,Godot 对话历史与后果日志 是引擎里的选择回看 UI,NPC 对话状态架构 与 叙事分支状态架构 是服务端的同步与持久化。本卷做的是语言无关的规格层:条件是可求值的表达式树,状态是有写入域约束的整数向量,不涉及任何引擎 API,也不涉及网络。
  • Godot 任务条件图编写 与 Godot 任务图资源编辑器 是引擎专属实现 + 编辑器 UI(条件怎么在引擎里连、怎么在编辑器里点)。本卷第 9 章做的是语言无关的规格级条件文法,并回答那两篇回答不了的问题:这个条件可满足吗?有没有死条件?条件和状态是否闭合?
  • 《游戏设计》卷一《玩法机制与可执行规格》 与本卷最像,但输入与对象完全不同。它的真源是数值与规则(伤害 / 资源 / 随机性),消费对象是一局战斗的状态轨迹,核心工具是仿真 + 守恒不变量,拓扑是单一路径的线性时间轴;本套的真源是结构与条件(世界 / 角色 / 弧光 / 分支 / 阈值),消费对象是故事在玩家选择下的展开路径,核心工具是穷举 + 覆盖 + 曲线,拓扑是多路径的状态空间。一句话:那一套证明「这份规则自洽且可解」,本套证明「这张故事图可达、可覆盖、且走出来的形状符合设计意图」。两者共享「整数化 + 确定性 + proxy 口径」的工程纪律,但不共享任何规格字段。
  • 《孢子三部曲创作手记》 做的是文本创作(小说怎么写、叙事怎么编)。本卷第 12 章做的是文本的结构——模板、变量、条件变体、文本表一致性——明确不评价文本的创作质量。这一条边界在本卷被反复重申,见下节。

一句话:本卷做的是「一个故事怎么写成规格、这份规格自洽吗、可达吗、可覆盖吗」,不是「这段故事写得好不好」,也不是「这段对话在引擎里怎么渲染」。

明确不在本卷范围内

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

  • 叙事文本的创作质量(文笔、修辞、人物是否动人)——见《孢子三部曲创作手记》。本卷只处理文本的结构:模板、变量、条件变体、键的一致性。本卷第 12 章会明确列出「做 / 不做」对照表。
  • 引擎里的对话系统实现(打字机、立绘口型、字幕时间轴、过场编排)——见 game/client/ 各篇。
  • 服务端的对话状态同步、断线重连与持久化——见 game/server/ 各篇与《MMO 服务器开发》四卷。
  • 关卡与环境叙事的空间设计(引导、视线、节奏铺陈)——见 game/indie/ 关卡长文。
  • 产品立项、品类选择、变现与买量——见《SLG 游戏产品设计》两卷与《小游戏开发实战》各卷。
  • 真实玩家测试数据、真实配音与字体成本、真实团队协作流程:本机没有,一律标 X,正文逐处显式标注「需真实业务环境,命令行不可复现」。
  • 任何第三方素材:贯穿案例为虚构叙事,所有 SVG / 图表均由程序化生成。

可运行性六档口径

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

档算什么产出断言
S解析 story_spec.json → 结构校验(孤儿 / 断链 / 死条件)→ BFS 穷举可达状态集 → 覆盖率与结局分布 → 张力曲线与 MAEJSON / CSV / 纯 xml.etree SVG脚本 exit 0;SVG 可回读;整数不变量:好感度 ∈ [0,100]、flag 为布尔、tension ∈ 1–5、同输入确定性
PTypeScript 叙事引擎内核(条件求值 / 状态推进 / 确定性回放 / 跨语言摘要)core/test/*.test.tsnode --test '<glob>' fail == 0;同一选择序列跨语言逐位一致(对状态做 digest,不对文本做)
W单文件 HTML 内联规格与内核,Playwright 注入选择序列真玩web/dist/*.html + out/shots/*.png全绿 + 两遍确定性 + DOM 已绘制 + 终局状态与结局断言
D规格 schema、字段表、不变量清单、校验清单JSON / Markdown 表lint,必须与 S / P / W 同节搭配
X真实团队协作流程 / 真实配音与字体成本 / 真人玩家测试无正文逐处显式标注「需真实业务环境,命令行不可复现」
B分支宽度 / 采样上界 / 好感度阈值 / 节拍密度扰动 → 覆盖与分布的相变JSON软告警不硬失败;exit 0;数字按对应实验输入与环境解读

「叙事质量」的操作化:本卷与后续两卷会用到若干指标,它们全部是代理指标(proxy),只做相对比较,不做绝对声称——分支覆盖率(走过的对话边 / 全部对话边)、结局分布熵(5 个结局的分布均匀度)、张力曲线 MAE(实际路径曲线 vs metrics.target_tension_curve)、一致性残差(设定与行为的失配计数)、整数不变量残差(必须恒为 0)。正文不会出现「更感人」「提升 N 倍」「好玩 X%」这类表述。

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

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

固定规模(全书常量):

项数量
幕 / 夜3
场景节点14
场景边16(11 条无条件 + 5 条带条件)
角色6
节拍 beat16(tension 之和 = 52)
对话节点48(其中 5 个是结局节点)
选项115(其中 34 个带条件)
flag18
好感度变量4(yan / hui / shen / mu)
线索物品9
派系4(船帮 guild / 教会 church / 商会 merchant / 守望者工会 watch)
结局5(灯守 / 离港 / 真相 / 缄默 / 沉没)

实测规模(seed=20261016):

指标值
可达状态数(BFS 完整枚举)9895423
分支覆盖率(BFS 口径)1000‰(115/115 条对话边全部可达)
不可达节点 / 不可达选项0 / 0
均匀随机采样 20000 条的结局分布离港 10074 · 沉没 5507 · 缄默 3223 · 灯守 1018 · 真相 178
卡死路径(无选项且非结局)0

设计目标是可穷举但非平凡:状态空间远大于「14 场景 × 5 结局」,因为状态 = 节点 × 4 个好感度 × 18 个 flag × 9 个物品,朴素上界是 101⁴ × 2¹⁸ × 2⁹;真实可达状态 990 万,正是本卷与卷二反复拿来做教学对象的那一组数。

规格的两处真实特征(不是缺陷,正文按实测发现呈现):① lin / hai 两个角色声明了 affinity,却不在 state.vars 里、也从未被任何选项 add 过——即「声明域 > 状态域」;② d_lt_2 声明了 line_when 却没有 line_else,渲染器按声明规则回落到 line,属规格层面的欠声明。

底座:三处消费的公共入口

三处消费共享同一组入口,本卷要把它们全部讲透:

入口文件本卷位置
规格解析与结构校验sim/na_00_spec_check.py第 2 章、第 13 章
规格字段解析与 SVG 产出sim/na_01_spec_parse.py第 1 章、第 6 章
条件求值与效果应用core/src/inkwell/engine.ts第 9 章、第 11 章
确定性回放与状态摘要sim/na_02_digest.py + core/test/na_02_digest.test.ts第 9 章、第 15 章
张力曲线与比例检验sim/na_06_*.py第 6 章

跨语言 digest 是本套的工程地基:同一选择序列在 Python 与 TypeScript 下推进出的状态摘要逐位相同(500 条路径全等)。这里只对整数状态做摘要,不对文本做——文本涉及编码与换行,把「文本逐字相等」当作判据是不可靠的。

一页规格摘要:这份规格长什么样

目录篇不给完整教程,只给一个秒级可跑的入口,让读者先确认这份规格的规模与不变量。脚本只读 config/story_spec.json,刻意不跑 BFS(完整枚举约 75 秒),打印十二项规模与整数不变量残差:

计数口径分开:JSON 顶层有十段;meta.scale 声明九项统计;下面 scale_of 输出十二项(另列 acts、scene_edges、beats)。旧代码注释和输出中的“九段规模”是历史标签,不是顶层字段数,不能用来当作规格十段完整性的检查。

#!/usr/bin/env python3
# file: sim/na_00_toc_scale.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_00_toc_scale.py
"""目录篇用的一页规格摘要:九段规模 + 整数不变量。

刻意不跑 BFS(na_00_spec_check.py 的完整枚举约 75 秒),本脚本秒级返回,
供目录篇展示「这份规格长什么样、它的不变量残差是不是 0」。
"""
from __future__ import annotations

import json
import os
import sys

AFF_LO, AFF_HI = 0, 100
TENSION_LO, TENSION_HI = 1, 5


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 scale_of(spec: dict) -> dict:
    d = spec["dialogue"]
    return {
        "acts": spec["meta"]["acts"],
        "scenes": len(spec["scene_graph"]["nodes"]),
        "scene_edges": len(spec["scene_graph"]["edges"]),
        "characters": len(spec["characters"]),
        "beats": len(spec["beats"]),
        "dialogue_nodes": len(d),
        "choices": sum(len(n.get("choices") or []) for n in d),
        "flags": len(spec["state"]["flags"]),
        "affinity_vars": len(spec["state"]["vars"]),
        "items": len(spec["state"]["items"]),
        "factions": len(spec["world"]["factions"]),
        "endings": len(spec["endings"]),
    }


def main() -> int:
    spec = load_spec()
    sc = scale_of(spec)

    for k, v in sc.items():
        print(f"{k:<15} {v}")

    # 整数不变量:残差必须恒为 0
    res = 0
    for c in spec["characters"]:
        v = c["affinity"]["init"]
        if not (AFF_LO <= v <= AFF_HI):
            print(f"FAIL {c['id']} 好感度初值越界:{v}")
            res += 1
    for b in spec["beats"]:
        if not (TENSION_LO <= b["tension"] <= TENSION_HI):
            print(f"FAIL 节拍 {b['id']} 张力越界:{b['tension']}")
            res += 1
    for key, seg in (("dialogue", "dialogue"), ("beats", "beats")):
        ids = [n["id"] for n in spec[seg]]
        if len(ids) != len(set(ids)):
            print(f"FAIL {key} 存在重复 id")
            res += 1
    # 场景边与场景节点的引用完整性
    nodes = {n["id"] for n in spec["scene_graph"]["nodes"]}
    for e in spec["scene_graph"]["edges"]:
        if e["from"] not in nodes or e["to"] not in nodes:
            print(f"FAIL 场景边引用不存在的节点:{e['from']} -> {e['to']}")
            res += 1

    cond_edges = sum(1 for e in spec["scene_graph"]["edges"] if e.get("when"))
    cond_choices = sum(1 for n in spec["dialogue"] for c in (n.get("choices") or []) if c.get("when"))
    print(f"无条件/带条件场景边  {len(spec['scene_graph']['edges']) - cond_edges}/{cond_edges}")
    print(f"带条件选项          {cond_choices}/{sc['choices']}")
    print(f"张力合计            {sum(b['tension'] for b in spec['beats'])}")
    print(f"整数不变量残差      {res}")
    if res:
        return 1
    print("OK na_00_toc_scale:九段规模与整数不变量全部通过")
    return 0


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

本机实跑输出:

acts            3
scenes          14
scene_edges     16
characters      6
beats           16
dialogue_nodes  48
choices         115
flags           18
affinity_vars   4
items           9
factions        4
endings         5
无条件/带条件场景边  11/5
带条件选项          34/115
张力合计            52
整数不变量残差      0
OK na_00_toc_scale:九段规模与整数不变量全部通过

判读:规模与上表逐项一致;无条件/带条件场景边 11/5 与 带条件选项 34/115 是第 7、8、9 章的输入;整数不变量残差 0 表示好感度初值、节拍张力、id 唯一性、场景边引用完整性四项全部成立。这个残差在任何一卷里都必须为 0——它不衡量故事好不好,只衡量这份规格有没有自相矛盾。

全书目录(16 章 48 节)

第一部分 · 结构与规格(第 1–4 章)

第二部分 · 弧光、节拍与分支(第 5–8 章)

第三部分 · 条件、数值与文本(第 9–12 章)

第四部分 · 校验与装配(第 13–16 章)

怎么读

  • 想先看主张:读第 1 章 的四构成与第 1.3 节 的「文本是渲染」分工,那两节把整套书的口径讲完。
  • 想直接动手:从第 2 章 开始照字段表写一份自己的 story_spec.json,再按第 13 章的三层校验跑一遍。
  • 想拿结论去说服人:看第 8.3 节 的假选择判定与第 16.1 节 的装配检查表——这两节把「这张故事图值不值得做」变成可核对的清单。
  • 关心后续两卷:卷二《叙事引擎与分支系统》接住「执行」,卷三《叙事体验与路径度量》接住「度量」,接口见第 16.3 节 的交付说明。

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

章章名主档位
1叙事的最小构成D + S
2叙事规格的字段D + P
3世界观与设定的形式化D + S
4角色与关系的形式化D + S
5角色弧光的形式化D + S
6叙事结构与节拍D + S
7场景图与解锁条件D + S
8分支与汇聚D + S
9条件系统D + P
10好感度与关系数值S + P
11对话树的数据结构D + P
12文本作为规格的渲染S + D + W
13叙事规格的校验D + P
14叙事一致性S + B
15叙事工具链与工作流D + P + X
16装配:检查表与交付D + S

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 规格规模的增长