14.1 曲线与谱

动态可视化只需两族图:横轴是有序参数的曲线、横轴是离散分箱的谱。本节用纯 xml.etree 手绘坐标轴、网格线、刻度与图例,画出胜率随 enemyAttack 变化的曲线与按牌型分箱的伤害与次数谱,并用 ET.fromstring 回读重建逐格比对;胜率与占比都是代理指标,只作相对比较。

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 策略空间的热力图 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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