3.3 仿真输出的可读化

误差表是给机器看的,血线图是给眼睛看的。本节用纯 xml.etree 画一张多局面血线对比图 out/mech03_hp.svg,每行一种子、每回合一根条,画出敌方 HP 从 50 掉到 0 的过程;再回读该 SVG 断言矩形数量与 data-value 逐个正确,并踩一次 xmlns 命名空间的坑。

3.3 仿真输出的可读化

3.2 的误差表是给机器看的:一格一格都是整数,全 0 就是合格。但机制设计是人的活,数字得能一眼看懂。《小游戏开发实战》卷一的核心循环拆解 也把循环画出来,但它画的是循环时长占比(每个循环占整局多久);本节画的是血线——每一局里敌方 HP 随回合怎么掉,是机制口径的可视化。轨迹给了数据,可读化把数据变成判断。

为什么输出要可读化

同一份 8 组种子的结果,可以有三种形态:

形态载体读者回答
快照字典程序赢没赢
误差表CSV程序 / 脚本守恒破了没有
血线图SVG人每局的血是怎么掉的、节奏一不一样

前两种能过机器,却过不了眼睛——没人能盯着 30 行 CSV 看出「第 3 回合掉血最快」。可读化不是锦上添花,它把「机制在时间里怎么走」这一层信息从数据里提出来,变成设计者能直接判读的形状。血线图一画出来,「八组种子都在 3–4 回合内把敌方打空」这句话就不再需要从数字里推,而是一眼可见。

纯 xml.etree 画 SVG,不用任何绘图库

本机没有 matplotlib / scipy / cairo,所有图表一律纯 xml.etree 生成 SVG。这不是妥协,反而让图可断言:SVG 就是 XML,画完能立刻回读自证——解析回来数矩形、比属性,等于给图本身写了一条测试。

血线图的结构很简单:svg_begin 起画布,每行一种子,行内每回合一根条,条长 = 该回合结束时的剩余敌方 HP(分母恒为 params.enemyHp = 50)。一根根条从左到右,就是这条种子下「敌方 HP 从 50 掉到 0」的血线。条长用整数地板除,全程无浮点。

#!/usr/bin/env python3
# file: sim/gd_mech_03_readable.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_03_readable.py
"""3.3 仿真输出的可读化(S 层)。

轨迹与误差表都是给机器看的。本脚本把它们画成**给眼睛看**的图:一张多局面
血线对比图 out/mech03_hp.svg——**每行一个种子**,行内**每回合一根条**,条长 =
该回合结束时的剩余敌方 HP(分母恒为 params.enemyHp = 50)。一根根条从左到右
就是这条种子下「敌方 HP 从 50 掉到 0」的血线。条长用整数地板除(禁浮点)。

图不是终点:画完必须**回读自证**。用 xml.etree 解析刚写出的 SVG,按
`e.tag.endswith("rect")` 过滤矩形(**坑**:带 xmlns 的 SVG 里 `root.iter("rect")`
匹配不到任何元素,真实标签是 `{http://www.w3.org/2000/svg}rect`),
断言 ① 矩形总数 == 1(背景)+ 2 × 条数(每根条一根底槽 + 一根填充);
② 带 data-value 的填充矩形,其值序列与每条血线的剩余 HP 逐个相等。

同时落 out/mech03_readable.csv。种子是显式列举的整数,不用 random 模块。
"""
from __future__ import annotations

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

import ashlib

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)

SEEDS = [20261014, 1, 7, 42, 100, 999, 123456, 2147483646]
HEADER = ["seed", "dealt", "enemyHpFinal", "turn", "won", "steps", "turns"]


def replay(spec: dict) -> tuple:
    """逐步重放(与 3.1 同一套规则);额外返回每回合结束时的剩余敌方 HP 血线。"""
    p = spec["params"]
    deck: list = []
    for c in spec["cards"]:
        deck.extend([c] * c["count"])
    deck = ashlib.shuffle_deck(deck, spec["seed"])
    hand = deck[: p["handSize"]]
    draw = deck[p["handSize"]:]
    discard: list = []
    turn, energy = 1, p["energyMax"]
    player_hp, enemy_hp, block, dealt, over, steps = p["playerHp"], p["enemyHp"], 0, 0, False, 0
    line: list = []
    for cmd in spec["strategy"]:
        if over:
            break
        if turn > p["turnLimit"]:
            break
        if cmd.startswith("p"):
            idx = int(cmd[1:])
            if idx < 0 or idx >= len(hand):
                continue
            c = hand[idx]
            if c["cost"] > energy:
                continue
            hand.pop(idx)
            discard.append(c)
            energy -= c["cost"]
            block += c["block"]
            d = min(c["damage"], enemy_hp)
            enemy_hp -= d
            dealt += d
            steps += 1
            if enemy_hp <= 0:
                over = True
        elif cmd == "e":
            absorbed = min(block, p["enemyAttack"])
            block -= absorbed
            player_hp = max(0, player_hp - (p["enemyAttack"] - absorbed))
            steps += 1
            if player_hp <= 0:
                break
            turn += 1
            block = 0
            energy = p["energyMax"]
            while len(hand) < p["handSize"] and draw:
                hand.append(draw.pop(0))
            line.append(enemy_hp)
    if not line or line[-1] != enemy_hp:
        line.append(enemy_hp)
    return dealt, enemy_hp, turn, over and enemy_hp <= 0, steps, line


def build(spec: dict, seeds: list, path: str) -> list:
    """画多局面血线对比图,返回每条血线展平后的剩余 HP(供回读断言)。"""
    W, H = 560, 46 + len(seeds) * 30
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 16, 24, "ashdeck enemy HP per turn (50 -> 0), one row per seed", 13)
    ashlib.svg_text(root, 150, 24, "turn1 -> last", 11, fill="#94a3b8")
    values: list = []
    for i, seed in enumerate(seeds):
        s = dict(spec)
        s["seed"] = seed
        dealt, _enemy_hp, turn, _won, _steps, line = replay(s)
        y = 46 + i * 30
        ashlib.svg_text(root, 16, y + 12, f"seed {seed}", 11)
        for k, hp in enumerate(line):
            ashlib.svg_bar(root, 150 + k * 36, y, 30, 16, hp, spec["params"]["enemyHp"])
            values.append(hp)
        ashlib.svg_text(root, 150 + len(line) * 36 + 8, y + 12,
                        f"dealt={dealt} T={turn}", 11, fill="#94a3b8")
    ashlib.svg_save(root, path)
    return values


def main() -> int:
    spec = ashlib.load_spec()
    out = os.path.join(ROOT, "out")
    svg_path = os.path.join(out, "mech03_hp.svg")
    values = build(spec, SEEDS, svg_path)

    rows: list = []
    for seed in SEEDS:
        s = dict(spec)
        s["seed"] = seed
        dealt, enemy_hp, turn, won, steps, line = replay(s)
        rows.append([seed, dealt, enemy_hp, turn, str(won).lower(), steps, len(line)])
    n = ashlib.write_csv(os.path.join(out, "mech03_readable.csv"), HEADER, rows)

    tree = ET.parse(svg_path)
    r = tree.getroot()
    naive = list(r.iter("rect"))                      # 坑:命名空间下匹配不到
    rects = [e for e in r.iter() if e.tag.endswith("rect")]
    fills = [e.get("data-value") for e in rects if e.get("data-value") is not None]
    expect_rects = 1 + 2 * len(values)
    ok_count = len(rects) == expect_rects
    ok_values = fills == [str(v) for v in values]

    print(f"seeds={len(SEEDS)} bars={len(values)} svg={os.path.getsize(svg_path)}B")
    print(f"iter('rect')={len(naive)} (namespace pitfall) endswith('rect')={len(rects)}")
    print(f"rects expect={expect_rects} got={len(rects)} ok={ok_count}")
    print(f"data-value={fills}")
    print(f"valuesMatchHpLine={ok_values}")
    print(f"wrote out/mech03_hp.svg out/mech03_readable.csv rows={n}")
    return 0 if ok_count and ok_values and len(naive) == 0 else 1


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

实跑 /opt/homebrew/bin/python3 sim/gd_mech_03_readable.py:

seeds=8 bars=30 svg=6879B
iter('rect')=0 (namespace pitfall) endswith('rect')=61
rects expect=61 got=61 ok=True
data-value=['27', '21', '9', '0', '44', '21', '3', '0', '35', '21', '1', '0', '35', '20', '6', '0', '27', '4', '0', '36', '21', '1', '0', '27', '7', '0', '30', '30', '4', '0']
valuesMatchHpLine=True
wrote out/mech03_hp.svg out/mech03_readable.csv rows=8

回读自证:数矩形,比 data-value

图写完,立刻把它当数据读回来。两个断言:

  • 数量:8 组种子共 30 根条(回合数不同:有的 4 回合、有的 3 回合),每根条两根矩形(一根底槽 + 一根填充),加上 1 块背景,总数应为 1 + 2×30 = 61,实测 got=61,ok=True。
  • 取值:data-value 逐个等于血线上的剩余 HP。第一组种子 20261014 的四个值是 27, 21, 9, 0——第 1 回合打完剩 27,第 2 回合 21,第 3 回合 9,第 4 回合 0;valuesMatchHpLine=True 表示 30 个值一个不差。

这里藏着本节唯一的坑:iter('rect')=0。带 xmlns 的 SVG 里,root.iter("rect") 匹配不到任何元素,因为真实标签是 {http://www.w3.org/2000/svg}rect——命名空间被拼进了标签名。正确写法是先 root.iter() 遍历全部元素,再用 e.tag.endswith("rect") 过滤,这才得到 61。脚本把两个数都打出来(0 与 61),就是为了把这条坑钉在输出里:任何用 iter("rect") 的检查器都会静默地数出 0 个矩形,从而假通过。

读图:血线形状就是结论

把 30 个数字按种子排开看:

种子每回合剩余敌方 HP回合
2026101427, 21, 9, 04
144, 21, 3, 04
735, 21, 1, 04
4235, 20, 6, 04
10027, 4, 03
99936, 21, 1, 04
12345627, 7, 03
214748364630, 30, 4, 04

一眼能读出三件事:① 全部收尾——每条血线最后一根都是 0,八组种子都被打通;② 开局速度不一——第 1 回合后的剩余 HP 在 27 到 44 之间跳,说明洗牌顺序显著影响开局手感;③ 收尾快——多数种子最后两个回合就清空,越到后面掉血越快。这些结论从 CSV 里要费劲地逐列比对才能看出,画成血线却是一眼的事——这就是可读化的价值。

需要声明的是,血线图是描述不是评分:它显示每局怎么掉血,不对「哪局更好玩」下判断。

本节落两个产物:

产物路径内容
血线图out/mech03_hp.svg8 行 × 每回合一根条,共 30 根
数据表out/mech03_readable.csv每种的 dealt / 终局 HP / 回合 / 步数

图和表同源:图是表按行的可视化,表是图的可解析版本。两者都由同一次重放生成,不存在「图好看但表对不上」的可能。

自检表

检查项合格标准
无绘图库纯 xml.etree 生成 SVG,无 matplotlib
无浮点条长整数地板除
数量自证矩形总数 == 1 + 2 × 条数
取值自证data-value 序列 == 血线剩余 HP
命名空间坑用 endswith("rect"),不用 iter("rect")

S 层到此把「规格 → 轨迹 → 守恒 → 可读图」走完了一遍。下一步,同样的规则要换到另一门语言里,做成一个确定性的状态机。

阅读导航:上一节:3.2 资源守恒与不变量检验 · 下一节:4.1 规格驱动的状态机 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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