8.2 类型到机制的映射
《小游戏开发实战》卷一 5.1 用户画像与场景 那篇把画像用在产品决策上:素材、题材、节奏按人群分层。本节把同一批类型接到机制旋钮上,问一个更窄也更硬的问题——动了这个旋钮,哪一类玩家的最优得分动了多少。上一节证明了类型会挑不同策略;本节要的是那张「谁喂饱谁」的表,它是设计者排优先级时唯一能拿来说「这个改动是给谁做的」的东西。
映射度:一个旋钮对一个类型值多少
机制旋钮就是规格里可调的整数:params 段某个字段的加减,或者某张卡某个字段的加减。映射度定义成一个差:
Δ_t(k) = 施加旋钮 k 后类型 t 的最优得分 − 类型 t 的基线最优得分
两边都是千分比整数,所以 Δ 也是整数。注意它不是「这个旋钮有多好」,而是「对这类玩家有多好」——同一个旋钮对不同类型的 Δ 可以差出很远。每行再派生两个量:bias = max Δ − min Δ 是跨类型的偏向性,判定表如下:
| 判定 | 条件 | 读作 |
|---|---|---|
| 无响应 | 四个 Δ 全为 0 | 旋钮没接到任何结果轴(死旋钮或对照) |
| 普适 | bias ≤ 120‰ | 各类型同向受益,旋钮不挑人 |
| 偏向 | bias > 120‰ | 旋钮只喂饱一部分类型,是类型偏向旋钮 |
配置里故意放了两个对照旋钮——rewardGold +8 与 punishHp −2。run_spec 从不读这两个字段(卷一 14.1 已实测过它们的 Δ 恒为 0),所以它们的 Δ 必须全为 0。这是映射矩阵的自证有效:如果连对照旋钮都动了,说明脚本在别处泄漏,整张表作废。旋钮清单与判定阈值都写进 config:
{
"id": "gd_play_08_mapping",
"note": "卷二 8.2 类型到机制的映射:机制旋钮 × 玩家类型 → 映射度(最优得分的整数增量,千分比)",
"typesConfig": "config/gd_play_08_types.json",
"seeds": [401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416],
"knobs": [
{ "id": "energy", "label": "energyMax +1", "kind": "param", "param": "energyMax", "delta": 1 },
{ "id": "playerHp", "label": "playerHp +10", "kind": "param", "param": "playerHp", "delta": 10 },
{ "id": "enemyAttack", "label": "enemyAttack -2", "kind": "param", "param": "enemyAttack", "delta": -2 },
{ "id": "enemyHp", "label": "enemyHp -5", "kind": "param", "param": "enemyHp", "delta": -5 },
{ "id": "turnLimit", "label": "turnLimit +5", "kind": "param", "param": "turnLimit", "delta": 5 },
{ "id": "handSize", "label": "handSize +1", "kind": "param", "param": "handSize", "delta": 1 },
{ "id": "guard", "label": "guard.block +3", "kind": "card", "card": "guard", "field": "block", "delta": 3 },
{ "id": "strike", "label": "strike.damage +2", "kind": "card", "card": "strike", "field": "damage", "delta": 2 },
{ "id": "heavy", "label": "heavy.damage +2", "kind": "card", "card": "heavy", "field": "damage", "delta": 2 },
{ "id": "jab", "label": "jab.damage +2", "kind": "card", "card": "jab", "field": "damage", "delta": 2 },
{ "id": "rewardGold", "label": "rewardGold +8(对照)", "kind": "param", "param": "rewardGold", "delta": 8 },
{ "id": "punishHp", "label": "punishHp -2(对照)", "kind": "param", "param": "punishHp", "delta": -2 }
],
"biasWarnPermille": 120
}
S 层:12 个旋钮 × 4 个类型
脚本对每个旋钮复制一份切片规格、施加改动,然后重跑整个策略空间(4096 条模式串 × 16 个种子),重新求每个类型的 argmax 得分,再与基线相减。12 个旋钮 + 1 次基线共 13 遍枚举,全整数、无浮点、无 random。产物是 JSON + CSV + 一张纯 xml.etree 的判定矩阵 SVG(绿 = 受益、红 = 受损、灰 = 无响应)。
#!/usr/bin/env python3
# file: sim/gd_play_08_mapping.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_08_mapping.py
"""卷二 8.2 类型到机制的映射(S 层):机制旋钮 × 玩家类型 → 映射度矩阵。
上一节把「玩家类型」写成结果轴上的整数权重向量,并让每个类型在策略空间里挑出
自己的最优策略。本节换一个方向问:**哪一个机制旋钮,真正喂饱了哪一类玩家**。
做法:把每个旋钮(参数加减、卡牌字段加减)单独施加到切片规格上,重新枚举策略空间、
重新求每个类型的 argmax 得分,映射度 = 新得分 − 基线得分(千分比整数)。于是得到
一张「旋钮 × 类型」的矩阵。每行再看两个派生量:
bias = max Δ − min Δ(跨类型的偏向性:越大越说明这个旋钮只喂一类人)
判定 = 无响应(全 0)/ 普适(bias ≤ 阈值)/ 偏向(bias > 阈值)
配置里故意放了两个**对照旋钮**(`rewardGold` / `punishHp`)——`run_spec` 从不读它们,
它们的 Δ 必须全为 0;这是映射矩阵的**自证有效**:如果连它们都动了,说明脚本在别处泄漏。
映射度是**代理指标**:它只说明「相对基线,这类玩家的最优得分动了多少」,不是体验的
度量,禁止绝对化。全程整数、无浮点、无 random。产出 out/play08_mapping.json + .csv + .svg。
"""
import copy
import itertools
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")
TYPES_CONFIG = os.path.join(ROOT, "config/gd_play_08_types.json")
CONFIG = os.path.join(ROOT, "config/gd_play_08_mapping.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
AXES = ("win", "dealt", "hp", "play", "speed", "eff")
FILL_POS, FILL_NEG, FILL_ZERO = "#22c55e", "#ef4444", "#334155"
def apply_knob(spec, knob):
"""把旋钮施加到规格上:kind=param 改 params,kind=card 改某张卡的某字段。"""
s = copy.deepcopy(spec)
if knob["kind"] == "param":
s["params"][knob["param"]] += knob["delta"]
else:
for c in s["cards"]:
if c["id"] == knob["card"]:
c[knob["field"]] += knob["delta"]
return s
def features(spec, seeds, seq, refs):
p = spec["params"]
n = len(seeds)
acc = [0, 0, 0, 0]
wins = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
sn = ashlib.run_spec(s, seq)
acc[0] += sn["dealt"]
acc[1] += sn["playerHp"]
acc[2] += sn["played"]
acc[3] += sn["turn"]
wins += 1 if sn["won"] else 0
dealt, hp, played, turn = (v // n for v in acc)
return (
1000 * wins // n,
1000 * dealt // p["enemyHp"],
1000 * hp // p["playerHp"],
1000 * played // refs["playedRef"],
1000 * (p["turnLimit"] - turn) // p["turnLimit"],
1000 * dealt // (played * refs["damageRef"]) if played else 0,
)
def best_scores(spec, seeds, cfg, refs):
"""枚举策略空间(按特征向量去重),返回每个类型的最优得分。"""
patterns, horizon = cfg["patterns"], cfg["horizon"]
letters = sorted(patterns)
by_vec = {}
for combo in itertools.product(letters, repeat=horizon):
seq = []
for letter in combo:
seq.extend("p%d" % slot for slot in patterns[letter])
seq.append("e")
by_vec.setdefault(features(spec, seeds, seq, refs), "".join(combo))
out = {}
for t in cfg["types"]:
w, best = t["weights"], None
for vec in sorted(by_vec):
sc = sum(w[a] * vec[i] for i, a in enumerate(AXES)) // 1000
if best is None or sc > best:
best = sc
out[t["id"]] = best
return out, len(by_vec)
def matrix(rows, type_names, path):
cw, ch, x0, y0 = 130, 30, 200, 78
W, H = x0 + cw * len(type_names) + 60, y0 + ch * len(rows) + 60
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 16, 28, "机制旋钮 × 玩家类型:映射度(Δ 最优得分,千分比)", 15)
ashlib.svg_text(root, 16, 50, "绿 = 该类型受益 红 = 受损 灰 = 无响应(对照旋钮应全灰)", 11, "#94a3b8")
for j, name in enumerate(type_names):
ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 - 12, name, 11, "#cbd5e1", "middle")
for i, r in enumerate(rows):
y = y0 + i * ch
ashlib.svg_text(root, 16, y + 19, f"{r['label']} bias={r['bias']}", 11)
for j, name in enumerate(type_names):
d = r["delta"][name]
fill = FILL_ZERO if d == 0 else (FILL_POS if d > 0 else FILL_NEG)
ET.SubElement(root, "rect", {
"x": str(x0 + j * cw + 4), "y": str(y + 4), "width": str(cw - 8),
"height": str(ch - 8), "fill": fill, "rx": "4",
})
ashlib.svg_text(root, x0 + j * cw + cw // 2, y + 20, f"{d:+d}", 11, "#0f172a", "middle")
return ashlib.svg_save(root, path)
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(TYPES_CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
with open(CONFIG, encoding="utf-8") as f:
knobs_cfg = json.load(f)
refs, slice_over, seeds = cfg["refs"], cfg["slice"], knobs_cfg["seeds"]
names = [t["name"] for t in cfg["types"]]
ids = [t["id"] for t in cfg["types"]]
base_spec = copy.deepcopy(spec)
base_spec["params"].update(slice_over)
base, nvec = best_scores(base_spec, seeds, cfg, refs)
rows = []
for knob in knobs_cfg["knobs"]:
variant = apply_knob(base_spec, knob)
sc, _ = best_scores(variant, seeds, cfg, refs)
delta = {t["name"]: sc[t["id"]] - base[t["id"]] for t in cfg["types"]}
vals = list(delta.values())
bias = max(vals) - min(vals)
verdict = ("无响应" if all(v == 0 for v in vals)
else "偏向" if bias > knobs_cfg["biasWarnPermille"] else "普适")
rows.append({"id": knob["id"], "label": knob["label"], "delta": delta,
"bias": bias, "verdict": verdict})
csv = [[r["label"]] + [r["delta"][nm] for nm in names] + [r["bias"], r["verdict"]] for r in rows]
n = ashlib.write_csv(os.path.join(OUT, "play08_mapping.csv"),
["knob"] + ids + ["bias", "verdict"], csv)
size = matrix(rows, names, os.path.join(OUT, "play08_mapping.svg"))
report = {"id": "gd_play_08_mapping", "slice": slice_over, "seeds": len(seeds),
"strategies": len(cfg["patterns"]) ** cfg["horizon"], "distinctVectors": nvec,
"base": {t["name"]: base[t["id"]] for t in cfg["types"]}, "rows": rows,
"counts": {v: sum(1 for r in rows if r["verdict"] == v)
for v in ("无响应", "普适", "偏向")},
"biasWarnPermille": knobs_cfg["biasWarnPermille"],
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play08_mapping.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"切片 enemyHp={slice_over['enemyHp']} enemyAttack={slice_over['enemyAttack']}"
f" 去重后 {nvec} 个特征向量 基线最优得分 " + " ".join(f"{k}={v}" for k, v in report["base"].items()))
print(f"{'机制旋钮':<22}" + "".join(f"{nm:>8}" for nm in names) + f"{'bias':>7} 判定")
for r in rows:
print(f"{r['label']:<22}" + "".join(f"{r['delta'][nm]:>+8}" for nm in names)
+ f"{r['bias']:>7} {r['verdict']}")
print(f"counts={report['counts']}")
print(f"csv={n} rows svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play08_mapping.json out/play08_mapping.csv out/play08_mapping.svg")
ET.parse(os.path.join(OUT, "play08_mapping.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_08_mapping.py:
切片 enemyHp=55 enemyAttack=16 去重后 150 个特征向量 基线最优得分 征服者=916 守护者=527 效率者=876 探索者=785
机制旋钮 征服者 守护者 效率者 探索者 bias 判定
energyMax +1 +20 +142 +40 +10 132 偏向
playerHp +10 +9 +61 +5 +5 56 普适
enemyAttack -2 +10 +66 +5 +5 61 普适
enemyHp -5 +6 +58 +4 -53 111 普适
turnLimit +5 +2 +1 +18 +2 17 普适
handSize +1 +2 +12 +1 +1 11 普适
guard.block +3 +7 +43 +3 +4 40 普适
strike.damage +2 +9 +85 +19 -52 137 偏向
heavy.damage +2 +4 +47 +14 -54 101 普适
jab.damage +2 +3 +35 +3 -55 90 普适
rewardGold +8(对照) +0 +0 +0 +0 0 无响应
punishHp -2(对照) +0 +0 +0 +0 0 无响应
counts={'无响应': 2, '普适': 8, '偏向': 2}
csv=12 rows svg=12103 bytes elapsed=18539 ms
wrote out/play08_mapping.json out/play08_mapping.csv out/play08_mapping.svg
读表:旋钮确实挑人,而且有旋钮会伤到人
矩阵里有三件值得拆开的事。第一,两个对照旋钮严格全零(rewardGold、punishHp 两行 bias=0、判定「无响应」),说明这套枚举—聚合—求差的链路没有别的泄漏口;这是读下面所有数字的前提。
第二,同一个旋钮对不同类型的分量差一个数量级:
| 旋钮 | 征服者 | 守护者 | 效率者 | 探索者 | bias |
|---|---|---|---|---|---|
energyMax +1 | +20 | +142 | +40 | +10 | 132 |
playerHp +10 | +9 | +61 | +5 | +5 | 56 |
guard.block +3 | +7 | +43 | +3 | +4 | 40 |
turnLimit +5 | +2 | +1 | +18 | +2 | 17 |
energyMax +1 对守护者值 +142‰,对探索者只值 +10‰,同一行内部的 bias 就是 132‰。原因是守护者的主轴是存活,而多一格能量最直接的用途就是多打一张格挡;探索者的主轴是出牌量,可它已经贴着 playedRef 的天花板,多一格能量换不来更多次出牌。「这个旋钮很强」这句话,不指定类型就没有意义。
第三,也是最有意思的一类:有四个旋钮让探索者净受损。它们都是「让战斗变短或变强」的旋钮,方向对其它三类一致为正:
| 旋钮 | 守护者 | 探索者 | 探索者为什么掉 |
|---|---|---|---|
enemyHp −5 | +58 | −53 | 敌人更脆 → 战斗提前结束 → 出牌机会减少 |
strike.damage +2 | +85 | −52 | 同样:打得更疼,牌还没出完就打完了 |
heavy.damage +2 | +47 | −54 | 同上,且更明显 |
jab.damage +2 | +35 | −55 | 同上 |
这不是脚本出错,而是代理指标的效力边界:play 轴量的是「出牌次数」,它在「玩家必胜、且战斗时长由机制决定」的切片上,与「输出强度」是负相关的。把敌人改脆、把伤害调高,对绝大多数玩家是好事,对「以操作量为目标」的那一类玩家却是坏事。落到设计上就是一条具体取舍:如果探索者是你的一类目标玩家,那么「加强玩家」和「缩短战斗」是两个会互相抵消的方向,必须分开评估。
自检表
| 检查项 | 合格标准 |
|---|---|
| 映射度可算 | Δ 定义为「旋钮后最优得分 − 基线最优得分」,两侧同口径 |
| 旋钮入档 | 旋钮清单、步长与判定阈值都写进 config,不散落在正文 |
| 对照必为零 | rewardGold / punishHp 的 Δ 全为 0,否则整表作废 |
| 偏向性同报 | 每行报 bias,避免把「偏向旋钮」当成「普适旋钮」引用 |
| 负值不隐藏 | Δ 为负要照样入表并解释,不能只留正向旋钮 |
| 代理指标显式 | 映射度是 proxy,只做旋钮之间、类型之间的相对比较 |
这一节把「类型」接到了「旋钮」上,但用的是一个切片上的一批种子。下一节把这两样都放开:类型人口结构会不会随参数整体挪动,以及类型边界本身稳不稳。
阅读导航:上一节:8.1 玩家类型学 · 下一节:8.3 类型的分布与漂移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。