8.1 分支拓扑:分叉、汇聚、并行
Phaser 分支叙事与对话系统 那一侧关心的是「对话树怎么在引擎里跑起来」——数据结构、节点推进、选项渲染。本节在它下面一层:不问引擎怎么执行,只问这张图长什么形状。前七章把叙事的构成、节拍、条件与顺序约束都落成了可校验的数据;到了第八章,问题换了一个角度——不看单个节点写了什么,看节点与节点之间的连线排布。这一节处理最基础的一层:拓扑原语。
三种原语:分叉、汇聚、并行
把 dialogue 段的 48 个节点读成有向图:节点是对话节点,边是选项(choice 从当前节点指向它的 goto)。在这个视角下,叙事里所有「线」的走向都可以拆成三种原语:
| 原语 | 图论口径 | 叙事含义 | 判据 |
|---|---|---|---|
| 分叉 fork | 出度 ≥ 2 且目标不唯一 | 一次真正的岔路:不同选项通向不同节点 | distinct(goto) >= 2 |
| 汇聚 merge | 入度 ≥ 2(前驱去重) | 多条线收拢到同一个节点,故事重新合流 | len(preds) >= 2 |
| 并行 parallel | 同一分叉点分出的两条去向互不可达 | 两条线各自推进,彼此不干扰 | b ∉ reach(a) 且 a ∉ reach(b) |
三者互不替代。分叉管「有没有岔路」,汇聚管「岔路有没有收回来」,并行管「岔开的线之间会不会互相踩到」。一个只有分叉、没有汇聚的结构是纯树:每一层的选择都会永久分裂世界,节点总数随深度指数膨胀。一个只有分叉、没有并行的结构是纠缠图:两条看似分开的线在几跳之后又能互相跳到,玩家会觉得「我刚做的选择好像不算数」。
分叉不是扇出:一个必须先分开的口径
出度 ≥ 2 并不等于分叉。同一个节点上挂两个选项、但两个选项的 goto 指向同一个节点,这在图上是「出度 2、去向 1」——本节把它单列一类,叫扇出 fanout。
| 口径 | 出度 | 去向数 | 是否改变后续 |
|---|---|---|---|
| 分叉 fork | ≥ 2 | ≥ 2 | 是:走哪条选项,后面看的节点不同 |
| 扇出 fanout | ≥ 2 | 1 | 否:去向相同,差别只在选项自身的效果 |
这个区分在《雾港夜航》里格外重要:全站 43 个带选项的节点中,只有 9 个是分叉,另外 34 个是扇出。也就是说多数选择不改变你接下来看到什么,只改变你带着什么状态走下去。这不是设计缺陷,而是两种不同的手段:分叉管「剧情走向」,扇出管「状态记账」。但它意味着——只数出度会严重高估这张图的分支程度。8.3 会把这件事算到底。
强制汇流:用支配点找出「必经之路」
汇聚还有强弱之分。一个节点入度 ≥ 2 只说明有人汇进来;如果从起点到任一结局的每条路径都必须经过它,那它是强制汇流点——玩家无论怎么选都会撞上它。
这就是编译原理里的支配点(dominator):m 支配 n,当且仅当从起点到 n 的每条路径都经过 m。在有向无环图上用迭代法求不动点即可:先把每个节点的支配集初始化为全部可达节点,起点支配集为 {起点},然后反复用 dom[n] = ⋂ dom[p] ∪ {n}(p 取 n 的全部前驱)更新,直到不再变化。把 5 个结局节点的支配集取交集,得到的就是「所有结局都必经」的节点集合。
为什么用交集而不是逐个判断:结局是终点,没有后继,所以「到某个结局的路径」等价于「到该结局节点本身」。取 5 个结局的支配集交集,正好是「到每个结局都必须经过」的节点——这正是强制汇流的定义。
S 层:清点 48 个节点
下面这段是 S 层脚本 sim/na_08_1_topology.py。它读 config/story_spec.json,建出边表与邻接表,算出度、去重入度、支配集,然后打印三类原语的清单与并行去向对,并写出 48 行拓扑表。节点数与边数对不上 meta.scale 就非零退出——拓扑清点必须先确认自己读的是完整图。
#!/usr/bin/env python3
# file: sim/na_08_1_topology.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_08_1_topology.py
"""卷一 8.1 分支拓扑(S 层):把 48 个对话节点读成有向图,量三种拓扑原语。
分叉 fork 出度 >= 2 且目标不唯一(一出多,真的分出不同去向)
扇出 fanout 出度 >= 2 但全部指向同一目标(多个选项共用一个去向)
汇聚 merge 入度 >= 2(多入一,由不同前驱汇到一起)
强制汇流 入度 >= 2 且是支配点:从起点到任一结局的路径都必经它
并行 同一分叉点分出的两条去向互不可达(两条线彼此不干扰)
产出 out/narr08_topology.csv(48 行)与控制台摘要;节点/边计数不符即非零退出。
"""
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")
SPEC = os.path.join(ROOT, "config/story_spec.json")
def reachable(succ: dict, start: str) -> set:
seen, stack = set(), [start]
while stack:
x = stack.pop()
if x not in seen:
seen.add(x)
stack.extend(succ[x])
return seen
def dominators(succ: dict, preds: dict, start: str) -> dict:
"""n 被 m 支配 <=> 从起点到 n 的每条路径都经过 m。DAG 上迭代求不动点。"""
r = reachable(succ, start)
dom = {n: set(r) for n in r}
dom[start] = {start}
changed = True
while changed:
changed = False
for n in sorted(r):
ps = [p for p in preds[n] if p in r]
if n == start or not ps:
continue
new = set.intersection(*(dom[p] for p in ps)) | {n}
if new != dom[n]:
dom[n] = new
changed = True
return dom
def nearest_join(succ: dict, a: str, b: str):
"""a、b 分出的两条线「最近重逢」的节点与跳数:BFS 序遇到的第一个共同后继。"""
both = reachable(succ, a) & reachable(succ, b)
seen, queue = {a}, [(a, 0)]
while queue:
x, d = queue.pop(0)
if x in both:
return x, d
for y in succ[x]:
if y not in seen:
seen.add(y)
queue.append((y, d + 1))
return None, None
def main() -> int:
spec = json.load(open(SPEC, encoding="utf-8"))
nodes = spec["dialogue"]
by = {n["id"]: n for n in nodes}
start = spec["meta"]["start"]
scale = spec["meta"]["scale"]
succ = {n["id"]: [] for n in nodes}
preds = {n["id"]: set() for n in nodes}
edges = []
for n in nodes:
for c in (n.get("choices") or []):
succ[n["id"]].append(c["goto"])
preds[c["goto"]].add(n["id"])
edges.append((n["id"], c["id"], c["goto"]))
preds = {k: sorted(v) for k, v in preds.items()}
out_deg = {n["id"]: len(n.get("choices") or []) for n in nodes}
dst = {n["id"]: len({c["goto"] for c in (n.get("choices") or [])}) for n in nodes}
in_deg = {n["id"]: len(preds[n["id"]]) for n in nodes}
leaves = sorted(n["id"] for n in nodes if out_deg[n["id"]] == 0)
end_dom = set.intersection(*(dominators(succ, preds, start)[e] for e in leaves))
forks = sorted(n for n in out_deg if dst[n] >= 2)
fanouts = sorted(n for n in out_deg if out_deg[n] >= 2 and dst[n] == 1)
merges = sorted(n for n in in_deg if in_deg[n] >= 2)
forced = sorted(n for n in merges if n in end_dom)
parallel = []
for f in forks:
tg = sorted({c["goto"] for c in by[f]["choices"]})
for i in range(len(tg)):
for j in range(i + 1, len(tg)):
a, b = tg[i], tg[j]
if b not in reachable(succ, a) and a not in reachable(succ, b):
parallel.append((f, a, b))
mid = [p for p in parallel if p[1] not in leaves and p[2] not in leaves]
bad = []
if len(nodes) != scale["dialogue_nodes"]:
bad.append(f"节点数 {len(nodes)} != scale {scale['dialogue_nodes']}")
if len(edges) != scale["choices"]:
bad.append(f"边数 {len(edges)} != scale {scale['choices']}")
print(f"nodes={len(nodes)} edges={len(edges)} start={start}")
print(f"分叉 fork(出度>=2 且目标不唯一) {len(forks)} {forks}")
print(f"扇出 fanout(出度>=2 但目标唯一) {len(fanouts)}")
print(f"汇聚 merge(入度>=2) {len(merges)} {merges}")
print(f"强制汇流(入度>=2 且所有路径必经) {len(forced)} {forced}")
print(f"结局节点 leaf(出度=0) {len(leaves)} {leaves}")
print(f"并行去向对 {len(parallel)}(终局对 {len(parallel) - len(mid)}、中途并行 {len(mid)})")
for f, a, b in mid:
j, d = nearest_join(succ, a, b)
print(f" {f} -> {a} ∥ {b} 最近重逢 {j}({d} 跳)")
rows = [{"id": n["id"], "scene": n["scene"], "speaker": n.get("speaker", "-"),
"in_deg": in_deg[n["id"]], "out_deg": out_deg[n["id"]], "distinct": dst[n["id"]],
"role": ("ending" if n.get("ending") else "forced-merge" if n["id"] in forced
else "fork" if n["id"] in forks else "merge" if n["id"] in merges
else "fanout" if n["id"] in fanouts else "chain")} for n in nodes]
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "narr08_topology.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=list(rows[0]))
w.writeheader()
w.writerows(rows)
print(f"wrote out/narr08_topology.csv rows={len(rows)}")
for e in bad:
print(f"FAIL {e}")
if bad:
print(f"== na_08_1_topology:FAIL {len(bad)}")
return 1
print("OK na_08_1_topology:拓扑原语清点一致")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_08_1_topology.py:
nodes=48 edges=115 start=d_pier_1
分叉 fork(出度>=2 且目标不唯一) 9 ['d_bw_3', 'd_dq_2', 'd_hub_a1', 'd_hub_a2', 'd_hub_b2', 'd_lb_3', 'd_lt_3', 'd_mr_3', 'd_pier_2']
扇出 fanout(出度>=2 但目标唯一) 34
汇聚 merge(入度>=2) 8 ['d_bw_1', 'd_dq_1', 'd_fg_1', 'd_ho_1', 'd_hub_a1', 'd_hub_a2', 'd_hub_b2', 'd_mr_1']
强制汇流(入度>=2 且所有路径必经) 4 ['d_dq_1', 'd_hub_a1', 'd_hub_a2', 'd_hub_b2']
结局节点 leaf(出度=0) 5 ['e_dark', 'e_lit', 'e_loss', 'e_silence', 'e_truth']
并行去向对 14(终局对 10、中途并行 4)
d_hub_a1 -> d_ch_1 ∥ d_wp_1 最近重逢 d_hub_a2(3 跳)
d_lb_3 -> d_mr_1 ∥ d_tc_1 最近重逢 d_ho_1(3 跳)
d_mr_3 -> d_db_1 ∥ d_ho_1 最近重逢 d_hub_b2(3 跳)
d_pier_2 -> d_chapel_1 ∥ d_tavern_1 最近重逢 d_hub_a1(3 跳)
wrote out/narr08_topology.csv rows=48
OK na_08_1_topology:拓扑原语清点一致
判读:48 个节点读出了什么
把三行数字摊开看,这张图的结构比「48 个节点、115 个选项」这个总量描述要具体得多。
分叉只有 9 个,而且集中。 9 个分叉点是 d_pier_2、d_lb_3、d_hub_a1、d_hub_a2、d_hub_b2、d_mr_3、d_lt_3、d_bw_3、d_dq_2。其中 4 个带 hub_ 或 dq_ 前缀——它们是结构上的枢纽,其余是幕内的小分岔。48 个节点里只有 9 个真正分岔,意味着这张图的骨架是一条主干 + 若干短支,而不是一棵分形树。
汇聚 8 个,强制汇流 4 个。 8 个汇聚点是 d_dq_1、d_hub_a1、d_hub_a2、d_hub_b2、d_bw_1、d_fg_1、d_ho_1、d_mr_1。注意 d_hub_a1、d_hub_a2、d_hub_b2、d_dq_1 四个同时出现在分叉表与汇聚表里——它们既收线又放线,是枢纽节点;另外四个(d_bw_1、d_fg_1、d_ho_1、d_mr_1)各挂 3 个选项、但 3 个选项指向同一个去向,按本节前面区分扇出时的口径属于扇出,是纯粹的收束点。
四个强制汇流点是 d_dq_1、d_hub_a1、d_hub_a2、d_hub_b2——它们同时满足「入度 ≥ 2」与「到任一结局的路径都必经」。这是本节最有信息量的一行:故事有四个玩家无论如何都躲不开的节点。按所属场景读,d_hub_a1 与 d_hub_b2 挂在 pier、d_hub_a2 挂在 watch_post,三个都在第一夜的场景里;d_dq_1 挂在 dawn_quay,是第三夜黎明码头的那道。四个节点把整张图切成了四段:每一个强制汇流点都是一个「无论如何都会到达」的里程碑,玩家在不同支线上的经历都会在这四个点上被重新对齐。
并行 14 对,其中中途并行只有 4 对。 14 对里有 10 对是「分叉点直接分出两个结局」,那不是并行,那是互斥结局(e_lit 与 e_truth 当然互不可达)。真正值得看的是中途并行的 4 对,脚本对每对都算了「最近重逢」的节点与跳数:
| 分叉点 | 两条去向 | 最近重逢 | 读法 |
|---|---|---|---|
d_hub_a1 | d_ch_1 ∥ d_wp_1 | d_hub_a2(3 跳) | 账房线与守夜线,两条都在第一夜 |
d_lb_3 | d_mr_1 ∥ d_tc_1 | d_ho_1(3 跳) | 第二夜的市场线与潮汐线 |
d_mr_3 | d_db_1 ∥ d_ho_1 | d_hub_b2(3 跳) | 第二夜的沉钟线与港务线 |
d_pier_2 | d_chapel_1 ∥ d_tavern_1 | d_hub_a1(3 跳) | 第一夜的礼拜堂线与酒馆线 |
四对并行的重逢跳数都是 3,且四个重逢点里有三个是强制汇流点(d_hub_a1、d_hub_a2、d_hub_b2),只有 d_ho_1 是普通汇聚点。这说明这张图的并行是局部的:两条线分开走 3 跳就重新相遇。这正是「局部并行 + 全局收束」的形状——玩家在短区间里有真实的空间感,但叙事主线不会永久分家。
反过来说,没有一处「两条线永远不相遇」的中途并行。如果存在,那就是一张可以各自独立成篇的子故事图,而不是一个统一的故事。
自检表
| 检查项 | 合格标准 |
|---|---|
| 口径分离 | 分叉(去向 ≥ 2)与扇出(去向 = 1)分开统计,不混成一个数 |
| 入度去重 | 入度按前驱去重统计,不按边数——同一前驱的两条边只算一次 |
| 强制汇流 | 用支配点交集判定,而不是「入度大就算」 |
| 并行判定 | 双向不可达才算并行,单向可达只算「先后」 |
| 计数守卫 | 节点数/边数与 meta.scale 不一致即非零退出 |
拓扑清点给了一张骨架图,但它只回答「线怎么连」。每条线有多长、每个岔口有多宽是另一组量:宽度决定玩家每次要读几个选项,深度决定一个周目要走多少步。下一节把这两把尺子架上。
阅读导航:上一节:7.3 顺序约束与解锁 · 下一节:8.2 宽度与深度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。