7.1 分支宽度的度量

宽度就是一个节点的出度:同时摆在玩家面前的选项数。本节给 48 个对话节点量出度,实测直方图是 5 个 0 出度、16 个 2 出度、26 个 3 出度、1 个 5 出度,最宽的一步是 d_dq_2 的 5 个选项;再按边距离分 23 层看每层宽度,并把 115 个选项去重成 56 条唯一跳转、47 个不同目标。

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 剪枝与采样上界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长