当前复现状态(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 穷举可达状态集 → 覆盖率与结局分布 → 张力曲线与 MAE | JSON / CSV / 纯 xml.etree SVG | 脚本 exit 0;SVG 可回读;整数不变量:好感度 ∈ [0,100]、flag 为布尔、tension ∈ 1–5、同输入确定性 |
| P | TypeScript 叙事引擎内核(条件求值 / 状态推进 / 确定性回放 / 跨语言摘要) | core/test/*.test.ts | node --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 |
| 节拍 beat | 16(tension 之和 = 52) |
| 对话节点 | 48(其中 5 个是结局节点) |
| 选项 | 115(其中 34 个带条件) |
| flag | 18 |
| 好感度变量 | 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 章)
- 第 1 章 叙事的最小构成
- 第 2 章 叙事规格的字段
- 第 3 章 世界观与设定的形式化
- 第 4 章 角色与关系的形式化
第二部分 · 弧光、节拍与分支(第 5–8 章)
- 第 5 章 角色弧光的形式化
- 第 6 章 叙事结构与节拍
- 第 7 章 场景图与解锁条件
- 第 8 章 分支与汇聚
第三部分 · 条件、数值与文本(第 9–12 章)
- 第 9 章 条件系统
- 第 10 章 好感度与关系数值
- 第 11 章 对话树的数据结构
- 第 12 章 文本作为规格的渲染
第四部分 · 校验与装配(第 13–16 章)
第 13 章 叙事规格的校验
第 14 章 叙事一致性
第 15 章 叙事工具链与工作流
第 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 与指标扫描需要较长时间,未运行或超时不会计作通过。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。