8.1 玩家类型学

玩家类型在散文里是四个标签,在机制里必须是四条目标函数。本节把类型写成六个结果轴(胜率、伤害进度、存活、出牌量、速通、伤害效率)上的整数权重向量,在 4096 条回合模式策略上枚举出 150 个结果画像,让四个类型各自挑出最优策略:主种子池上四类选出四条不同代表策略,换池掉到两条。

8.1 玩家类型学

《小游戏开发实战》卷一 5.1 用户画像与场景 那篇做的是产品口径的画像:这款游戏写给谁、在什么场景下玩、用什么题材与节奏打动人。画像是一份叙事,靠访谈、问卷与行为日志建立,回答的是「该服务谁」。本节做的是机制口径的同一件事,并且只做能被仿真回答的那一半:把「玩家类型」写成结果轴上的目标函数,然后问一个可跑的问题——同一套机制下,不同类型会不会选出不同的打法。前者不可证伪(没人能说一份画像「跑错了」),后者可以:跑一遍,四条目标函数各挑一条策略,是不是同一条,一看便知。

类型不是标签,是目标函数

类型学最有名的一版是 Bartle 的四分类:成就者、探索者、社交家、杀手。它的价值是给了行业一套共同词汇,代价是四个词都没法直接喂给仿真——「我是探索者」这句话既不能被证伪,也不能被优化。要让它进机制,必须补一步翻译:类型 = 一组权重。

翻译的落点是结果轴:一局《灰烬牌库》跑完,run_spec 交回一批整数(伤害、剩余血量、回合数、出牌数、胜负)。把它们归一成六个千分比,就得到本节用的六条轴:

轴定义(全整数)范围读作
win胜场数 ÷ 种子数0–1000赢面
dealt平均伤害 ÷ enemyHp0–1000进度
hp平均剩余血量 ÷ playerHp0–1000存活
play平均出牌数 ÷ playedRef0–1300操作量
speed(turnLimit − 平均回合) ÷ turnLimit0–1000速通
eff平均伤害 ÷ (平均出牌数 × damageRef)0–1300单张产出

一个类型就是这六条轴上的整数权重向量 w(和为 1000),策略 s 对该类型的得分是加权和的地板除:

score_t(s) = ( w_win·f_win(s) + w_dealt·f_dealt(s) + … + w_eff·f_eff(s) ) ÷ 1000

整式只有乘加与地板除,结果是一个千分比整数。六个结果轴都是代理指标(proxy):它们不是「好玩」的度量,score_t 更不是「这类玩家玩得爽不爽」的度量;它们只用于相对比较——同一类型在不同机制下、或不同策略之间谁高谁低。禁止写成「体验提升 N 倍」,那需要一个从未被定义过的体验单位。

四类型 = 四条权重向量

本节的四类型不是 Bartle 的四个词照搬,而是从这套机制真有的六条轴里挑四条主轴建出来的(本卷不覆盖社交,故没有「社交家」):

类型主轴windealthpplayspeedeff
征服者win70010010050500
守护者hp10050700100500
效率者speed1001005050600100
探索者play10010050700500

四条向量都给主权重 700、把余下 300 摊到别处,这样即使主轴被打平,类型之间也不会完全坍缩到同一个 argmax。权重、结果轴与策略字母表都写进 config,改口径只改配置:

{
  "id": "gd_play_08_types",
  "note": "卷二 8.1 玩家类型学:类型 = 结果轴上的整数权重向量,策略空间 = 回合模式串(全整数,禁浮点)",
  "slice": { "enemyHp": 55, "enemyAttack": 16 },
  "seeds": [401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416],
  "altPools": [[501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516],
               [601, 602, 603, 604, 605, 606, 607, 608, 609, 610, 611, 612, 613, 614, 615, 616]],
  "refs": { "playedRef": 12, "damageRef": 6 },
  "axes": ["win", "dealt", "hp", "play", "speed", "eff"],
  "patterns": {
    "A": [0, 0, 0, 0],
    "B": [0, 0, 0],
    "C": [1, 0, 0, 0],
    "D": [3, 0, 0, 0],
    "E": [2, 0, 0, 0],
    "F": [3, 3, 0],
    "G": [3, 3, 3],
    "H": [0, 0, 0, 0, 0]
  },
  "horizon": 4,
  "types": [
    { "id": "conqueror", "name": "征服者", "weights": { "win": 700, "dealt": 100, "hp": 100, "play": 50, "speed": 50, "eff": 0 } },
    { "id": "guardian", "name": "守护者", "weights": { "win": 100, "dealt": 50, "hp": 700, "play": 100, "speed": 50, "eff": 0 } },
    { "id": "optimizer", "name": "效率者", "weights": { "win": 100, "dealt": 100, "hp": 50, "play": 50, "speed": 600, "eff": 100 } },
    { "id": "explorer", "name": "探索者", "weights": { "win": 100, "dealt": 100, "hp": 50, "play": 700, "speed": 50, "eff": 0 } }
  ]
}

S 层:4096 条策略 × 4 条目标函数

策略空间不能直接枚举动作序列(长度一上去就爆炸),所以本节把策略压成回合模式串:字母表 A–H 里每个字母是一回合内要依次尝试的卡槽序列(A = 打 4 次最左、D = 先打格挡位、H = 打 5 次最左),长度 4 表示只管前四个回合,回合末自动补 e。8^4 = 4096 条串,每条跑 16 个显式种子,跨种子聚合成六轴特征向量后按向量去重——4096 条串只落在 150 个不同的结果画像上。脚本全程整数、无浮点、无 random,产物是 JSON + CSV + 纯 xml.etree SVG。

#!/usr/bin/env python3
# file: sim/gd_play_08_taxonomy.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_08_taxonomy.py
"""卷二 8.1 玩家类型学(S 层):把「玩家类型」写成结果轴上的整数权重向量。

类型在散文里是四个词,在机制里必须是四条**目标函数**:每个类型给一组整数权重 w,
策略 s 对该类型的得分 score_t(s) = Σ w_i · f_i(s) ÷ 1000,f_i 是跨种子聚合的六个
结果轴(全为千分比整数):win 胜场;dealt 伤害÷enemyHp;hp 存活÷playerHp;
play 出牌÷playedRef;speed (turnLimit−turn)÷turnLimit;eff 伤害÷(出牌·damageRef)。

策略空间 = 回合模式串:8 个模式字母表 × 长度 horizon,枚举后按特征向量去重。每个
类型取自己得分最高的策略,据此算**类型区分度**、并列规模、两两类型距离与 Pareto 前沿。

六个结果轴都是**代理指标**:只用于类型之间、参数之间的相对比较,禁止绝对化。
全程整数、无浮点、无 random。产出 out/play08_taxonomy.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")
CONFIG = os.path.join(ROOT, "config/gd_play_08_types.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

AXES = ("win", "dealt", "hp", "play", "speed", "eff")
BAR_DEN = 1300


def strategy_of(combo, patterns):
    """模式串 → 动作序列:每个模式展开成若干 p<i>,回合末补一个 e。"""
    seq = []
    for letter in combo:
        seq.extend("p%d" % slot for slot in patterns[letter])
        seq.append("e")
    return seq


def features(spec, over, seeds, seq, refs):
    """跨种子聚合到六个千分比结果轴(整数地板除,无浮点)。"""
    p = spec["params"]
    acc = [0, 0, 0, 0]
    wins = 0
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        s["params"].update(over)
        sn = ashlib.run_spec(s, seq)
        acc = [a + b for a, b in zip(acc, (sn["dealt"], sn["playerHp"], sn["played"], sn["turn"]))]
        wins += 1 if sn["won"] else 0
    n = len(seeds)
    dealt, hp, played, turn = (v // n for v in acc)
    return (1000 * wins // n, 1000 * dealt // over.get("enemyHp", 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 enumerate_space(spec, over, seeds, cfg, refs):
    """枚举模式串 → 按特征向量去重,返回 {特征向量: 首个代表模式串}。"""
    patterns = cfg["patterns"]
    by_vec = {}
    for combo in itertools.product(sorted(patterns), repeat=cfg["horizon"]):
        vec = features(spec, over, seeds, strategy_of(combo, patterns), refs)
        by_vec.setdefault(vec, "".join(combo))
    return by_vec


def pick(by_vec, weights):
    """确定性 argmax:按特征向量字典序遍历,严格大于才替换,同时报并列规模。

    并列不是巧合:小权重轴(如 50‰)乘上小差距(如 16‰ 存活)后不足 1‰,
    整数地板除把差距吃成 0——并列规模是**代理指标分辨率**的直接读数。
    """
    best_vec, best_score, ties = None, None, 0
    for vec in sorted(by_vec):
        sc = sum(weights[a] * vec[i] for i, a in enumerate(AXES)) // 1000
        if best_score is None or sc > best_score:
            best_vec, best_score, ties = vec, sc, 1
        elif sc == best_score:
            ties += 1
    return best_vec, best_score, ties


def pareto_size(vecs):
    """前沿规模:不被任何其它向量在各轴上都 ≥ 且至少一轴 > 的向量个数。"""
    return sum(1 for v in vecs if not any(all(b[i] >= v[i] for i in range(6))
               and any(b[i] > v[i] for i in range(6)) for b in vecs))


def classify(by_vec, cfg):
    rows = []
    for t in cfg["types"]:
        vec, sc, ties = pick(by_vec, t["weights"])
        rows.append({"id": t["id"], "name": t["name"], "combo": by_vec[vec],
                     "vec": list(vec), "score": sc, "tieCount": ties})
    return rows


def grid(rows, path):
    """纯 xml.etree SVG:每类型一行,六条轴各一根比例条。"""
    root = ashlib.svg_begin(760, 60 + 34 * len(rows))
    ashlib.svg_text(root, 16, 28, "四类型在六个结果轴上的最优策略画像(千分比,轴满格 1300)", 15)
    x0, bw = 210, 78
    for j, a in enumerate(AXES):
        ashlib.svg_text(root, x0 + j * bw + bw // 2, 48, a, 11, "#94a3b8", "middle")
    for i, r in enumerate(rows):
        y = 62 + i * 34
        ashlib.svg_text(root, 16, y + 16, f"{r['name']} {r['combo']} 得分 {r['score']}", 11)
        for j, val in enumerate(r["vec"]):
            ashlib.svg_bar(root, x0 + j * bw, y, 62, 18, val, BAR_DEN,
                           "#38bdf8" if r["id"] != "explorer" else "#22c55e", str(val))
    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)
    refs, slice_over = cfg["refs"], cfg["slice"]
    pools = [cfg["seeds"]] + cfg["altPools"]
    by_vec = enumerate_space(spec, slice_over, pools[0], cfg, refs)
    rows = classify(by_vec, cfg)
    distinct = 1000 * len({r["combo"] for r in rows}) // len(rows)
    dist = [{"pair": f"{a['name']}–{b['name']}",
             "hamming": sum(1 for x, y in zip(a["combo"], b["combo"]) if x != y),
             "l1": sum(abs(x - y) for x, y in zip(a["vec"], b["vec"]))}
            for a, b in itertools.combinations(rows, 2)]
    robust = []
    for k, pool in enumerate(pools):
        bv = by_vec if k == 0 else enumerate_space(spec, slice_over, pool, cfg, refs)
        rs = classify(bv, cfg)
        robust.append({"pool": k, "seeds": len(pool),
                       "distinctPermille": 1000 * len({r["combo"] for r in rs}) // len(rs),
                       "combos": {r["name"]: r["combo"] for r in rs},
                       "scores": {r["name"]: r["score"] for r in rs}})
    csv = [[r["name"], r["combo"], r["score"], r["tieCount"]] + list(r["vec"]) for r in rows]
    n = ashlib.write_csv(os.path.join(OUT, "play08_taxonomy.csv"),
                         ["type", "combo", "score", "tieCount"] + list(AXES), csv)
    size = grid(rows, os.path.join(OUT, "play08_taxonomy.svg"))
    report = {"id": "gd_play_08_taxonomy", "slice": slice_over, "seeds": len(pools[0]),
              "refs": refs, "patterns": len(cfg["patterns"]), "horizon": cfg["horizon"],
              "strategies": len(cfg["patterns"]) ** cfg["horizon"], "distinctVectors": len(by_vec),
              "paretoFront": pareto_size(list(by_vec)), "types": rows,
              "distinctPermille": distinct, "pairwise": dist, "robustness": robust,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play08_taxonomy.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"  模式 {len(cfg['patterns'])}^ 长 {cfg['horizon']} = {report['strategies']} 条"
          f"  去重后 {len(by_vec)} 个特征向量  Pareto 前沿 {report['paretoFront']}")
    print(f"{'类型':<8}{'最优模式':<10}{'得分':>6}{'并列':>5}  " + "".join(f"{a:>7}" for a in AXES))
    for r in rows:
        print(f"{r['name']:<8}{r['combo']:<10}{r['score']:>6}{r['tieCount']:>5}  "
              + "".join(f"{v:>7}" for v in r["vec"]))
    print(f"类型区分度={distinct}‰({len({r['combo'] for r in rows})}/{len(rows)} 个类型选到不同代表策略)")
    for d in dist:
        print(f"  距离 {d['pair']:<12} 模式汉明={d['hamming']}  特征 L1={d['l1']}")
    print("种子池稳健性:")
    for rb in robust:
        print(f"  池{rb['pool']}({rb['seeds']} 种子)区分度={rb['distinctPermille']}‰  "
              + " ".join(f"{k}={v}" for k, v in rb["combos"].items()))
        print("      得分 " + " ".join(f"{k}={v}" for k, v in rb["scores"].items()))
    print(f"csv={n} rows  svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/play08_taxonomy.json out/play08_taxonomy.csv out/play08_taxonomy.svg")
    ET.parse(os.path.join(OUT, "play08_taxonomy.svg"))
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_08_taxonomy.py(elapsed 是墙钟采样,每次运行都不同,下同):

切片 enemyHp=55 enemyAttack=16  模式 8^ 长 4 = 4096 条  去重后 150 个特征向量  Pareto 前沿 3
类型      最优模式          得分   并列      win  dealt     hp   play  speed    eff
征服者     AEDC         916    1     1000   1000    366    750    850   1018
守护者     ADFA         527    1     1000   1000    383    666    850   1145
效率者     ADDA         876    2     1000   1000    366    666    850   1145
探索者     AFAC         785    2     1000   1000    350    750    850   1018
类型区分度=1000‰(4/4 个类型选到不同代表策略)
  距离 征服者–守护者      模式汉明=3  特征 L1=228
  距离 征服者–效率者      模式汉明=2  特征 L1=211
  距离 征服者–探索者      模式汉明=2  特征 L1=16
  距离 守护者–效率者      模式汉明=1  特征 L1=17
  距离 守护者–探索者      模式汉明=3  特征 L1=244
  距离 效率者–探索者      模式汉明=3  特征 L1=227
种子池稳健性:
  池0(16 种子)区分度=1000‰  征服者=AEDC 守护者=ADFA 效率者=ADDA 探索者=AFAC
      得分 征服者=916 守护者=527 效率者=876 探索者=785
  池1(16 种子)区分度=500‰  征服者=AFHE 守护者=EAFD 效率者=EAFD 探索者=AFHE
      得分 征服者=920 守护者=550 效率者=878 探索者=787
  池2(16 种子)区分度=500‰  征服者=AAEC 守护者=AAEC 效率者=AAEA 探索者=AAEC
      得分 征服者=920 守护者=547 效率者=877 探索者=787
csv=4 rows  svg=8118 bytes  elapsed=4211 ms
wrote out/play08_taxonomy.json out/play08_taxonomy.csv out/play08_taxonomy.svg

读表:四类选出四条策略,但两条是并列

主种子池(401–416)上四个类型选出四条不同的代表策略:

类型代表模式得分并列它为什么挑这条
征服者AEDC9161win 权重 700 压倒一切:赢面与进度都到顶(1000‰),存活够用即可
守护者ADFA5271把第 4 回合换成双格挡 F,存活从 366‰ 抬到 383‰(全集上限)
效率者ADDA8762speed 850‰ 取到全集上限,且在该前提下 eff 1145‰ 是可达最高档
探索者AFAC7852play 权重 700,出牌量 750‰ 已到全集上限

「并列」一列是关键:效率者与探索者的最优得分各有 2 条并列向量。并列不是巧合,而是代理指标分辨率的直接读数——效率者对 hp 的权重只有 50‰,而两条并列向量的存活差是 17‰,17 × 50 ÷ 1000 = 0.85,整数地板除后归零。所以「效率者选了 ADDA」这句话,严格说只意味着「它在一组并列里按字典序取到了 ADDA」。两两距离里的 征服者–探索者 L1=16 也是同一回事:那 16‰ 全部来自存活,属于并列内的噪声,不是偏好差异。

种子池是同一张表的另一半:

种子池区分度四类型选到的代表模式
池 0(401–416)1000‰AEDC / ADFA / ADDA / AFAC
池 1(501–516)500‰AFHE / EAFD / EAFD / AFHE
池 2(601–616)500‰AAEC / AAEC / AAEA / AAEC

换一组种子,代表模式几乎全换,区分度从 1000‰ 掉到 500‰。但同一张表也给出了稳定的那一半:三个池的最优得分是 916/920/920、527/550/547、876/878/877、785/787/787——得分序(征服者 > 效率者 > 探索者 > 守护者)三池一致,只有具体的策略串在抖。这条区别很实用:「谁更适合这套机制」是稳的,「他会怎么打」是不稳的。前者可以拿来做参数决策,后者不能当结论引用。

自检表

检查项合格标准
类型可算每个类型是六轴上的整数权重向量,和为 1000
主轴不独大主权重 700、余 300 摊到别处,避免类型坍缩到同一 argmax
并列入档报每个类型的并列规模,不把并列内的代表当成偏好
代理指标显式六轴与得分都声明为 proxy,只做相对比较、禁绝对化
稳健性同报三个种子池各跑一遍,区分度与得分序分开报
复现无浮点全整数、种子固定、无 random,同池重复跑逐位相同

这一节的结论是一半一半:四类确实会选出四条策略,但代表策略不稳、并列必须披露。下一节把问题反过来问——既然类型会挑不同策略,那么哪一个机制旋钮在喂饱哪一类玩家。

阅读导航:上一节:7.3 动因与机制的对齐 · 下一节:8.2 类型到机制的映射 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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