7.1 分支宽度的度量
玩法机制与可执行规格 那套控制的是机制组合爆炸——伤害公式、资源、随机的参数组合在数值空间里乘起来。本章控制的是另一种爆炸:分支组合爆炸,对话图上的路径数在图上乘起来。两者都用采样上界与剪枝,但对象一个是数值空间、一个是图。要控制它,先得把它量出来;这一节给这张对话图量第一把尺子:每个节点同时摆几个选项。
宽度的三个口径
宽度在图论上就是出度——一个节点同时可选的选项数。但它有三个必须分开的口径,混着看会得出互相矛盾的结论:
| 口径 | 定义 | 回答的问题 |
|---|---|---|
| 出度直方图 | 48 个节点各挂几个选项,落在哪几个档 | 这张图的宽度是「开场即给」还是「逐步展开」 |
| 最宽节点 | 出度最大的那些节点 | 最挤的一步在哪里,是不是收束点 |
| 分层宽度 | 按边距离把节点分层,看每层总宽度 | 宽度在纵深上怎么分布 |
还有一个「去重」口径要单独拎出来:选项数不等于边数。同一对节点之间可以挂多个条件互斥的选项,它们在图上只算一条边(一个 (起点, 目标) 二元组)。所以「115 个选项」与「56 条跳转」是同一个图的两种数法,宽度要在两个口径上分别报。
分层宽度里的「层」用边距离定义:从起点出发,走几条边能到这个节点,就归到第几层。一步 = 一次选择,所以层号就是「走到这一步最少按了几次」。
S 层:出度直方图、最宽节点、分层宽度
脚本 sim/ne_07_1_width.py 把上面三个口径一次跑齐:算出度得到直方图、挑出最宽的节点、用 BFS 求每个节点的边距离再分层汇总,最后统计去重跳转数与不同目标数。它还顺手把直方图渲染成一张纯 xml.etree 的 SVG。
#!/usr/bin/env python3
# file: sim/ne_07_1_width.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_07_1_width.py
"""卷二 7.1 分支宽度的度量(S 层):给这张对话图量「每步多宽」。
宽度 = 一个节点的**出度**(同时可选的选项数),它有三个必须分开的口径:
出度直方图 48 个节点各挂几个选项,落在哪几个档
最宽节点 出度最大的那些节点(最挤的一步)
分层宽度 按「从起点出发的边距离」把节点分层,看每一层的总宽度 / 平均宽度
另算一个「去重」口径:115 个选项里,真正**不同的跳转**有多少条。
一条跳转 = (起点节点, 目标节点) 这个二元组——同一对节点之间挂多个条件互斥的选项,
在图上只算**一条边**。所以「选项数」不等于「边数」,宽度要在两个口径上分别看。
产出 out/ne_07_1_width.json + out/ne_07_1_width.csv + out/ne_07_1_width.svg(纯 xml.etree)。
只读 config/story_spec.json。exit 0 表示宽度清点自洽(出度 ∈ [0,5]、选项总数 = 115)。
"""
from __future__ import annotations
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
from collections import Counter, deque
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
JSON_PATH = os.path.join(OUT, "ne_07_1_width.json")
CSV_PATH = os.path.join(OUT, "ne_07_1_width.csv")
SVG_PATH = os.path.join(OUT, "ne_07_1_width.svg")
WIDTH_MAX = 5
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def bfs_levels(by: dict, start: str) -> dict:
"""从起点出发的**边距离**(最短步数);一步 = 一次选择。"""
dist = {start: 0}
q = deque([start])
while q:
u = q.popleft()
for c in (by[u].get("choices") or []):
v = c["goto"]
if v not in dist:
dist[v] = dist[u] + 1
q.append(v)
return dist
def svg_hist(hist: dict) -> str:
"""出度直方图的纯 xml.etree SVG(不依赖 matplotlib)。"""
keys = sorted(hist)
W, H, PAD, BW = 520, 300, 48, 72
top = max(hist.values())
plot_h = H - 2 * PAD
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg",
"width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W),
"height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": str(W // 2), "y": "24",
"font-size": "15", "text-anchor": "middle"}) .text = \
"出度直方图(48 个对话节点)"
for i, k in enumerate(keys):
n = hist[k]
h = int(plot_h * n / top)
x = PAD + 40 + i * BW
y = H - PAD - h
ET.SubElement(svg, "rect", {"x": str(x), "y": str(y), "width": "48",
"height": str(h), "fill": "#3b6ea5"})
ET.SubElement(svg, "text", {"x": str(x + 24), "y": str(y - 6),
"font-size": "12", "text-anchor": "middle"}).text = str(n)
ET.SubElement(svg, "text", {"x": str(x + 24), "y": str(H - PAD + 18),
"font-size": "12", "text-anchor": "middle"}).text = f"d={k}"
ET.SubElement(svg, "line", {"x1": str(PAD + 40), "y1": str(H - PAD),
"x2": str(W - PAD), "y2": str(H - PAD),
"stroke": "#333", "stroke-width": "1"})
return ET.tostring(svg, encoding="unicode")
def main() -> int:
spec = load_spec()
nodes = spec["dialogue"]
by = {n["id"]: n for n in nodes}
start = spec["meta"]["start"]
outdeg = {n["id"]: len(n.get("choices") or []) for n in nodes}
hist = dict(sorted(Counter(outdeg.values()).items()))
total_choices = sum(outdeg.values())
widest = sorted(k for k, v in outdeg.items() if v == max(outdeg.values()))
allc = [(n["id"], c) for n in nodes for c in (n.get("choices") or [])]
jumps = {(nid, c["goto"]) for nid, c in allc}
targets = {c["goto"] for _, c in allc}
dist = bfs_levels(by, start)
layers: dict = {}
for n in nodes:
l = dist.get(n["id"])
if l is None:
continue
layers.setdefault(l, []).append(outdeg[n["id"]])
layer_rows = []
for l in sorted(layers):
vs = layers[l]
layer_rows.append({"level": l, "nodes": len(vs), "choices": sum(vs),
"max_width": max(vs), "sum_over_nodes": f"{sum(vs)}/{len(vs)}"})
unreached = [n["id"] for n in nodes if n["id"] not in dist]
bad = [f"出度越界 {k}={v}" for k, v in outdeg.items() if not (0 <= v <= WIDTH_MAX)]
if total_choices != spec["meta"]["scale"]["choices"]:
bad.append(f"选项总数 {total_choices} != scale.choices {spec['meta']['scale']['choices']}")
if sum(hist.values()) != len(nodes):
bad.append(f"直方图求和 {sum(hist.values())} != 节点数 {len(nodes)}")
os.makedirs(OUT, exist_ok=True)
report = {
"nodes": len(nodes),
"out_degree_histogram": {str(k): v for k, v in hist.items()},
"max_width": max(outdeg.values()),
"widest_nodes": widest,
"total_choices": total_choices,
"unique_jumps_node_goto": len(jumps),
"distinct_targets": len(targets),
"layers": len(layer_rows),
"layer_rows": layer_rows,
"unreached_nodes": unreached,
}
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["id", "scene", "out_degree", "level"])
w.writeheader()
for n in nodes:
w.writerow({"id": n["id"], "scene": n["scene"],
"out_degree": outdeg[n["id"]], "level": dist.get(n["id"], "")})
with open(SVG_PATH, "w", encoding="utf-8") as fh:
fh.write(svg_hist(hist) + "\n")
print(f"对话节点 {len(nodes)} 有选项节点 {sum(1 for v in outdeg.values() if v)}"
f" 结局节点 {sum(1 for v in outdeg.values() if v == 0)}")
print(f"出度直方图:{hist}")
print(f"最宽节点 出度 {max(outdeg.values())}:{widest}")
print(f"选项总数 {total_choices} 去重跳转 (节点,目标) {len(jumps)} 不同目标节点 {len(targets)}")
print(f"分层(边距离){len(layer_rows)} 层,从 L0 到 L{max(dist.values())}")
for r in layer_rows:
print(f" L{r['level']:>2} 节点 {r['nodes']:>2} 宽度和 {r['choices']:>3}"
f" 最大 {r['max_width']} 平均 {r['sum_over_nodes']}")
print(f"wrote out/ne_07_1_width.json / .csv / .svg")
for e in bad:
print(f"FAIL {e}")
if bad:
return 1
print(f"OK ne_07_1_width:宽度直方图 {hist},最宽 {max(outdeg.values())} 在 {widest},"
f"{total_choices} 选项去重后 {len(jumps)} 条跳转")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_07_1_width.py:
对话节点 48 有选项节点 43 结局节点 5
出度直方图:{0: 5, 2: 16, 3: 26, 5: 1}
最宽节点 出度 5:['d_dq_2']
选项总数 115 去重跳转 (节点,目标) 56 不同目标节点 47
分层(边距离)23 层,从 L0 到 L22
L 0 节点 1 宽度和 2 最大 2 平均 2/1
L 1 节点 1 宽度和 2 最大 2 平均 2/1
L 2 节点 2 宽度和 6 最大 3 平均 6/2
L 3 节点 2 宽度和 6 最大 3 平均 6/2
L 4 节点 2 宽度和 4 最大 2 平均 4/2
L 5 节点 1 宽度和 2 最大 2 平均 2/1
L 6 节点 2 宽度和 6 最大 3 平均 6/2
L 7 节点 2 宽度和 6 最大 3 平均 6/2
L 8 节点 2 宽度和 4 最大 2 平均 4/2
L 9 节点 1 宽度和 2 最大 2 平均 2/1
L10 节点 2 宽度和 6 最大 3 平均 6/2
L11 节点 2 宽度和 6 最大 3 平均 6/2
L12 节点 2 宽度和 4 最大 2 平均 4/2
L13 节点 3 宽度和 9 最大 3 平均 9/3
L14 节点 3 宽度和 9 最大 3 平均 9/3
L15 节点 3 宽度和 6 最大 2 平均 6/3
L16 节点 1 宽度和 2 最大 2 平均 2/1
L17 节点 2 宽度和 6 最大 3 平均 6/2
L18 节点 2 宽度和 6 最大 3 平均 6/2
L19 节点 2 宽度和 5 最大 3 平均 5/2
L20 节点 2 宽度和 6 最大 3 平均 6/2
L21 节点 2 宽度和 8 最大 5 平均 8/2
L22 节点 6 宽度和 2 最大 2 平均 2/6
wrote out/ne_07_1_width.json / .csv / .svg
OK ne_07_1_width:宽度直方图 {0: 5, 2: 16, 3: 26, 5: 1},最宽 5 在 ['d_dq_2'],115 选项去重后 56 条跳转
判读:宽度是克制的,且去重后缩了近一半
直方图只有四个档:0、2、3、5。 48 个节点里,5 个出度 0(就是 5 个结局节点)、16 个出度 2、26 个出度 3、1 个出度 5。没有一个节点出度是 1 或 4——每个决策点要么给 2 个、要么给 3 个选项,收束点给 5 个。这是一张刻意克制的图:2 到 3 个选项是一个玩家能在不翻回上文的情况下比较完的数量。
最宽的一步是 d_dq_2,出度 5,全图仅此一处。 它落在 L21,五个选项分别指向五个结局——那是收束点而不是普通岔路。脚本把宽度上界写成 5 并在越界时非零退出,实测未触发。
115 个选项,去重后只剩 56 条跳转、47 个不同目标。 这是本节最容易被忽略的一行:选项数几乎是边数的两倍。原因是同一对节点之间常挂多个条件互斥的选项——它们对玩家是「不同的话」,对图是「同一条边」。所以做宽度分析必须双口径:按选项数,平均每步 115/43 ≈ 2.67 个;按跳转数,平均每步 56/43 ≈ 1.30 条边。前一个数说的是「玩家看到几个按钮」,后一个数说的是「结构上有几条岔路」。
分层看,宽度呈 2-2-6-6-4 的周期。 23 层从 L0 到 L22,每层 1 到 3 个节点,宽度和反复在 2、6、4 之间循环,这是「三夜」三幕在结构上的重复节拍。真正打破规律的是两处:L21(宽度和 8、最大 5,因为收束点 d_dq_2 在这里)与 L22(6 个节点、宽度和仅 2,因为五个结局节点出度为 0,只剩 d_fg_3 还挂着 2 个选项)。
| 检查项 | 合格标准 |
|---|---|
| 宽度口径 | 宽度 = 出度,不用「可达节点数」冒充 |
| 双口径 | 选项数(115)与去重跳转数(56)分列,不混用 |
| 分层口径 | 层号按边距离,一步 = 一次选择 |
| 直方图自洽 | 各档节点数求和 = 48,选项求和 = 115 |
| 整数不变量 | 出度 ∈ [0,5],越界即非零退出 |
宽度量完了,知道这张图每步多宽、最挤的一步在哪。但宽度只回答「一步有几个选择」,不回答「这些选择能拼出多少条路」。下一节把宽度乘起来,看路径数会膨胀到什么程度,再给出剪枝与采样两条控制它的路子。
阅读导航:上一节:6.3 结局的可达性 · 下一节:7.2 剪枝与采样上界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。