8.2 类型到机制的映射

映射度 = 施加一个机制旋钮后某类型的最优得分动了多少(千分比整数)。本节把 12 个旋钮逐个施加到切片规格上,对四个类型各重跑一遍策略空间,得到旋钮 × 类型矩阵:8 个普适、2 个偏向、2 个对照旋钮必须全零;而四个让战斗变短的旋钮反而让探索者受损。

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+10132
playerHp +10+9+61+5+556
guard.block +3+7+43+3+440
turnLimit +5+2+1+18+217

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 类型的分布与漂移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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