8.1 玩家类型学
《小游戏开发实战》卷一 5.1 用户画像与场景 那篇做的是产品口径的画像:这款游戏写给谁、在什么场景下玩、用什么题材与节奏打动人。画像是一份叙事,靠访谈、问卷与行为日志建立,回答的是「该服务谁」。本节做的是机制口径的同一件事,并且只做能被仿真回答的那一半:把「玩家类型」写成结果轴上的目标函数,然后问一个可跑的问题——同一套机制下,不同类型会不会选出不同的打法。前者不可证伪(没人能说一份画像「跑错了」),后者可以:跑一遍,四条目标函数各挑一条策略,是不是同一条,一看便知。
类型不是标签,是目标函数
类型学最有名的一版是 Bartle 的四分类:成就者、探索者、社交家、杀手。它的价值是给了行业一套共同词汇,代价是四个词都没法直接喂给仿真——「我是探索者」这句话既不能被证伪,也不能被优化。要让它进机制,必须补一步翻译:类型 = 一组权重。
翻译的落点是结果轴:一局《灰烬牌库》跑完,run_spec 交回一批整数(伤害、剩余血量、回合数、出牌数、胜负)。把它们归一成六个千分比,就得到本节用的六条轴:
| 轴 | 定义(全整数) | 范围 | 读作 |
|---|---|---|---|
win | 胜场数 ÷ 种子数 | 0–1000 | 赢面 |
dealt | 平均伤害 ÷ enemyHp | 0–1000 | 进度 |
hp | 平均剩余血量 ÷ playerHp | 0–1000 | 存活 |
play | 平均出牌数 ÷ playedRef | 0–1300 | 操作量 |
speed | (turnLimit − 平均回合) ÷ turnLimit | 0–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 的四个词照搬,而是从这套机制真有的六条轴里挑四条主轴建出来的(本卷不覆盖社交,故没有「社交家」):
| 类型 | 主轴 | win | dealt | hp | play | speed | eff |
|---|---|---|---|---|---|---|---|
| 征服者 | win | 700 | 100 | 100 | 50 | 50 | 0 |
| 守护者 | hp | 100 | 50 | 700 | 100 | 50 | 0 |
| 效率者 | speed | 100 | 100 | 50 | 50 | 600 | 100 |
| 探索者 | play | 100 | 100 | 50 | 700 | 50 | 0 |
四条向量都给主权重 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)上四个类型选出四条不同的代表策略:
| 类型 | 代表模式 | 得分 | 并列 | 它为什么挑这条 |
|---|---|---|---|---|
| 征服者 | AEDC | 916 | 1 | win 权重 700 压倒一切:赢面与进度都到顶(1000‰),存活够用即可 |
| 守护者 | ADFA | 527 | 1 | 把第 4 回合换成双格挡 F,存活从 366‰ 抬到 383‰(全集上限) |
| 效率者 | ADDA | 876 | 2 | speed 850‰ 取到全集上限,且在该前提下 eff 1145‰ 是可达最高档 |
| 探索者 | AFAC | 785 | 2 | play 权重 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 类型到机制的映射 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。