10.2 分支图的可视化

把 48 个对话节点 / 56 条去重对话边按场景分层画成一张 SVG 分支图,边按是否被一条确定性路径走过着色:走过 25 条(446‰),未走过 31 条;随机采样 200 条即可铺满 56/56。布局坐标由场景序号与场景内序号直接算出,纯 xml.etree 建树、回读校验。

《游戏设计》卷二《策略涌现与体验度量》 画的是策略空间热力图——把一组动作组合摊成网格看收益;本节画的是分支图——把故事图摊成一张节点连线图,看一条路径压亮了哪几条边。前者是机制数值的空间,后者是叙事拓扑的形状。

10.2 分支图的可视化

上一节画了一条路径的张力曲线,它只讲了「这条路径经历了什么」。本节换个角度:把整张故事图画出来——48 个对话节点、56 条去重对话边——再把「这条路径走过哪些边」着色上去。

布局:没有现成库,就自己排

本机没装 networkx,也没有任何图布局库。所以坐标不能靠自动布局,得自己算。好在叙事图有一个天然的层次:场景。于是分层规则简单到可以手推:

  • x 轴 = 场景。按幕序排开 14 列:第 1 幕 5 个场景、第 2 幕 5 个、第 3 幕 4 个;
  • y 轴 = 该场景内的对话节点序号。每个场景内的节点按声明顺序自上而下排。

节点的中心坐标就是 L + 场景序号*列宽 + 半宽 与 T + 场景内序号*行高 + 半高——全是整数。边画成从源节点中心到目标节点中心的直线;被确定性路径走过的边画实线深蓝,没走过的画虚线浅灰。5 个结局节点单独用红框标出。

#!/usr/bin/env python3
# file: sim/nm_10_2_branch_graph.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_10_2_branch_graph.py
"""卷三 10.2 分支图的可视化(S 层)。

把 48 个对话节点 / 56 条去重对话边画成分层图:**x 轴 = 场景**(按幕序排开 14 列),
**y 轴 = 该场景内的对话节点**。边按「是否被一条确定性首选项路径走过」着色:实线深蓝
= 走过,虚线浅灰 = 未走过。这张图把第 2 章的覆盖率**画出来**——一条路径只压亮 56 条
边里的 25 条(446‰),剩下的要靠采样(随机 200 条即铺满 56/56)。

分层图没有现成布局库(本机无 networkx),坐标由「场景序号 × 场景内序号」直接算出,
全部整数。产出 out/nm_10_2_branch_graph.svg 与 out/nm_10_2_branch_graph.json。
共用内核在 sim/nm_10_common.py。
"""
from __future__ import annotations

import json
import os
import sys
import xml.etree.ElementTree as ET

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from nm_10_common import (OUT, SVG_NS, first_path, load_spec, random_paths,  # noqa: E402
                          readback, sub)

SEED = 20261016
SAMPLE_N = 200
COL_W, ROW_H = 84, 40
BOX_W, BOX_H = 60, 24
L, T = 70, 64
SVG_PATH = os.path.join(OUT, "nm_10_2_branch_graph.svg")
JSON_PATH = os.path.join(OUT, "nm_10_2_branch_graph.json")


def build_svg(spec, by, covered, cov_all):
    scene_order = [n["id"] for n in spec["scene_graph"]["nodes"]]
    col = {s: i for i, s in enumerate(scene_order)}
    per_scene = {s: [] for s in scene_order}
    for n in spec["dialogue"]:
        per_scene[n["scene"]].append(n["id"])
    row = {nid: r for s in scene_order for r, nid in enumerate(per_scene[s])}
    max_rows = max(len(v) for v in per_scene.values())
    W = L + len(scene_order) * COL_W + 20
    H = T + max_rows * ROW_H + 56

    def cx(nid):
        return L + col[by[nid]["scene"]] * COL_W + BOX_W // 2

    def cy(nid):
        return T + row[nid] * ROW_H + BOX_H // 2

    svg = ET.Element(f"{{{SVG_NS}}}svg", {"width": str(W), "height": str(H),
                                          "viewBox": f"0 0 {W} {H}"})
    sub(svg, "rect", x=0, y=0, width=W, height=H, fill="#ffffff")
    sub(svg, "text", x=L, y=26, **{"font-size": 13, "font-family": "ui-sans-serif, system-ui",
                                   "fill": "#0f172a"}).text = (
        f"《雾港夜航》分支图:48 对话节点 / 56 边(按场景分层;实线=确定性路径走过 {len(covered)} 条)")

    for s in scene_order:
        x = L + col[s] * COL_W
        sub(svg, "text", x=x + BOX_W // 2, y=T - 12, **{"font-size": 9, "text-anchor": "middle",
                                                        "fill": "#64748b"}).text = s

    for a, b in sorted(cov_all):
        on = (a, b) in covered
        sub(svg, "line", x1=cx(a), y1=cy(a), x2=cx(b), y2=cy(b),
            stroke="#2563eb" if on else "#cbd5e1",
            **{"stroke-width": 1.6 if on else 1, **({} if on else {"stroke-dasharray": "3 3"})})

    for n in spec["dialogue"]:
        nid = n["id"]
        x, y = L + col[n["scene"]] * COL_W, T + row[nid] * ROW_H
        ending = bool(n.get("ending"))
        sub(svg, "rect", x=x, y=y, width=BOX_W, height=BOX_H, rx=4,
            fill="#fee2e2" if ending else "#eff6ff",
            stroke="#dc2626" if ending else "#93c5fd", **{"stroke-width": 1})
        sub(svg, "text", x=x + BOX_W // 2, y=y + 16, **{"font-size": 8, "text-anchor": "middle",
                                                        "fill": "#1e293b"}).text = nid

    lx, ly = L, H - 30
    sub(svg, "line", x1=lx, y1=ly, x2=lx + 22, y2=ly, stroke="#2563eb", **{"stroke-width": 1.6})
    sub(svg, "text", x=lx + 28, y=ly + 4, **{"font-size": 10, "fill": "#334155"}).text = f"路径走过 {len(covered)}"
    sub(svg, "line", x1=lx + 130, y1=ly, x2=lx + 152, y2=ly, stroke="#cbd5e1",
        **{"stroke-width": 1, "stroke-dasharray": "3 3"})
    sub(svg, "text", x=lx + 158, y=ly + 4, **{"font-size": 10, "fill": "#334155"}).text = f"未走过 {len(cov_all) - len(covered)}"
    sub(svg, "rect", x=lx + 270, y=ly - 8, width=14, height=14, rx=3, fill="#fee2e2", stroke="#dc2626")
    sub(svg, "text", x=lx + 290, y=ly + 4, **{"font-size": 10, "fill": "#334155"}).text = "结局节点 5"
    return svg, W, H


def main() -> int:
    spec = load_spec()
    by = {n["id"]: n for n in spec["dialogue"]}
    de = {(n["id"], c["goto"]) for n in spec["dialogue"] for c in (n.get("choices") or [])}
    det = {(p[i], p[i + 1]) for p in [first_path(spec, by)] for i in range(len(p) - 1)}
    covered = det & de
    sampled = {(seq[i], seq[i + 1]) for seq, _e in random_paths(spec, by, SAMPLE_N, SEED)
               for i in range(len(seq) - 1)} & de

    os.makedirs(OUT, exist_ok=True)
    svg, W, H = build_svg(spec, by, covered, de)
    data = ET.tostring(svg, encoding="utf-8", xml_declaration=True)
    with open(SVG_PATH, "wb") as fh:
        fh.write(data)
    root, counts = readback(SVG_PATH)

    n_nodes, n_edges = len(spec["dialogue"]), len(de)
    assert root.tag == f"{{{SVG_NS}}}svg", "回读根节点应为 svg"
    assert counts.get("rect", 0) == n_nodes + 1 + 1, "矩形数应为 节点 + 背景 + 图例色块"
    assert counts.get("line", 0) == n_edges + 2, "线数应为 56 边 + 2 图例"
    assert 0 < len(covered) < len(de), "单条路径覆盖应严格小于全量"

    with open(JSON_PATH, "w", encoding="utf-8") as fh:
        json.dump({"nodes": n_nodes, "edges": n_edges, "scene_cols": len(spec["scene_graph"]["nodes"]),
                   "det_covered": len(covered), "det_permille": len(covered) * 1000 // n_edges,
                   "sampled_n": SAMPLE_N, "sampled_covered": len(sampled),
                   "sampled_permille": len(sampled) * 1000 // n_edges,
                   "svg": f"{W}x{H}", "svg_bytes": len(data), "elements": counts},
                  fh, ensure_ascii=False, indent=1)
        fh.write("\n")

    print(f"图:{n_nodes} 对话节点  场景列 {len(spec['scene_graph']['nodes'])}  {n_edges} 去重对话边")
    print(f"确定性路径覆盖   {len(covered)} / {n_edges} = {len(covered) * 1000 // n_edges}‰")
    print(f"随机采样 {SAMPLE_N} 条 覆盖   {len(sampled)} / {n_edges} = {len(sampled) * 1000 // n_edges}‰")
    print(f"SVG 元素     " + "  ".join(f"{k}={counts[k]}" for k in sorted(counts)))
    print(f"wrote out/nm_10_2_branch_graph.svg {len(data)} bytes  {W}x{H}")
    print("OK nm_10_2_branch_graph:分支图由纯 xml.etree 生成,覆盖/未覆盖边已着色,回读通过")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/nm_10_2_branch_graph.py:

图:48 对话节点  场景列 14  56 去重对话边
确定性路径覆盖   25 / 56 = 446‰
随机采样 200 条 覆盖   56 / 56 = 1000‰
SVG 元素     line=58  rect=50  svg=1  text=66
wrote out/nm_10_2_branch_graph.svg 16301 bytes  1266x400
OK nm_10_2_branch_graph:分支图由纯 xml.etree 生成,覆盖/未覆盖边已着色,回读通过

14 列里各有多少节点

分层图的高度由最宽的列决定:dawn_quay 有 7 个节点(两个对话节点 + 5 个结局节点),所以画布留 7 行。逐列摊开看:

场景幕节点数备注
pier14含两个枢纽节点 d_hub_a1 / d_hub_b2
tavern13
chapel13
counting_house13
watch_post14含枢纽节点 d_hub_a2
lighthouse_base23
tide_cave23
market_row23
harbor_office23
drowned_bell23
lighthouse_top33
breakwater33
fog_gate33
dawn_quay372 个对话节点 + 5 个结局节点

三个枢纽节点(d_hub_a1 / d_hub_a2 / d_hub_b2)落在 act 1 的场景列里,却把边牵向后面的幕——这就是分层图里那些跨列的长边。它们的存在让「场景序号」不能直接当成「推进顺序」,但作为列标签它仍然成立:每个对话节点只属于一个场景,不会重复占位。

# snippet: 分层坐标(摘自 sim/nm_10_2_branch_graph.py,全部整数)
per_scene = {s: [] for s in scene_order}
for n in spec["dialogue"]:
    per_scene[n["scene"]].append(n["id"])          # 场景 → 节点列表(按声明序)
row = {nid: r for s in scene_order for r, nid in enumerate(per_scene[s])}

def cx(nid):                                        # 列中心
    return L + col[by[nid]["scene"]] * COL_W + BOX_W // 2

def cy(nid):                                        # 行中心
    return T + row[nid] * ROW_H + BOX_H // 2

判读:一张图看懂「为什么要采样」

这张图给出的两个数是本节的全部信息量:

口径覆盖边千分比说明
确定性首选项路径(1 条)25 / 56446‰一条路径的覆盖下界
随机采样 200 条56 / 561000‰铺满全部可达边
穷举(BFS,第 2 章基准)56 / 561000‰覆盖率定义的上界

确定性路径只压亮了 56 条边里的 25 条——这张 SVG 里,深蓝实线 25 条、浅灰虚线 31 条,比例一眼可见。这不是错误,是采样必要性的证据:一条路径走不完一张图,要让 31 条虚线变实,必须换策略、走更多条。

回读同样逐个元素数:48 个节点框 + 1 个背景 + 1 个图例色块 = 50 个 rect;56 条边 + 2 条图例线 = 58 条 line。计数对不上就断言失败,图的完整性有硬约束。

要提醒口径:覆盖率高不等于故事好。446‰ 与 1000‰ 只是「走过了多少条边」这个结构量,是 proxy,只用于相对比较。它能回答「采样够不够」,不能回答「这段故事好不好」。

自检表

检查项合格标准
分层规则x=场景(14 列)、y=场景内节点序号,坐标全整数
覆盖着色走过的边实线、未走过虚线,数量与 JSON 一致
落盘可回读ET.fromstring 解析通过,rect=50、line=58 断言通过
口径声明覆盖率为 proxy,只做相对比较
环境说明无 networkx / matplotlib,布局与绘图全部自写

分支图是静态的一张图:它告诉你哪些边被走过,但没告诉你每条边被走过多少次。下一节把「次数」画出来——一张路径热力图。

阅读导航:上一节:10.1 纯 xml.etree 出曲线 · 下一节:10.3 路径热力图 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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