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 | 回合 |
|---|---|---|
| 20261014 | 27, 21, 9, 0 | 4 |
| 1 | 44, 21, 3, 0 | 4 |
| 7 | 35, 21, 1, 0 | 4 |
| 42 | 35, 20, 6, 0 | 4 |
| 100 | 27, 4, 0 | 3 |
| 999 | 36, 21, 1, 0 | 4 |
| 123456 | 27, 7, 0 | 3 |
| 2147483646 | 30, 30, 4, 0 | 4 |
一眼能读出三件事:① 全部收尾——每条血线最后一根都是 0,八组种子都被打通;② 开局速度不一——第 1 回合后的剩余 HP 在 27 到 44 之间跳,说明洗牌顺序显著影响开局手感;③ 收尾快——多数种子最后两个回合就清空,越到后面掉血越快。这些结论从 CSV 里要费劲地逐列比对才能看出,画成血线却是一眼的事——这就是可读化的价值。
需要声明的是,血线图是描述不是评分:它显示每局怎么掉血,不对「哪局更好玩」下判断。
本节落两个产物:
| 产物 | 路径 | 内容 |
|---|---|---|
| 血线图 | out/mech03_hp.svg | 8 行 × 每回合一根条,共 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 规格驱动的状态机 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。