8.2 宽度与深度
Phaser 分支叙事与对话系统 里讨论对话树时,关注的是「一层有几个按钮、树有多深」这类渲染层面的量。本节量的不是 UI,而是结构本身的两个尺度:宽度(一个节点同时可选几个)与深度(从起点到终局走几步)。上一节把 48 个节点连成的图读成了分叉、汇聚与并行;这一节给这张图量尺。
两把尺子:宽度与深度
两个量都定义在图论的量上,但各有一个容易踩的口径:
| 量 | 图论定义 | 口径要点 | 叙事含义 |
|---|---|---|---|
| 宽度 | 节点的出度 | 同时可选的选项数,不是「后续能到达的节点数」 | 玩家每次要做几个决定 |
| 条件宽度 | 出度中带 when 的部分 | 只统计「被条件门住」的选项 | 宽度的多少来自状态而非剧本 |
| 深度 | 从起点到终局的边数 | 数边不数节点,一步 = 一次选择 | 一个周目要按多少次按钮 |
宽度取节点的出度,取的是局部量:它不关心这条选项通向哪里,只问「此刻摆在玩家面前的有几个」。所以宽度是一个每节点一个数的向量,而不是一个全局标量。全局标量要么取均值(每步平均几个选项),要么取最大值(最挤的那一步有几个)。
宽度:一个节点同时可选几个
宽度有两个必须分开的读法:结构宽度(节点上挂了几个选项)与可用宽度(此刻有几个选项的条件成立)。一个出度 5 的节点,如果其中 4 个选项都带 when 且条件互斥,那么玩家每一刻实际只看到 1 到 2 个选项——结构宽度 5,可用宽度 1。
这也解释了为什么要单独统计条件宽度:它量的是「宽度里有多少是被状态解锁出来的」。条件宽度占比高,说明这张图的宽度不是一开始就摊开的,而是随玩家攒下的旗标与物品逐步展开;条件宽度占比低,说明宽度是开场即给的。
深度:数边不数节点
深度必须用边数,不能用节点数,原因很实际:玩家感知的「走了多远」是做了几次选择,而不是「经过了几段文本」。一个没有任何选项的过场节点(出度 0、入度 1)在图上贡献一个节点,却不让玩家做任何决定——把它算进深度会高估长度。
于是深度变成一个图论问题:在有向无环图上,从起点到任一终局的最短路径与最长路径。这两个值给出深度的下界与上界——任何一个周目都至少走最短那么多步,也至多走最长那么多步,不可能落在区间外。区间内的具体分布则取决于玩家的选择。
两种口径:结构路径与实跑采样
深度还有一个更隐蔽的口径分岔,本节要专门处理它:「所有可能的路径」与「玩家实际走的路径」不是同一个集合的等权平均。
| 口径 | 权重 | 回答的问题 |
|---|---|---|
| 结构路径 | 每条路径权重相同 | 这张图理论上能生出多少条不同的选择序列,长度怎么分布 |
| 实跑采样 | 每步在可选项上等概率,路径权重为 1/∏出度 | 玩家实际走出来的步数怎么分布 |
两者的差不是误差,而是两个不同的量:结构口径把所有路径一视同仁,实跑口径里「每一步都要在若干选项里挑一个」这个动作本身就在压低长路径的概率——路径越长,穿过的决策点越多,每一处都被摊薄一次。下面实跑出来的两组数会把这件事摆得很清楚。
S 层:量宽度、深度与两条深度分布
下面这段是 S 层脚本 sim/na_08_2_width_depth.py。它做四件事:算出度得到宽度向量、在 DAG 上用 DP 精确算最短/最长路径与结构路径总数、再按长度对全部结构路径精确计数、最后用固定种子的采样走 20000 条路径得到实跑分布。结构路径总数与按长度计数的总和互为校验,宽度越界也非零退出。
#!/usr/bin/env python3
# file: sim/na_08_2_width_depth.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_08_2_width_depth.py
"""卷一 8.2 宽度与深度(S 层):量分支的「每步多宽」与「一共多深」。
宽度 width 一个节点同时可选的选项数(出度);另分「条件宽度」(带 when 的选项数)
深度 depth 从起点到结局走过的边数:图论上取最短 / 最长路径,实跑上取采样分布
路径条数 把所有 when 都当真的结构计数(选择序列条数),用 DAG 上的 DP 精确算
状态空间 读 out/spec_check.json(na_00 的 BFS 口径)作为对照量
产出 out/narr08_width_depth.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")
SAMPLE_N = 20000
SEED = 20261016
def eval_cond(c, st) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return st["flags"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = st["aff"].get(c[1], 0), c[2], c[3]
return {">=": v >= n, "<": v < n, "==": v == n, "!=": v != n}[op]
if k == "item":
return st["items"].get(c[1], 0) == c[2]
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)
return not eval_cond(c[1], st)
def apply_effects(eff: dict, st: dict) -> dict:
aff, flags, items = dict(st["aff"]), dict(st["flags"]), dict(st["items"])
for k in (eff.get("set") or {}):
flags[k] = eff["set"][k]
for k in (eff.get("add") or {}):
aff[k] = max(0, min(100, aff.get(k, 0) + eff["add"][k]))
for k in (eff.get("grant") or {}):
items[k] = eff["grant"][k]
for k in (eff.get("revoke") or {}):
items[k] = 0
return {"aff": aff, "flags": flags, "items": items}
def main() -> int:
spec = json.load(open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8"))
nodes = spec["dialogue"]
by = {n["id"]: n for n in nodes}
start = spec["meta"]["start"]
width = {n["id"]: len(n.get("choices") or []) for n in nodes}
cond = {n["id"]: sum(1 for c in (n.get("choices") or []) if c.get("when")) for n in nodes}
bad = [f"宽度越界 {k}={v}" for k, v in width.items() if not (0 <= v <= 5)]
memo: dict = {}
def dp(nid: str, f: str) -> int:
key = (nid, f)
if key in memo:
return memo[key]
ch = by[nid].get("choices") or []
if not ch:
v = 1 if f == "paths" else 0
elif f == "paths":
v = sum(dp(c["goto"], "paths") for c in ch)
elif f == "short":
v = 1 + min(dp(c["goto"], "short") for c in ch)
else:
v = 1 + max(dp(c["goto"], "long") for c in ch)
memo[key] = v
return v
paths_total = dp(start, "paths")
dmin, dmax = dp(start, "short"), dp(start, "long")
# 结构路径按长度精确计数(每条路径等权),与 dp(paths) 的总数互为校验
lmem: dict = {}
def lens(nid: str) -> dict:
if nid in lmem:
return lmem[nid]
ch = by[nid].get("choices") or []
r = {0: 1} if not ch else {}
for c in ch:
for L, k in lens(c["goto"]).items():
r[L + 1] = r.get(L + 1, 0) + k
lmem[nid] = r
return r
all_len = lens(start)
if sum(all_len.values()) != paths_total:
bad.append(f"结构路径总数 {sum(all_len.values())} != dp {paths_total}")
aff0 = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in sorted(spec["state"]["vars"])}
init = {"aff": aff0,
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])}}
rng = SEED
def rnd(mod: int) -> int:
nonlocal rng
rng = (rng * 48271) % 2147483647
return rng % mod
hist: dict = {}
end_hits: dict = {e["name"]: 0 for e in spec["endings"]}
step_sum, step_min, step_max = 0, 10 ** 9, 0
for _ in range(SAMPLE_N):
node, st, steps = start, init, 0
for _s in range(200):
cur = by[node]
if cur.get("ending"):
end_hits[cur["ending"]] += 1
break
opts = [c for c in (cur.get("choices") or []) if eval_cond(c.get("when"), st)]
c = opts[rnd(len(opts))]
st = apply_effects(c.get("effects") or {}, st)
node = c["goto"]
steps += 1
hist[steps] = hist.get(steps, 0) + 1
step_sum += steps
step_min, step_max = min(step_min, steps), max(step_max, steps)
cn = [n["id"] for n in nodes if (n.get("choices") or [])]
wsum = sum(width[n] for n in cn)
csum = sum(cond[n["id"]] for n in nodes)
wmax = max(width.values())
print(f"nodes={len(nodes)} 有选项节点 {len(cn)} 结局节点 5")
print(f"宽度:总选项 {wsum} / 有选项节点 {len(cn)} = {wsum / len(cn):.2f}"
f"(整数比 {wsum}/{len(cn)}) 最大 {wmax} 在 "
f"{sorted(n for n in width if width[n] == wmax)}")
print(f"带条件选项 {csum}/{wsum}(条件宽度占比 {csum * 1000 // wsum}‰)")
print(f"深度(边数):最短 {dmin} 最长 {dmax} 比值 {dmax}/{dmin}")
print(f"结构路径条数(忽略 when):{paths_total}")
print(f"全部结构路径按长度精确计数(每条路径等权,总 {sum(all_len.values())} 条):")
for k in sorted(all_len):
print(f" {k:>2} 步 {all_len[k]:>12} 条 {all_len[k] * 1000 // paths_total}‰")
print(f"采样 {SAMPLE_N} 条(每步在可选项上等概率,故每条路径权重 1/∏出度):"
f"步数 min={step_min} max={step_max} "
f"均值={step_sum / SAMPLE_N:.2f}(整数比 {step_sum}/{SAMPLE_N})")
for k in sorted(hist):
print(f" {k:>2} 步 {hist[k]:>5} 条 {hist[k] * 1000 // SAMPLE_N}‰")
print(f"结局分布(采样):{end_hits}")
sc_path = os.path.join(OUT, "spec_check.json")
if os.path.exists(sc_path):
sc = json.load(open(sc_path, encoding="utf-8"))
print(f"对照:na_00 BFS 口径可达节点-状态对 {sc.get('states')}"
f"(路径条数是组合数,状态空间是去重后的节点-状态对)")
else:
print("对照:先跑 na_00_spec_check.py 再对照状态空间")
rows = [{"id": n["id"], "scene": n["scene"], "width": width[n["id"]],
"cond_width": cond[n["id"]], "shortest_to_end": dp(n["id"], "short"),
"longest_to_end": dp(n["id"], "long"),
"paths_from_here": dp(n["id"], "paths")} for n in nodes]
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "narr08_width_depth.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_width_depth.csv rows={len(rows)}")
for e in bad:
print(f"FAIL {e}")
if bad:
print(f"== na_08_2_width_depth:FAIL {len(bad)}")
return 1
print("OK na_08_2_width_depth:宽度 / 深度清点一致")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_08_2_width_depth.py:
nodes=48 有选项节点 43 结局节点 5
宽度:总选项 115 / 有选项节点 43 = 2.67(整数比 115/43) 最大 5 在 ['d_dq_2']
带条件选项 34/115(条件宽度占比 295‰)
深度(边数):最短 22 最长 31 比值 31/22
结构路径条数(忽略 when):450208471680
全部结构路径按长度精确计数(每条路径等权,总 450208471680 条):
22 步 226748160 条 0‰
25 步 9183300480 条 20‰
28 步 110199605760 条 244‰
31 步 330598817280 条 734‰
采样 20000 条(每步在可选项上等概率,故每条路径权重 1/∏出度):步数 min=22 max=31 均值=25.41(整数比 508271/20000)
22 步 4950 条 247‰
25 步 8379 条 418‰
28 步 5635 条 281‰
31 步 1036 条 51‰
结局分布(采样):{'灯守': 1018, '离港': 10074, '真相': 178, '缄默': 3223, '沉没': 5507}
对照:na_00 BFS 口径可达节点-状态对 9895423(路径条数是组合数,状态空间是去重后的节点-状态对)
wrote out/narr08_width_depth.csv rows=48
OK na_08_2_width_depth:宽度 / 深度清点一致
判读:四个数字读出的结构
宽度 2.67,最大 5,且最大只在 d_dq_2 一处。 43 个带选项的节点一共挂 115 个选项,均值 2.67——每步平均不到三个选项。最挤的一步是 d_dq_2,五个选项分别指向五个结局,那是收束点而不是普通岔路。除它之外没有节点超过 3 个选项(脚本把上界写成 5 并在越界时非零退出,实测未触发)。这个宽度是刻意的克制:2 到 3 个选项是一个玩家能在不翻回上文的情况下比较完的数量。
条件宽度 34/115,占 295‰。 115 个选项里有 34 个带 when,接近三成。这意味着宽度里约 29.5% 的部分不是「摆在那里的」,而是状态解锁出来的——玩家早期看到的选项比后期少,宽度随进度增长。这与上一节「34 个扇出节点」的数字遥相呼应:多数选择不改去向,改的是状态;而状态又反过来决定后续能看到哪些选项。
深度 22 到 31 步,比值 31/22。 最短路径 22 条边,最长 31 条边,任何周目都落在这 9 步的区间内。9 步的浮动不算大——这再次印证了上一节的判断:这是一张局部并行、全局收束的图,绕行是短支而非长支。
两条深度分布几乎相反,这是本节最值得记住的一行。 结构口径下,73.4% 的路径是最长的 31 步,只有 0‰(不足千分之一)是 22 步;实跑口径下,反而是 24.7% 落在 22 步,31 步只占 5.1%,众数在 25 步(41.8%),均值 25.41。两个口径给出的「典型长度」差了整整一档。
为什么会反过来:实跑口径里每条路径的权重是 1/∏出度,路径越长穿过的决策点越多,被摊薄的次数越多。结构上数量最多的那一档(31 步)恰恰是穿过决策点最多的一档,所以被压得最狠。「路径条数多」不等于「玩家容易走到」——这是两条口径必须同时报的原因。任何只给一个数就下结论的深度分析,都可能把「数量最多」误读成「体验上最常见」。
450208471680 与 9895423 相差约 4.7 个数量级。 前者是结构路径条数(忽略条件、按组合计),后者是 na_00 口径下去重后的可达节点-状态对数。两个数都对,但回答的问题完全不同:路径条数随深度指数膨胀,状态空间把「殊途同归」的路径合并了。叙事设计真正要控制的规模是后者——玩家能到达的不同局面数,而不是选择序列数。下一节正是站在这个数上做判定。
自检表
| 检查项 | 合格标准 |
|---|---|
| 宽度口径 | 宽度 = 出度,不用「可达节点数」冒充 |
| 条件宽度 | 带 when 的选项单独统计,与结构宽度分列 |
| 深度口径 | 深度按边数计,不按节点数 |
| 深度双报 | 结构路径长度分布与实跑采样分布必须同时给出,并说明权重差异 |
| 总数互校 | 按长度精确计数的总和必须等于 DP 算出的结构路径总数 |
宽度与深度量完,我们知道了这张图每步多宽、一共多深,也知道「结构上最多的路径」与「实跑最常走的路径」不是同一批。但还有一个更尖锐的问题没问:115 个选项里,有多少个其实是摆设——选了它和没选它,后面能到达的结局集合完全一样。下一节把这个判定做成一个可跑的算法。
阅读导航:上一节:8.1 分支拓扑:分叉、汇聚、并行 · 下一节:8.3 假选择与真后果的判定 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。