14.1 曲线与谱
/posts/game/industry/
那一栏目是行业口径的短文——留存清单、引导设计、平衡委员会、反作弊、买量,讲的是「该做什么、流程怎么走、行业里谁在做」。它们里面的图是配图,用来把一段文字说得更直观。本章反过来:不讲流程,只讲图怎么画、画完怎么验。本机没有 matplotlib、scipy、cairosvg,所以曲线、谱、热力图、相变图全部由纯 xml.etree 生成 SVG,并且每一张都要能被 ET.fromstring 回读、逐格比对回源数据——图不是示意,是可重跑、可断言的产物。
前 13 章把「好玩」拆成了一堆整数:策略空间熵、支配缺失度、涌现度、心流带宽、相变临界宽度、可解率。这些量算出来之后,下一个问题永远是同一个:它长什么样? 本节只给两族图,因为本卷九成的图都是这两族的变体。读它们有一个共同前提:纵轴上的量全是代理指标(proxy),只用于相对比较,不用于证明「好玩」。
两族图:曲线读趋势,谱读分布
| 族 | 横轴 | 纵轴 | 读什么 | 读错的典型方式 |
|---|---|---|---|---|
| 曲线 | 有序参数(enemyAttack 2..30) | 代理指标(胜率千分比) | 拐点在哪、两条曲线何时分离、分离后谁掉得快 | 把两条完全重合的线当成一条,以为只跑了一组 |
| 谱 | 离散分箱(牌型 heavy/strike/jab/guard) | 分箱内的量(伤害贡献‰、打出次数‰) | 分布偏在哪、哪些分箱在某口径下直接消失 | 只看一个口径就下结论(防御牌在伤害谱上是 0) |
判断该用哪一族,只看一个问题:横轴上的取值能不能排序? 参数、回合序号、难度档可以排序,用曲线;牌型、稀有度、技能类别、玩家类型不能排序(或者排序没有意义),用谱。给不可排序的东西硬画曲线,会让读者以为相邻两档之间有「距离」。
曲线与谱各自的纪律
曲线有三条纪律:① 横轴必须单调,刻度间距要么等距要么明确标注不等距;② 每条曲线要有独立颜色 + 图例,不能用同色不同粗细;③ 数据点要画出来——只画折线时,两点之间的直线是插值,不是数据。本节画的是「胜率随 enemyAttack 变化」,两组固定 enemyHp(60 与 70)。这条曲线天然是阶梯状的:参数在 2..16 之间时两组都赢满 1000‰,到 18 才开始分叉。这不是画法问题,是机制本身在这个区间里没有区分度——曲线把它诚实地暴露出来,比一张「精心选过参数、看起来有趋势」的图有用得多。
谱的核心是分箱,而分箱是设计者的选择,不是数据的属性。本节把一局的伤害按牌型分箱:同一批 992 局里,heavy 贡献 553‰ 的伤害、strike 374‰、jab 72‰、guard 0‰——因为 guard 的 damage 字段是 0。但换一个口径、按打出次数分箱,guard 立刻回到 153‰:它被打出 54 次,是四张牌里第三常用的。同一个分箱、两种口径,一个把防御牌抹掉、一个把它显出来——这就是谱的读法:先问「这是哪个口径的谱」。
脚本把两族图一起画完,再回读两张 SVG:曲线核对「polyline 条数 × 每条点数」,谱核对「柱数 = 分箱数 × 口径数」,并把每根柱的 data-value 重新读回来与源数据逐格比对。同时,按牌型记账的那次重放会与权威实现 ashlib.run_spec 逐种子比对胜负,记账与权威实现不符必须为 0,否则谱画得再好看也是假的。
#!/usr/bin/env python3
# file: sim/gd_play_14_curve_spectrum.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_14_curve_spectrum.py
"""卷二 14.1 曲线与谱(S 层):两族图——按有序参数排序的曲线、按离散分箱的谱。
曲线横轴是**有序参数**,谱横轴是**离散分箱**;分箱怎么切决定你能看见什么——只按伤害分箱时,
防御牌整条不可见。本机无 matplotlib / scipy / cairosvg,所以轴、网格线、刻度、刻度值、图例
全部由 `xml.etree` 手绘,产出的 SVG 必须能被 `ET.fromstring` 回读。纵轴三个量(胜率、伤害贡献、
打出次数)都是**代理指标(proxy)**,只做相对比较,禁绝对化。全整数、无浮点、无 random。
"""
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_charts.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def replay(spec: dict, over: dict, seed: int, strat: list):
"""跑一局并同时记账:胜负、按牌型的伤害与打出次数。规则与 ashlib.run_spec 逐条对应。"""
s = copy.deepcopy(spec)
s["seed"] = seed
s["params"].update(over)
p = s["params"]
deck = []
for c in s["cards"]:
deck.extend([c] * c["count"])
hand, draw = ashlib.shuffle_deck(deck, s["seed"]), []
hand, draw = hand[: p["handSize"]], hand[p["handSize"]:]
energy, ehp, php, block, turn, won, over_ = (
p["energyMax"], p["enemyHp"], p["playerHp"], 0, 1, False, False)
dmg: dict = {}
plays: dict = {}
for cmd in strat:
if over_ or 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)
energy -= c["cost"]
block += c["block"]
d = min(c["damage"], ehp)
ehp -= d
dmg[c["id"]] = dmg.get(c["id"], 0) + d
plays[c["id"]] = plays.get(c["id"], 0) + 1
if ehp <= 0:
over_, won = True, True
elif cmd == "e":
absorbed = min(block, p["enemyAttack"])
block -= absorbed
php = max(0, php - (p["enemyAttack"] - absorbed))
if php <= 0:
break
turn += 1
block = 0
energy = p["energyMax"]
while len(hand) < p["handSize"] and draw:
hand.append(draw.pop(0))
return won, dmg, plays
def aggregate(spec: dict, over: dict, seeds: list, strat: list):
"""胜率取 ashlib.run_spec(权威实现),牌型记账取 replay,并逐种子比对胜负(不符必须为 0)。"""
win, dmg, plays, diff = 0, {}, {}, 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update(over)
ref = ashlib.run_spec(s, strat)["won"]
w, d, pl = replay(spec, over, sd, strat)
win += 1 if ref else 0
diff += 0 if ref == w else 1
for k, v in d.items():
dmg[k] = dmg.get(k, 0) + v
for k, v in pl.items():
plays[k] = plays.get(k, 0) + v
return 1000 * win // len(seeds), dmg, plays, diff
def draw_frame(root: ET.Element, box: tuple, ticks: list, title: str, sub: str) -> None:
"""手绘坐标框:ticks = [(标签, 像素, 'x'|'y')];x 画竖线+下方标签,y 画横线+左侧标签。"""
x0, y0, x1, y1 = box
ashlib.svg_text(root, x0, 24, title, 15)
ashlib.svg_text(root, x0, 42, sub, 11, "#94a3b8")
for lab, p, o in ticks:
if o == "x":
ET.SubElement(root, "line", {"x1": str(p), "y1": str(y0), "x2": str(p), "y2": str(y1),
"stroke": "#1e293b", "stroke-width": "1"})
ashlib.svg_text(root, p, y1 + 16, lab, 9, "#94a3b8", "middle")
else:
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(p), "x2": str(x1), "y2": str(p),
"stroke": "#1e293b", "stroke-width": "1"})
ashlib.svg_text(root, x0 - 6, p + 3, lab, 9, "#94a3b8", "end")
for x1_, y1_, x2_, y2_ in ((x0, y1, x1, y1), (x0, y0, x0, y1)):
ET.SubElement(root, "line", {"x1": str(x1_), "y1": str(y1_), "x2": str(x2_),
"y2": str(y2_), "stroke": "#64748b", "stroke-width": "1"})
def draw_curve(xs: list, series: dict, colors: list, path: str) -> int:
"""曲线族:每个系列一条 polyline + 数据点 + 图例色块。"""
W, H, box = 660, 320, (76, 60, 616, 262)
x0, y0, x1, y1 = box
px = lambda v: x0 + (v - xs[0]) * (x1 - x0) // (xs[-1] - xs[0])
py = lambda v: y1 - v * (y1 - y0) // 1000
root = ashlib.svg_begin(W, H)
draw_frame(root, box, [(str(v), px(v), "x") for v in xs]
+ [(str(v), py(v), "y") for v in (0, 250, 500, 750, 1000)],
"曲线:胜率(代理指标 ‰)随 enemyAttack 变化",
"横轴是有序参数 · 轴/刻度/图例全部 xml.etree 手绘 · 仅作相对比较")
for k, (hp, vals) in enumerate(series.items()):
ET.SubElement(root, "polyline", {
"points": " ".join(f"{px(xs[i])},{py(v)}" for i, v in enumerate(vals)),
"fill": "none", "stroke": colors[k], "stroke-width": "2", "data-series": hp})
for i, v in enumerate(vals):
ET.SubElement(root, "circle", {"cx": str(px(xs[i])), "cy": str(py(v)), "r": "3",
"fill": colors[k]})
ET.SubElement(root, "rect", {"x": str(x1 - 100), "y": str(y0 + 8 + 18 * k),
"width": "10", "height": "10", "fill": colors[k]})
ashlib.svg_text(root, x1 - 86, y0 + 17 + 18 * k, f"enemyHp={hp}", 11, colors[k])
return ashlib.svg_save(root, path)
def draw_spectrum(bins: list, series: list, colors: list, path: str) -> int:
"""谱族:每个分箱两根柱(同一分箱两种口径);柱带 data-value,图例放在轴下。"""
W, H, box = 660, 320, (76, 60, 616, 262)
x0, y0, x1, y1 = box
vmax = max(max(s["values"]) for s in series) or 1
group, bw = (x1 - x0) // len(bins), (x1 - x0) // len(bins) // 2 - 6
root = ashlib.svg_begin(W, H)
draw_frame(root, box, [(b, x0 + group * i + group // 2, "x") for i, b in enumerate(bins)]
+ [(str(v), y1 - v * (y1 - y0) // vmax, "y") for v in (0, vmax // 2, vmax)],
"谱:按牌型分箱的伤害贡献与打出次数(‰)",
"横轴是离散分箱 · 分箱切法决定看得见什么 · 轴/刻度/图例全部手绘")
for k, (s, col) in enumerate(zip(series, colors)):
ET.SubElement(root, "rect", {"x": str(x0 + 150 * k), "y": str(y1 + 44), "width": "10",
"height": "10", "fill": col})
ashlib.svg_text(root, x0 + 14 + 150 * k, y1 + 53, s["name"], 11, col)
for i, b in enumerate(bins):
bx = x0 + group * i + 6
for s, col in zip(series, colors):
v, by = s["values"][i], y1 - s["values"][i] * (y1 - y0) // vmax
ET.SubElement(root, "rect", {"x": str(bx), "y": str(by), "width": str(bw),
"height": str(y1 - by), "fill": col, "data-bin": b,
"data-metric": s["key"], "data-value": str(v)})
ashlib.svg_text(root, bx + bw // 2, by - 5, str(v), 9, "#e2e8f0", "middle")
bx += bw + 4
return ashlib.svg_save(root, path)
def readback(path: str):
"""回读校验:用 ET.fromstring 解析落盘 SVG,按 localname 统计元素。"""
text = open(path, encoding="utf-8").read()
root = ET.fromstring(text)
tags: dict = {}
for e in root.iter():
tags[e.tag.split("}")[-1]] = tags.get(e.tag.split("}")[-1], 0) + 1
return len(text.encode("utf-8")), tags, root
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 = cfg["seedPool"][: cfg["cellsPerScan"]]
strat = list(cfg["strategyPattern"]) * cfg["strategyRepeats"]
cv, sp, pal = cfg["curve"], cfg["spectrum"], cfg["palette"]
xs, diff = list(range(cv["from"], cv["to"] + 1, cv["step"])), 0
series: dict = {}
for hp in cv["fixedValues"]:
series[str(hp)] = []
for v in xs:
wp, _, _, d = aggregate(spec, {cv["fixedParam"]: hp, cv["swept"]: v}, seeds, strat)
series[str(hp)].append(wp)
diff += d
_, dmg, plays, d = aggregate(spec, sp["fixed"], seeds, strat)
diff += d
bins = sp["bins"]
tdmg = sum(dmg.get(b, 0) for b in bins) or 1
tplay = sum(plays.get(b, 0) for b in bins) or 1
bars = [{"key": "damage", "name": "伤害贡献‰",
"values": [1000 * dmg.get(b, 0) // tdmg for b in bins]},
{"key": "plays", "name": "打出次数‰",
"values": [1000 * plays.get(b, 0) // tplay for b in bins]}]
print(f"seeds={len(seeds)} 策略={len(strat)} 步({len(cfg['strategyPattern'])} 步模式 × "
f"{cfg['strategyRepeats']}) 曲线:{cv['swept']} {xs[0]}..{xs[-1]} step {cv['step']}")
print(f"{'enemyAttack':>11}" + "".join(f"{v:>5}" for v in xs))
for e in series:
print(f"{'hp=' + e:>11}" + "".join(f"{v:>5}" for v in series[e]))
print(f"\n谱:按牌型分箱({sp['fixed']})")
print(f"{'牌型':>8}{'伤害‰':>8}{'次数‰':>8}{'原始伤害':>10}{'打出次数':>10}")
for i, b in enumerate(bins):
print(f"{b:>8}{bars[0]['values'][i]:>8}{bars[1]['values'][i]:>8}"
f"{dmg.get(b, 0):>10}{plays.get(b, 0):>10}")
draw_curve(xs, series, pal["series"], os.path.join(OUT, "play14_curve.svg"))
draw_spectrum(bins, bars, [pal["bar"], pal["bar2"]], os.path.join(OUT, "play14_spectrum.svg"))
cbytes, ctags, croot = readback(os.path.join(OUT, "play14_curve.svg"))
sbytes, stags, sroot = readback(os.path.join(OUT, "play14_spectrum.svg"))
poly = [e for e in croot.iter() if e.tag.endswith("polyline")]
rects = [e for e in sroot.iter() if e.tag.endswith("rect") and e.get("data-value")]
recon: dict = {}
for e in rects:
recon.setdefault(e.get("data-metric"), {})[e.get("data-bin")] = int(e.get("data-value"))
mism = sum(1 for s in bars for i, b in enumerate(bins) if recon[s["key"]][b] != s["values"][i])
print(f"\n回读 curve : ET.fromstring ok bytes={cbytes} tags={ctags}")
print(f"回读 spectrum : ET.fromstring ok bytes={sbytes} tags={stags}")
print(f"曲线 polyline={len(poly)} 条,每条点数={[len(p.get('points').split()) for p in poly]}"
f"(数据 {len(xs)} 点 × {len(series)} 系列)")
print(f"谱 rect(带 data-value)={len(rects)}(分箱 {len(bins)} × 口径 {len(bars)})"
f" 回读重建逐格不符={mism}")
print(f"记账 replay vs ashlib.run_spec:胜负不符={diff}"
f"(共 {len(seeds) * (len(xs) * len(series) + 1)} 局)")
report = {"id": "gd_play_14_charts", "seeds": len(seeds), "strategySteps": len(strat), "xs": xs,
"series": series, "spectrum": {"bins": bins, "bars": bars, "rawDamage": dmg,
"rawPlays": plays},
"svg": {"curveBytes": cbytes, "spectrumBytes": sbytes, "curveTags": ctags,
"spectrumTags": stags, "reconMismatch": mism, "replayMismatch": diff},
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play14_charts.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"\nelapsed={report['elapsedMs']} ms")
print("wrote out/play14_curve.svg out/play14_spectrum.svg out/play14_charts.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_14_curve_spectrum.py:
seeds=32 策略=50 步(5 步模式 × 10) 曲线:enemyAttack 2..30 step 2
enemyAttack 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30
hp=60 1000 1000 1000 1000 1000 1000 1000 1000 812 718 343 31 31 31 31
hp=70 1000 1000 1000 1000 1000 1000 1000 1000 375 343 125 0 0 0 0
谱:按牌型分箱({'enemyHp': 70, 'enemyAttack': 6})
牌型 伤害‰ 次数‰ 原始伤害 打出次数
heavy 553 272 1239 96
strike 374 411 838 145
jab 72 161 163 57
guard 0 153 0 54
回读 curve : ET.fromstring ok bytes=7024 tags={'svg': 1, 'rect': 3, 'text': 24, 'line': 22, 'polyline': 2, 'circle': 30}
回读 spectrum : ET.fromstring ok bytes=4443 tags={'svg': 1, 'rect': 11, 'text': 19, 'line': 9}
曲线 polyline=2 条,每条点数=[15, 15](数据 15 点 × 2 系列)
谱 rect(带 data-value)=8(分箱 4 × 口径 2) 回读重建逐格不符=0
记账 replay vs ashlib.run_spec:胜负不符=0(共 992 局)
elapsed=65 ms
wrote out/play14_curve.svg out/play14_spectrum.svg out/play14_charts.json
读数
第一件事看曲线在哪里分开。enemyAttack 2..16 这 8 档里,hp=60 与 hp=70 两条曲线都压在 1000‰ 上,完全重合——图上只看得见一条线,另一条被盖住了。这不是画错,是这两组参数在低攻击区间没有区分度。分叉从 18 开始:hp=60 是 812‰、hp=70 是 375‰,到 22 变成 343 与 125,24 之后双双掉到 31‰ 与 0‰。曲线的价值在分叉点,不在平台段。
第二件事看谱的分箱与口径。heavy 伤害 553‰、次数 272‰;strike 374‰ 与 411‰;jab 72‰ 与 161‰;guard 0‰ 与 153‰。两个口径给出的排名完全不同:按伤害,heavy 第一;按次数,strike 第一。而 guard 在伤害谱上整条消失——如果只看伤害谱,会得出「防御牌没用」的结论,而它在次数谱上是第三常用的牌。谱的读法第一句永远是:这是哪个口径的谱。
第三件事看回读与记账。两张 SVG 都能被 ET.fromstring 解析(7024 与 4443 字节),曲线 2 条 polyline 各 15 点、谱 8 根柱,回读重建逐格不符 = 0;按牌型记账的那次重放与权威实现 ashlib.run_spec 比了 992 局,胜负不符 = 0。图的正确性不是「看着对」,而是这三个 0。
自检表
| 检查项 | 合格标准 |
|---|---|
| 回读与逐格比对 | 每张 SVG 都 ET.fromstring 解析成功,且图元属性重建回源数据后不符数为 0 |
| 记账可信 | 自定义重放与 ashlib.run_spec 逐种子比对胜负,不符数必须为 0 |
| 整数纪律 | 全部坐标与量值由整数地板除得到,无浮点、无 random;轴、刻度、图例全部手绘 |
| 代理指标声明 | 胜率、伤害贡献、打出次数都是 proxy,只作相对比较,禁绝对化 |
曲线与谱是一维的两族图。下一节把它们升到二维:同一个量同时随两个参数变化时,颜色就顶替了纵轴——策略空间的热力图。
阅读导航:上一节:13.3 指标的可比性 · 下一节:14.2 策略空间的热力图 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。