8.2 宽度与深度

宽度是一个节点同时可选的选项数,深度是从起点到终局走过的边数。本节用 S 层脚本量出《雾港夜航》的平均宽度 2.67、最大宽度 5,并用两条口径量深度:全部 450208471680 条结构路径的长度只取 22/25/28/31 四个值,而采样 20000 次的均值是 25.41 步。

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 假选择与真后果的判定 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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