14.2 策略空间的热力图

热力图不是新指标,而是把「策略族 × 参数档」两张一维表压成一张二维切片,用颜色承载第三个量。本节用纯 xml.etree 逐格画 rect、整数色阶插值、手绘色标图例,画出四种策略族在六档 enemyAttack 下的胜率矩阵并回读重建逐格比对;格值胜率是代理指标,只作相对比较。

14.2 策略空间的热力图

上一节把图分成了「曲线读趋势、谱读分布」两族,横轴都只有一维。/posts/game/industry/ 那一栏目的配图也基本停在这一层——把一段结论画成一张直观的图。但设计里更常见的问题是两个自变量同时变:换一套打法(策略族)、换一档难度(参数),结果怎么变?画成两条曲线会乱成一团。答案是热力图:行是一维、列是另一维,颜色承载第三个量。它不引入任何新指标,只是把两张一维表压进一张图。

读热力图先记住它的定位:它是二维切片,不是新量。行、列都来自你已经能算的整数,格值就是上一节的同一个代理指标(这里是胜率千分比)。本机没有 matplotlib,所以颜色、色阶、色标、网格线、行列表头全部由 xml.etree 手绘;每一格 rect 都带 data-row / data-col / data-value,画完立刻回读重建,逐格比对源矩阵。

颜色是第三维:整数色阶

热力图的全部难点在把 0–1000 的数映射成一个颜色。本节用三段整数色阶:0‰ 是深底 #0f172a,500‰ 是蓝 #2563eb,1000‰ 是橙 #f59e0b。落在两个色标之间的值,在 RGB 三个通道上各自线性插值,t 是千分比,全程地板除:

r = a_r + (b_r − a_r) × t // 1000    (g、b 通道同理,t = 1000 × (v − v0) // (v1 − v0))

全程无浮点,是因为浮点在跨机器重跑时会带来末位差异,而整数色阶在任何机器上都产出逐字节相同的 SVG。色标图例不由图像库生成,而是拿 legendSteps(10)段小 rect 拼出来,刻度值 0 / 500 / 1000 手写。

{ "seeds": [401, "…", 432], "maxSteps": 40,
  "rows": ["spec", "max_damage", "max_block", "cheap_first"],
  "cols": [6, 10, 14, 18, 22, 26], "cellPx": 62,
  "ramp": [[0, "#0f172a"], [500, "#2563eb"], [1000, "#f59e0b"]],
  "legendSteps": 10, "thresholdPermille": 500 }

S 层:四种策略族 × 六档难度

脚本对 4 种策略族各跑 6 档 enemyAttack × 32 个种子,得到 4×6 的胜率矩阵,再画成热力图并回读重建。四种策略族是:spec(照规格序列打)、max_damage(每步选伤害最高的可用牌)、max_block(每步选格挡最高的)、cheap_first(每步选费用最低的)。它们共用同一个 ashlib.run_spec 引擎,只是每步看快照后决定下一个命令——同一套机制,四种打法。

#!/usr/bin/env python3
# file: sim/gd_play_14_heatmap.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_14_heatmap.py
"""卷二 14.2 策略空间的热力图(S 层):行=策略族、列=参数档,格值=代理指标。

热力图是**二维切片**,不是新指标:它把「策略 × 参数」两张一维表压进一张图,用**颜色**承载
第三个量。本机无 matplotlib,所以颜色、色阶、色标、网格线、行列表头全部由 `xml.etree` 手绘:
  - 颜色用**整数线性插值**在三个色标之间取(0‰ → 深、500‰ → 蓝、1000‰ → 橙),全程地板除;
  - 每格 rect 带 `data-row` / `data-col` / `data-value`,画完立刻**回读重建**,逐格比对源矩阵;
  - 色标图例由 `legendSteps` 段 rect 拼成,刻度值手写。

格值胜率是**代理指标(proxy)**,只做相对比较,禁绝对化:热力图能告诉你「哪一族在哪一档领先」,
不能告诉你「这一族更好玩」。全整数、无浮点、无 random。产出 out/play14_heatmap.svg + .csv + .json。
"""
from __future__ import annotations

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

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_play_14_heatmap.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def hexrgb(h: str) -> tuple:
    h = h.lstrip("#")
    return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)


def ramp(v: int, stops: list) -> str:
    """整数色阶:在 stops 的相邻色标之间线性插值(t 为千分比,全程地板除)。"""
    for i in range(len(stops) - 1):
        v0, c0 = stops[i]
        v1, c1 = stops[i + 1]
        if v <= v1:
            t = 0 if v1 == v0 else 1000 * (v - v0) // (v1 - v0)
            a, b = hexrgb(c0), hexrgb(c1)
            return "#%02x%02x%02x" % (a[0] + (b[0] - a[0]) * t // 1000,
                                      a[1] + (b[1] - a[1]) * t // 1000,
                                      a[2] + (b[2] - a[2]) * t // 1000)
    return stops[-1][1]


def play_policy(spec: dict, over: dict, seed: int, policy: str, max_steps: int) -> dict:
    """跑一条策略族:每步看快照再决定下一个命令;返回终局快照。"""
    cost = {c["id"]: c["cost"] for c in spec["cards"]}
    dmg = {c["id"]: c["damage"] for c in spec["cards"]}
    blk = {c["id"]: c["block"] for c in spec["cards"]}
    s = copy.deepcopy(spec)
    s["seed"] = seed
    s["params"].update(over)
    seq: list = []
    for _ in range(max_steps):
        sn = ashlib.run_spec(s, seq)
        if sn["over"]:
            break
        if policy == "spec":
            seq.append(s["strategy"][len(seq)] if len(seq) < len(s["strategy"]) else "e")
            continue
        hand, en = sn["hand"], sn["energy"]
        aff = [i for i, cid in enumerate(hand) if cost[cid] <= en]
        if not aff:
            seq.append("e")
        elif policy == "max_damage":
            seq.append(f"p{max(aff, key=lambda i: (dmg[hand[i]], -cost[hand[i]]))}")
        elif policy == "max_block":
            seq.append(f"p{max(aff, key=lambda i: (blk[hand[i]], dmg[hand[i]]))}")
        else:  # cheap_first
            seq.append(f"p{min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))}")
    return ashlib.run_spec(s, seq)


def win_permille(spec: dict, over: dict, seeds: list, policy: str, max_steps: int) -> int:
    w = 0
    for sd in seeds:
        w += 1 if play_policy(spec, over, sd, policy, max_steps)["won"] else 0
    return 1000 * w // len(seeds)


def draw(matrix: dict, rows: list, rowlabels: list, cols: list, cfg: dict, path: str) -> int:
    """手绘热力图:逐格 rect + 行列表头 + 格内数值 + 底部色标图例。"""
    cp, pad, left, top = cfg["cellPx"], 6, 92, 88
    gw, gh = len(cols) * cp, len(rows) * cp
    W, H = left + gw + 96, top + gh + 74
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 20, 28, "策略空间热力图:行=策略族 · 列=enemyAttack · 格=胜率(代理指标 ‰)", 15)
    ashlib.svg_text(root, 20, 46, "颜色承载第三维 · 纯 xml.etree 逐格 rect · 仅作相对比较", 11, "#94a3b8")
    for j, c in enumerate(cols):
        ashlib.svg_text(root, left + cp * j + cp // 2, top - 8, str(c), 11, "#cbd5e1", "middle")
    ashlib.svg_text(root, left + gw // 2, top - 26, "enemyAttack", 11, "#64748b", "middle")
    for i, r in enumerate(rows):
        ashlib.svg_text(root, left - 8, top + cp * i + cp // 2 + 4, rowlabels[i], 11, "#cbd5e1", "end")
        for j, c in enumerate(cols):
            v = matrix[r][c]
            fill = ramp(v, cfg["ramp"])
            ET.SubElement(root, "rect", {"x": str(left + cp * j + 1), "y": str(top + cp * i + 1),
                                         "width": str(cp - 2), "height": str(cp - 2), "fill": fill,
                                         "data-row": r, "data-col": str(c), "data-value": str(v)})
            ashlib.svg_text(root, left + cp * j + cp // 2, top + cp * i + cp // 2 + 4, str(v),
                            11, "#0f172a" if v >= cfg["thresholdPermille"] else "#e2e8f0", "middle")
    lg_x, lg_y, sw = left, top + gh + 34, (gw - 40) // cfg["legendSteps"]
    for k in range(cfg["legendSteps"]):
        v = 1000 * k // (cfg["legendSteps"] - 1)
        ET.SubElement(root, "rect", {"x": str(lg_x + sw * k), "y": str(lg_y), "width": str(sw),
                                     "height": "12", "fill": ramp(v, cfg["ramp"]),
                                     "data-legend": str(v)})
    for v in (0, 500, 1000):
        ashlib.svg_text(root, lg_x + sw * cfg["legendSteps"] * v // 1000, lg_y + 26, str(v),
                        9, "#94a3b8", "middle")
    return ashlib.svg_save(root, path)


def main() -> int:
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    seeds, rows, cols = cfg["seedPool"][: cfg["cellsPerScan"]], cfg["rows"], cfg["cols"]

    matrix = {r: {c: win_permille(spec, {"enemyAttack": c}, seeds, r, cfg["maxSteps"])
                  for c in cols} for r in rows}
    best = {c: max(matrix[r][c] for r in rows) for c in cols}
    colmax = sum(1 for c in cols for r in rows if matrix[r][c] == best[c])
    total = len(rows) * len(cols)
    dominated = 1000 - 1000 * colmax // total

    header = ["policy"] + [f"ea{ c }" for c in cols]
    n = ashlib.write_csv(os.path.join(OUT, "play14_heatmap.csv"), header,
                         [[r] + [matrix[r][c] for c in cols] for r in rows])

    print(f"seeds={len(seeds)}  行={len(rows)} 族  列={len(cols)} 档  格值=胜率‰(代理指标)")
    print(f"{'策略族':>12}" + "".join(f"{'ea' + str(c):>7}" for c in cols) + f"{'行均值':>8}")
    for r in rows:
        vals = [matrix[r][c] for c in cols]
        print(f"{cfg['rowLabels'][rows.index(r)]:>12}" + "".join(f"{v:>7}" for v in vals)
              + f"{sum(vals) // len(vals):>8}")
    print(f"{'列最大':>12}" + "".join(f"{best[c]:>7}" for c in cols))
    print(f"\n支配格数(=列最大值的格)={colmax}/{total}  "
          f"支配缺失度={dominated}‰(=1000 − 1000×{colmax}//{total})")

    size = draw(matrix, rows, cfg["rowLabels"], cols, cfg, os.path.join(OUT, "play14_heatmap.svg"))
    text = open(os.path.join(OUT, "play14_heatmap.svg"), encoding="utf-8").read()
    root = ET.fromstring(text)
    tags: dict = {}
    for e in root.iter():
        t = e.tag.split("}")[-1]
        tags[t] = tags.get(t, 0) + 1
    cells = [e for e in root.iter() if e.tag.endswith("rect") and e.get("data-value")]
    legend = [e for e in root.iter() if e.tag.endswith("rect") and e.get("data-legend")]
    recon = {r: {} for r in rows}
    for e in cells:
        recon[e.get("data-row")][int(e.get("data-col"))] = int(e.get("data-value"))
    mism = sum(1 for r in rows for c in cols if recon[r][c] != matrix[r][c])

    print(f"\n回读 heatmap : ET.fromstring ok  bytes={size}  tags={tags}")
    print(f"格 rect(带 data-value)={len(cells)}(应为 {total})  色标 rect={len(legend)}"
          f"(应为 {cfg['legendSteps']})")
    print(f"回读重建 vs 源矩阵:逐格不符={mism}")

    report = {"id": "gd_play_14_heatmap", "seeds": len(seeds), "rows": rows, "cols": cols,
              "matrix": matrix, "colBest": best, "colMaxCells": colmax, "cells": total,
              "dominatedPermille": dominated, "svgBytes": size, "svgTags": tags,
              "reconMismatch": mism, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play14_heatmap.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"\ncsv={n} rows  elapsed={report['elapsedMs']} ms")
    print("wrote out/play14_heatmap.svg out/play14_heatmap.csv out/play14_heatmap.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

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

seeds=32  行=4 族  列=6 档  格值=胜率‰(代理指标)
         策略族    ea6   ea10   ea14   ea18   ea22   ea26     行均值
        规格序列   1000   1000   1000    968    750    531     874
      最大伤害优先   1000   1000   1000   1000   1000   1000    1000
      最大格挡优先   1000   1000   1000   1000   1000    843     973
        低费优先   1000   1000   1000    968    937    375     880
         列最大   1000   1000   1000   1000   1000   1000

支配格数(=列最大值的格)=17/24  支配缺失度=292‰(=1000 − 1000×17//24)

回读 heatmap : ET.fromstring ok  bytes=8893  tags={'svg': 1, 'rect': 35, 'text': 40}
格 rect(带 data-value)=24(应为 24)  色标 rect=10(应为 10)
回读重建 vs 源矩阵:逐格不符=0

csv=4 rows  elapsed=95 ms
wrote out/play14_heatmap.svg out/play14_heatmap.csv out/play14_heatmap.json

读数

第一件事看哪一族先掉。max_damage 一整行都是 1000——它在 6 档里全部赢满。spec 在 18 档就掉到 968、22 档 750、26 档只剩 531,是四族里最先崩的。max_block 撑到 22 档还是 1000、26 档 843。cheap_first 介于两者之间。同一套机制、同一批种子,只换每步的选牌规则,26 档的胜率就从 1000 差到 375——这就是热力图想让你一眼看见的东西。

第二件事看支配缺失度。把「列最大值」圈出来数一数:24 格里只有 17 格等于所在列的最大值,于是支配缺失度 = 1000 − 1000×17//24 = 292‰。这个数不是新指标,只是上一章「支配策略」概念在二维矩阵上的记法:值越高,说明越多的格被别的格支配(同一档下总有别的族更强)。292‰ 意味着大约三成的格是「被支配」的。

第三件事看回读。SVG 被 ET.fromstring 解析成功(8893 字节,35 个 rect、40 个 text),24 格逐格重建回源矩阵后不符 = 0,色标 10 段也对得上。热力图的正确性同样是三个可数的零,不是「看起来颜色对」。

自检表

检查项合格标准
回读与逐格比对ET.fromstring 解析成功,24 格重建回源矩阵后不符数为 0
颜色可复现色阶全程整数地板除,任何机器重跑产出逐字节相同的 SVG;色标图例手绘
整数纪律坐标、色值、胜率全由整数运算得到,无浮点、无 random
代理指标声明格值胜率是 proxy,只作相对比较,禁绝对化

热力图的行列都是离散的:策略族不可排序、参数档也只是几档采样。要真正读出「临界」,就得让控制参数连续变化、把序参量的跃迁找出来——那是下一节相变图的读法。

阅读导航:上一节:14.1 曲线与谱 · 下一节:14.3 相变图的读法 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡