《游戏设计》卷二《策略涌现与体验度量》 的热力图是策略空间的——横竖两轴都是机制动作,格子里是收益;本节的路径热力图是叙事路径的——行是场景、列是采样路径,格子里是「这条路径在这个场景里经过了几次」。一个度量机制空间,一个度量路径空间。
10.3 路径热力图
上一节的分支图是静态的:一条边要么被走过、要么没有,没有「走了几次」的信息。本节把次数补上——把 N 条采样路径在各场景上的经过次数摊成一张矩阵,行是 14 个场景、列是 N 条路径,格子的深浅代表次数。
颜色只能自己分档
热力图的关键是「值 → 颜色」的映射。本机没有绘图库,也没有调色板工具,所以映射得自己定,而且必须走整数——不能用「把值归一化到 0–1 再插值」这类浮点做法。做法是:把 cell 值切成若干整数档位,每档一个固定色值,查表取色。档位表写在一个 D 层配置里,S 层脚本读它——规格字段不动,档位是本节的度量口径。
{
"columns": 96,
"rows": 14,
"seed": 20261016,
"bucket_max": [0, 1, 2, 3, 4],
"bucket_color": ["#f1f5f9", "#bfdbfe", "#60a5fa", "#2563eb", "#1e3a8a"],
"note": "cell 值 = 该路径在该场景经过的对话节点数;颜色按整数档位查表,禁浮点调色;本文件由 S 层脚本读取,不新造规格字段"
}
bucket_max[i] 是第 i 档的上界,bucket_color[i] 是它的色值;取色就是 colors[min(value, 4)]——一个 min 加一个下标,没有任何浮点。
# snippet: 整数分档取色(摘自 sim/nm_10_3_path_heatmap.py)
colors = cfg["bucket_color"] # 5 档色值,来自 config/nm_10_3_heatmap.json
for r in range(n_row):
for c in range(n_col):
v = matrix[r][c] # 整数:该路径在该场景经过的对话节点数
fill = colors[min(v, len(colors) - 1)] # 查表,越界由 min 钳到最深一档
96 列 × 14 行 = 1344 个格子,每个格子一个 rect。这就是热力图 SVG 有 133193 字节的原因——图大不是因为代码复杂,是因为格子多。
S 层:采样、建矩阵、出图
脚本按 seed=20261016 采样 96 条路径,每条记录它经过的节点序列;把序列投影到场景,得到「这条路径在每个场景经过几个节点」。列按到达的结局排序,于是同一结局的路径聚成连续的一块,块与块之间画一条分隔线并标出结局名与条数。
#!/usr/bin/env python3
# file: sim/nm_10_3_path_heatmap.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_10_3_path_heatmap.py
"""卷三 10.3 路径热力图(S 层)。
把 N 条采样路径在各场景上的**经过次数**画成一张矩阵热力图:行 = 14 个场景,列 = N 条
路径(按到达的结局排序,于是同一结局的路径聚成一块)。cell 值 = 该路径在该场景里经过
的对话节点数(整数)。颜色**按整数档位查表**——档位与色值写在 config/nm_10_3_heatmap.json,
全程不出现浮点调色(本机无 matplotlib,颜色只能自己分档)。
整数不变量:cell ∈ [0, 每场景节点数];矩阵元素之和 == 所有路径的节点数之和。
产出 out/nm_10_3_heatmap.svg 与 out/nm_10_3_heatmap.json。共用内核在 sim/nm_10_common.py。
"""
from __future__ import annotations
import collections
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, load_spec, random_paths, readback, sub # noqa: E402
CFG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"config/nm_10_3_heatmap.json")
SVG_PATH = os.path.join(OUT, "nm_10_3_heatmap.svg")
JSON_PATH = os.path.join(OUT, "nm_10_3_heatmap.json")
CELL_W, CELL_H, L, T = 7, 22, 84, 62
def build_svg(scene_order, matrix, groups, cfg):
n_col, n_row = len(matrix[0]), len(matrix)
W = L + n_col * CELL_W + 24
H = T + n_row * CELL_H + 56
colors = cfg["bucket_color"]
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"《雾港夜航》路径热力图:{n_row} 场景 × {n_col} 条采样路径(cell=经过的对话节点数,按结局分组)")
for r, s in enumerate(scene_order):
sub(svg, "text", x=L - 8, y=T + r * CELL_H + 15, **{"font-size": 10, "text-anchor": "end",
"fill": "#334155"}).text = s
for r in range(n_row):
for c in range(n_col):
v = matrix[r][c]
sub(svg, "rect", x=L + c * CELL_W, y=T + r * CELL_H, width=CELL_W, height=CELL_H,
fill=colors[min(v, len(colors) - 1)], stroke="#ffffff", **{"stroke-width": 0.5})
for start, label, count in groups:
x = L + start * CELL_W
sub(svg, "line", x1=x, y1=T - 6, x2=x, y2=T + n_row * CELL_H, stroke="#94a3b8",
**{"stroke-width": 1})
sub(svg, "text", x=x + (count * CELL_W) // 2, y=T - 10,
**{"font-size": 9, "text-anchor": "middle", "fill": "#475569"}).text = f"{label} {count}"
ly, lx = T + n_row * CELL_H + 30, L
for i, col in enumerate(colors):
sub(svg, "rect", x=lx + i * 46, y=ly - 11, width=14, height=14, fill=col, stroke="#cbd5e1")
sub(svg, "text", x=lx + i * 46 + 20, y=ly + 2, **{"font-size": 10,
"fill": "#334155"}).text = str(cfg["bucket_max"][i])
sub(svg, "text", x=lx + 5 * 46 + 6, y=ly + 2, **{"font-size": 10, "fill": "#334155"}).text = "= 经过的对话节点数(整数档位)"
return svg, W, H
def main() -> int:
with open(CFG_PATH, encoding="utf-8") as fh:
cfg = json.load(fh)
spec = load_spec()
by = {n["id"]: n for n in spec["dialogue"]}
n, seed = cfg["columns"], cfg["seed"]
scene_order = [x["id"] for x in spec["scene_graph"]["nodes"]]
scene_idx = {s: i for i, s in enumerate(scene_order)}
end_order = {e["name"]: i for i, e in enumerate(spec["endings"])}
paths = random_paths(spec, by, n, seed)
paths.sort(key=lambda t: end_order.get(t[1], 99))
matrix = [[0] * n for _ in scene_order]
for c, (seq, _end) in enumerate(paths):
cnt = collections.Counter(by[x]["scene"] for x in seq)
for s in scene_order:
matrix[scene_idx[s]][c] = cnt.get(s, 0)
groups, seen = [], set()
for c, (_seq, end) in enumerate(paths):
if end not in seen:
seen.add(end)
groups.append((c, end or "(未终局)", sum(1 for _s, e2 in paths if e2 == end)))
total_cells = sum(sum(r) for r in matrix)
total_nodes = sum(len(seq) for seq, _e in paths)
max_v = max(max(r) for r in matrix)
assert len(scene_order) == cfg["rows"], "行数应等于场景数"
assert total_cells == total_nodes, "矩阵元素之和应等于所有路径的节点数之和"
assert 0 <= max_v <= len(cfg["bucket_color"]) - 1, "cell 值应落在色档范围内"
os.makedirs(OUT, exist_ok=True)
svg, W, H = build_svg(scene_order, matrix, groups, cfg)
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)
assert root.tag == f"{{{SVG_NS}}}svg", "回读根节点应为 svg"
assert counts.get("rect", 0) == len(scene_order) * n + 1 + len(cfg["bucket_color"]), \
"矩形数应为 场景×路径 + 背景 + 图例色块"
assert counts.get("line", 0) == len(groups), "线数应为结局分组分隔线"
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump({"rows": len(scene_order), "columns": n, "seed": seed, "paths": n,
"sampled_endings": dict(sorted(collections.Counter(e for _s, e in paths).items())),
"max_cell": max_v, "sum_cells": total_cells, "sum_path_nodes": total_nodes,
"scene_totals": {s: sum(matrix[scene_idx[s]]) for s in scene_order},
"svg": f"{W}x{H}", "svg_bytes": len(data), "elements": counts},
fh, ensure_ascii=False, indent=1)
fh.write("\n")
print(f"spec={spec['meta']['title']} 行 {len(scene_order)} 场景 列 {n} 条路径 seed={seed}")
print("采样结局分布 " + " ".join(f"{k}={v}" for k, v in sorted(collections.Counter(e for _s, e in paths).items())))
print(f"cell 最大 {max_v} 矩阵元素和 {total_cells} 路径节点和 {total_nodes} 相等 {total_cells == total_nodes}")
print("各场景热度(该列总和,proxy) " + " ".join(f"{s}={sum(matrix[scene_idx[s]])}" for s in scene_order))
print("SVG 元素 " + " ".join(f"{k}={counts[k]}" for k in sorted(counts)))
print(f"wrote out/nm_10_3_heatmap.svg {len(data)} bytes {W}x{H}")
print("OK nm_10_3_path_heatmap:热力图由纯 xml.etree 生成,整数分档,回读通过")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_10_3_path_heatmap.py:
spec=雾港夜航 行 14 场景 列 96 条路径 seed=20261016
采样结局分布 沉没=30 灯守=6 离港=51 缄默=9
cell 最大 4 矩阵元素和 2544 路径节点和 2544 相等 True
各场景热度(该列总和,proxy) pier=384 tavern=132 chapel=156 counting_house=120 watch_post=264 lighthouse_base=156 tide_cave=66 market_row=222 harbor_office=150 drowned_bell=138 lighthouse_top=165 breakwater=183 fog_gate=120 dawn_quay=288
SVG 元素 line=4 rect=1350 svg=1 text=25
wrote out/nm_10_3_heatmap.svg 133193 bytes 780x426
OK nm_10_3_path_heatmap:热力图由纯 xml.etree 生成,整数分档,回读通过
判读:整数不变量与两个读法
这张图有一条硬不变量:把矩阵所有 cell 加起来,必须等于所有路径的节点数之和。实测两边都是 2544——因为每个 cell 就是「该路径在该场景经过的节点数」,加起来自然回到总节点数。这个等式成立,才说明矩阵没有丢格子、没有重复计数。
按场景把每列加总,得到一张「场景热度」表(这是 proxy,只反映采样路径经过得多少,不是「这个场景更重要」):
| 场景 | 热度 | 场景 | 热度 |
|---|---|---|---|
| pier | 384 | harbor_office | 150 |
| dawn_quay | 288 | drowned_bell | 138 |
| watch_post | 264 | tavern | 132 |
| market_row | 222 | counting_house | 120 |
| breakwater | 183 | fog_gate | 120 |
| lighthouse_top | 165 | tide_cave | 66 |
| chapel | 156 | ||
| lighthouse_base | 156 |
pier 最高(384)是因为它是起点场景,还带两个枢纽节点,几乎每条路径都会经过;tide_cave 最低(66)是因为它要求 knows_tide_rule 这个前置 flag,进得去的路径少。这两个数读的都是结构可达性,不是故事质量。
第二个读法是按结局分块:96 条路径里到达的结局是 灯守 6、离港 51、缄默 9、沉没 30——真相 0 条。这跟第 3 章的口径一致:真相结局在均匀随机下极稀有(目录篇 20000 条里只有 178 条),96 条采样撞不上它很正常。热力图按结局排序后,同一结局的路径聚成一块,于是「哪块亮、哪块暗」一眼可见。
自检表
| 检查项 | 合格标准 |
|---|---|
| 整数分档 | 颜色由 colors[min(v, 4)] 查表,无浮点调色 |
| 档位外置 | 档位与色值写在 config/nm_10_3_heatmap.json,S 层读它 |
| 整数不变量 | 矩阵元素之和 == 路径节点之和(实测 2544 == 2544) |
| 落盘可回读 | ET.fromstring 通过,rect=1350、line=4 断言通过 |
| 口径声明 | 场景热度为 proxy,只做相对比较 |
三张图合起来看
10.1 的曲线讲一条路径的经历,10.2 的分支图讲整张图的覆盖,10.3 的热力图讲多条路径的分布。三张图都是纯 xml.etree 生成、都能 ET.fromstring 回读、都把颜色与坐标钉在整数上——图不是配图,是产物。下一节换一条路:不画图,而是模拟玩家,用不同的路径策略去看这些图会变成什么样。
阅读导航:上一节:10.2 分支图的可视化 · 下一节:11.1 模拟玩家的策略 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。