10.3 路径热力图

把 96 条采样路径在各场景上的经过次数画成 14×96 的矩阵热力图,cell 为整数、颜色按整数档位查表(档位定义在 config/nm_10_3_heatmap.json,全程无浮点调色)。矩阵元素之和 2544 恰等于所有路径的节点数之和;按结局排序后同结局的路径聚成一块。

《游戏设计》卷二《策略涌现与体验度量》 的热力图是策略空间的——横竖两轴都是机制动作,格子里是收益;本节的路径热力图是叙事路径的——行是场景、列是采样路径,格子里是「这条路径在这个场景里经过了几次」。一个度量机制空间,一个度量路径空间。

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,只反映采样路径经过得多少,不是「这个场景更重要」):

场景热度场景热度
pier384harbor_office150
dawn_quay288drowned_bell138
watch_post264tavern132
market_row222counting_house120
breakwater183fog_gate120
lighthouse_top165tide_cave66
chapel156
lighthouse_base156

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 模拟玩家的策略 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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