《游戏设计》卷二《策略涌现与体验度量》 画的是策略空间热力图——把一组动作组合摊成网格看收益;本节画的是分支图——把故事图摊成一张节点连线图,看一条路径压亮了哪几条边。前者是机制数值的空间,后者是叙事拓扑的形状。
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 行。逐列摊开看:
| 场景 | 幕 | 节点数 | 备注 |
|---|---|---|---|
| pier | 1 | 4 | 含两个枢纽节点 d_hub_a1 / d_hub_b2 |
| tavern | 1 | 3 | |
| chapel | 1 | 3 | |
| counting_house | 1 | 3 | |
| watch_post | 1 | 4 | 含枢纽节点 d_hub_a2 |
| lighthouse_base | 2 | 3 | |
| tide_cave | 2 | 3 | |
| market_row | 2 | 3 | |
| harbor_office | 2 | 3 | |
| drowned_bell | 2 | 3 | |
| lighthouse_top | 3 | 3 | |
| breakwater | 3 | 3 | |
| fog_gate | 3 | 3 | |
| dawn_quay | 3 | 7 | 2 个对话节点 + 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 / 56 | 446‰ | 一条路径的覆盖下界 |
| 随机采样 200 条 | 56 / 56 | 1000‰ | 铺满全部可达边 |
| 穷举(BFS,第 2 章基准) | 56 / 56 | 1000‰ | 覆盖率定义的上界 |
确定性路径只压亮了 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 路径热力图 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。