6.1 参数敏感度

固定 16 个显式整数种子,把 params.enemyHp 从 20 扫到 80、步长 5,逐格统计胜率千分比(1000×wins÷total,禁浮点)与平均剩余 HP:本节给出 B 层单参数敏感度的完整做法、可跑的 S 层脚本、CSV 与纯 xml.etree 折线图,并说明为什么「胜率全程 1000‰」不等于机制平衡。

6.1 参数敏感度

《小游戏开发实战》卷一 7.2 难度曲线与心流 用 S 层算过一条心流模型曲线,并在 B 层问过「难度对参数有多敏感」——那是曲线上的斜率:某个参数动一格,难度指标动多少。本节沿用同一套整数纪律,但只把单参数这一件事做完整:固定 16 个显式种子,把一个参数逐格扫过去,逐格报胜率。真正的相变——参数扫过某个值之后系统性质整体改变——留给 6.2;本节先把「一格一格看」的地基打好。

敏感度问的是什么

「这个机制对参数敏感吗」是个含糊的问题,把它拆成三个可以回答的子问题:

子问题可计算的量本节的做法
动一格会怎样相邻参数值之间的胜率差 Δ粗扫步长 5,逐格报胜率
随机性会不会淹没它同一格内种子之间的胜率散布每格固定 16 个种子,同种子集跨格复用
玩家体感怎么变平均剩余 HP每格同时报平均剩余 HP

第二行是关键。如果不固定种子集合,每一格用不同种子,那么「胜率变化」里既有参数的贡献、也有抽样的贡献,两者混在一起无法归因。固定种子集合 = 把随机性变成常量,于是两次运行之间唯一的变量就是参数本身。这也是 B 层与 S 层共享同一份 run_spec 的原因:同一份规格、同一套种子、同一个整数地板除,跨格可比。

种子集合为什么是 16 个

种子数是分辨率与算力之间的取舍:胜率是 1000×wins÷total,所以每格能分辨的最小缺口就是 1000÷total。16 个种子的分辨率是 63‰,刚好够看出「16 个种子里掉 1 个」这种量级的缺口——本节后面正是靠它把三段结构读出来的。

种子数每格分辨率粗扫 13 格的 run 数读法
4250‰52太粗:-63‰ 的缺口会被四舍五入抹平
1663‰208本节的取值:能分辨「掉 1 个种子」,且毫秒级跑完
6416‰832更细,但 6.2 的临界点并不依赖这个精度

规格字段:可扰动参数与扫描网格

扰动网格不进 play_spec.json——规格只描述这一局,网格描述怎么扰动它。两者分开的好处是:规格可以照常内联进单文件 HTML 去真玩,网格可以随时换。本节读的网格是只读配置 config/gd_mech_06_perturb.json:

{
  "id": "gd_mech_06_perturb",
  "note": "卷一第 6 章 B 层的可扰动参数:显式种子集合 + 单参数扫描网格 + 分类阈值(全整数,禁浮点)",
  "param": "enemyHp",
  "seeds": [101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116],
  "coarse": { "from": 20, "to": 80, "step": 5 },
  "bisect": { "crossPermille": 500, "bandHighPermille": 900, "bandLowPermille": 100, "maxRounds": 6 },
  "fine": { "radius": 10 },
  "classify": { "stableStepPermille": 100, "fragileStepPermille": 200 }
}

seeds 是 16 个显式写死的整数,不是「从 101 到 116」这种描述——写死才能保证换语言、换机器、换年份,跑出来的格子一模一样。coarse.step=5 与 fine.radius=10 是计算量闸门:粗扫 13 格、细扫最多 21 格、二分最多 6 轮,全部乘上 16 个种子,量级被钉在千次以内,脚本分钟级必完。

S 层:固定种子集合上的单参数扫描

下面这段是 S 层脚本 sim/gd_mech_06_sensitivity.py。它读只读规格与扰动网格,对每个参数值跑满 16 个种子,把胜率写成千分比整数、把剩余 HP 写成整数均值,再用 ashlib 的 svg_begin/svg_text/svg_save 与自写的 <polyline> 画折线。全程不出现浮点;连计时都用 perf_counter_ns 的整数纳秒。

#!/usr/bin/env python3
# file: sim/gd_mech_06_sensitivity.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_06_sensitivity.py
"""卷一 6.1 参数敏感度(S 层):固定 16 个显式种子,扫**单个**参数。

一份 play_spec.json 的 params.enemyHp 从 20 扫到 80(步长 5),每个取值下跑同一套
16 个显式整数种子,统计**胜率千分比**(1000 * wins // total)与**平均剩余 HP**
(整数地板除)。产出 out/mech06_sensitivity.csv(每个参数值一行)与
out/mech06_sensitivity.svg(胜率折线,纯 xml.etree 的 <polyline>)。

只读 config/gd_mech_06_perturb.json 的网格与种子;不改 play_spec.json,不用浮点,
不用 random 模块,不写 config/。整条链路上唯一的计时量用 perf_counter_ns(整数纳秒)。
"""
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_mech_06_perturb.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

CSV_PATH = os.path.join(OUT, "mech06_sensitivity.csv")
SVG_PATH = os.path.join(OUT, "mech06_sensitivity.svg")


def cell(spec, param, value, seeds):
    """一个参数取值 × 一组种子 → (wins, winPermille, avgPlayerHp, avgEnemyHp)。"""
    wins = php = ehp = 0
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        s["params"][param] = value
        snap = ashlib.run_spec(s)
        if snap["won"]:
            wins += 1
        php += snap["playerHp"]
        ehp += snap["enemyHp"]
    n = len(seeds)
    return wins, 1000 * wins // n, php // n, ehp // n


def sweep(spec, param, lo, hi, step, seeds):
    rows = []
    v = lo
    while v <= hi:
        wins, permille, php, ehp = cell(spec, param, v, seeds)
        rows.append({"value": v, "total": len(seeds), "wins": wins,
                     "winPermille": permille, "avgPlayerHp": php, "avgEnemyHp": ehp})
        v += step
    return rows


def draw(rows, param, lo, hi, path):
    """胜率折线:X = 参数值,Y = 胜率千分比;500‰ 处画一条虚线当临界参考。"""
    W, H = 660, 320
    x0, x1, y0, y1 = 70, 600, 40, 250
    span = hi - lo
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, x0, 24, f"{param} 敏感度:胜率(千分比)随参数变化", 15)
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x1), "y2": str(y1), "stroke": "#64748b"})
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y0), "x2": str(x0), "y2": str(y1), "stroke": "#64748b"})
    for p in range(0, 1001, 200):
        y = y1 - p * (y1 - y0) // 1000
        ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y), "x2": str(x1), "y2": str(y), "stroke": "#1e293b"})
        ashlib.svg_text(root, x0 - 8, y + 4, p, 11, "#94a3b8", "end")
    for v in range(lo, hi + 1, 10):
        x = x0 + (v - lo) * (x1 - x0) // span
        ET.SubElement(root, "line", {"x1": str(x), "y1": str(y1), "x2": str(x), "y2": str(y0), "stroke": "#1e293b"})
        ashlib.svg_text(root, x, y1 + 18, v, 11, "#94a3b8", "middle")
    yc = y1 - 500 * (y1 - y0) // 1000
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(yc), "x2": str(x1), "y2": str(yc),
                                 "stroke": "#f59e0b", "stroke-dasharray": "6 4"})
    ashlib.svg_text(root, x1 + 6, yc + 4, "500‰", 11, "#f59e0b")
    pts = " ".join(f"{x0 + (r['value'] - lo) * (x1 - x0) // span},"
                   f"{y1 - r['winPermille'] * (y1 - y0) // 1000}" for r in rows)
    ET.SubElement(root, "polyline", {"points": pts, "fill": "none", "stroke": "#38bdf8", "stroke-width": "2"})
    for r in rows:
        x = x0 + (r["value"] - lo) * (x1 - x0) // span
        y = y1 - r["winPermille"] * (y1 - y0) // 1000
        ET.SubElement(root, "circle", {"cx": str(x), "cy": str(y), "r": "3", "fill": "#38bdf8"})
        ashlib.svg_text(root, x, y - 8, r["winPermille"], 10, "#cbd5e1", "middle")
    return ashlib.svg_save(root, path)


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    param = cfg["param"]
    seeds = cfg["seeds"]
    lo, hi, step = cfg["coarse"]["from"], cfg["coarse"]["to"], cfg["coarse"]["step"]
    rows = sweep(spec, param, lo, hi, step, seeds)
    ashlib.write_csv(CSV_PATH, ["enemyHp", "seeds", "wins", "winPermille", "avgPlayerHp", "avgEnemyHp"],
                     [[r["value"], r["total"], r["wins"], r["winPermille"], r["avgPlayerHp"], r["avgEnemyHp"]]
                      for r in rows])
    size = draw(rows, param, lo, hi, SVG_PATH)
    # 相邻参数值的最大跌幅:敏感度分析要回答「哪一段最陡」。
    drops = [(rows[i + 1]["value"], rows[i + 1]["winPermille"] - rows[i]["winPermille"])
             for i in range(len(rows) - 1)]
    steep = min(drops, key=lambda d: d[1])
    for r in rows:
        print(f"{param}={r['value']:>3}  wins={r['wins']:>2}/{r['total']}  "
              f"winRate={r['winPermille']:>4}‰  avgPlayerHp={r['avgPlayerHp']:>2}")
    print(f"grid={len(rows)} cells  seeds={len(seeds)}  runs={len(rows) * len(seeds)}")
    print(f"steepest: {param}={steep[0]}  delta={steep[1]}‰")
    print(f"svg={size} bytes  elapsed={(time.perf_counter_ns() - t0) // 1000000} ms")
    print("wrote out/mech06_sensitivity.csv out/mech06_sensitivity.svg")
    ET.parse(SVG_PATH)
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_06_sensitivity.py(绝对数值不可移植,同机相对量级可信):

enemyHp= 20  wins=16/16  winRate=1000‰  avgPlayerHp=58
enemyHp= 25  wins=16/16  winRate=1000‰  avgPlayerHp=56
enemyHp= 30  wins=16/16  winRate=1000‰  avgPlayerHp=54
enemyHp= 35  wins=16/16  winRate=1000‰  avgPlayerHp=54
enemyHp= 40  wins=16/16  winRate=1000‰  avgPlayerHp=52
enemyHp= 45  wins=16/16  winRate=1000‰  avgPlayerHp=51
enemyHp= 50  wins=16/16  winRate=1000‰  avgPlayerHp=50
enemyHp= 55  wins=16/16  winRate=1000‰  avgPlayerHp=48
enemyHp= 60  wins=16/16  winRate=1000‰  avgPlayerHp=47
enemyHp= 65  wins=16/16  winRate=1000‰  avgPlayerHp=45
enemyHp= 70  wins=16/16  winRate=1000‰  avgPlayerHp=44
enemyHp= 75  wins=15/16  winRate= 937‰  avgPlayerHp=44
enemyHp= 80  wins= 0/16  winRate=   0‰  avgPlayerHp=39
grid=13 cells  seeds=16  runs=208
steepest: enemyHp=80  delta=-937‰
svg=5472 bytes  elapsed=11 ms
wrote out/mech06_sensitivity.csv out/mech06_sensitivity.svg

同一趟写出的 out/mech06_sensitivity.csv(13 格,每格一行):

enemyHp,seeds,wins,winPermille,avgPlayerHp,avgEnemyHp
20,16,16,1000,58,0
25,16,16,1000,56,0
30,16,16,1000,54,0
35,16,16,1000,54,0
40,16,16,1000,52,0
45,16,16,1000,51,0
50,16,16,1000,50,0
55,16,16,1000,48,0
60,16,16,1000,47,0
65,16,16,1000,45,0
70,16,16,1000,44,0
75,16,15,937,44,0
80,16,0,0,39,2

判读:三段式的敏感度结构

13 个格子读下来不是一条平滑曲线,而是三段:

参数区间胜率平均剩余 HP读法
enemyHp 20–701000‰58 → 44平顶:胜率对参数完全不敏感
enemyHp 70–751000‰ → 937‰44 → 44第一道缺口:16 个种子里掉 1 个
enemyHp 75–80937‰ → 0‰44 → 39悬崖:粗扫一步之内从 937‰ 掉到 0‰

第一段最容易误读。胜率全程 1000‰ 听起来像「机制很平衡」,其实它只说明固定策略的输出上限还没被敌人血量追上:enemyHp 在 20 到 70 之间怎么调,一局打完敌人都会死,胜率当然不变。这不是平衡,是还没进入有张力的区间。同理,最后一段的胜率恒 0‰ 也不是「太难的平衡」,而是已经越过了输出上限。两端都不是设计目标,中间那道悬崖才是。

平均剩余 HP 是第二个独立信号:它从 58 单调降到 39,且在胜率还是 1000‰ 的时候就在降(20→70 段掉了 14 点)。这说明胜率这个序参量在平顶区是失明的——要看出「难度其实在变」,得同时盯住剩余 HP。一个指标不够,两个指标才能定位。

自检表

检查项合格标准
种子显式16 个种子写死在配置里,不是「1..16」的生成式描述
单一变量跨格只动一个参数,其余字段与种子集合逐格相同
无浮点胜率与均值都是整数地板除;计时用整数纳秒
指标成对胜率之外必须同时报一个连续量(此处是平均剩余 HP)
说法诚实平顶区写「尚未进入有张力区间」,不写「平衡良好」

粗扫只能告诉我们「悬崖在 75 到 80 之间」。要把它压到一个整数上,需要换一种扫法——下一节用粗扫加二分把临界点钉到 ±1,并给临界点一个可算的定义。

阅读导航:上一节:5.3 原型与内核的同源性 · 下一节:6.2 相变扫描:游戏被解开的点 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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