13.1 可玩性的操作化定义
《小游戏开发实战》卷一 12.2 可玩性指标与测试 读的是占比类指标——胜率、通关率、留存率,分母都是「玩过的局数或人数」,量的是结果。本节换一套口径:可玩性 = 机制量的组合。策略熵、支配缺失度、涌现度、相变宽度这四个量都不看玩家玩了几局,只看机制本身长什么样,再把它们并成一个多指标联合闸门。两套口径互补而非替代:占比类指标要真实玩家,机制量在规格上就能算。四分量全是代理指标(proxy),只用于配置之间的相对比较,禁绝对化——本节不会出现「熵高就证明可玩」。
可玩性向量的四个分量
| 分量 | 定义 | 单位 | 退化时怎样 |
|---|---|---|---|
entropy 策略空间熵 | 动作前缀树上卡牌 id 加 e 的分布熵(复用 1.2) | 千分比 | 只剩一种牌时塌到 650 |
domFree 支配缺失度 | 1000 × 未被任何策略弱支配的策略数 // 策略数(复用 2.1) | 千分比 | 出现一条碾压所有打法的策略时掉到 0 |
emergence 涌现度 | 1000 × 可达回合程序并集 // 规则数 13(复用 4.1) | 千分比 | 规则互相独立时停在低位 |
band 相变宽度 | 胜率从 ≥800‰ 跌到 ≤200‰ 所跨的 enemyHp 整数步数 | 步 | 牌库能一次倒空时塌成 1 步 |
四个量写成一个向量 playability = (entropy, domFree, emergence, band),不做加权求和:加权和会把「四个都还行」和「一个极好三个极差」混成同一个数,而这两种机制要改的地方完全不同。闸门 gate 才是联合判据——四维全部过下限才 PASS。
{ "seeds": [601, "…", 616], "cellsPerConfig": 16, "maxSteps": 40, "rules": 13, "gate": { "entropyMin": 1200,
"domFreeMin": 500, "emergenceMin": 1000, "bandMin": 4 }, "variants": [ { "id": "ashdeck" }, "…", { "id": "tight" } ] }
S 层:把四个量拼成一个向量
熵复用 1.2 的整数定点 log2、支配关系复用 2.1 的 weak_dom、涌现度复用 4.1 的 reachable、策略规则复用 5.2 的 choose——本节只做「拼装 + 联合判定」。全整数、无浮点、无随机。
#!/usr/bin/env python3
# file: sim/gd_play_13_playability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_13_playability.py
"""卷二 13.1 可玩性的操作化定义(S 层):把「可玩性」拆成四个机制量组成的向量。
playability = (entropy, domFree, emergence, band),四分量都从同一份 play_spec.json 直接算出:
熵 被出牌型 + e 的 Shannon 熵 ×1000(复用 1.2 的整数定点 log2 与熵)
支配缺失度 1000 × 未被支配的策略数 // 策略数(复用 2.1 的弱支配关系)
涌现度 1000 × 可达回合程序并集 // 规则数 13(复用 4.1 的 reachable)
相变宽度 胜率从 >=800‰ 跌到 <=200‰ 所跨的 enemyHp 整数步数(须在受限 regime 下测)
闸门 gate 是多指标联合判定:四维都过下限才 PASS。四分量都是**代理指标(proxy)**,只用于相对
比较,禁绝对化。产出 out/play13_playability.{json,csv,svg}。全整数、无浮点、无 random。
"""
from __future__ import annotations
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_play_13_playability.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
import gd_play_01_entropy as en # noqa: E402
import gd_play_02_dominance as d2 # noqa: E402
import gd_play_04_emergence as em # noqa: E402
import gd_play_05_metric as m5 # noqa: E402
DIMS = ("entropy", "domFree", "emergence", "band")
LABEL = {"entropy": "策略空间熵", "domFree": "支配缺失度", "emergence": "涌现度", "band": "相变宽度"}
def action_entropy(spec, depth, cap):
"""动作前缀树的符号分布熵:符号取**卡牌 id**(不是手牌槽位)。退化机制只剩一种牌时
熵立刻塌下来;槽位口径下退化机制仍有 6 个槽位符号,熵会骗人。"""
cost = {c["id"]: c["cost"] for c in spec["cards"]}
frontier, symbols, edges = [[]], {}, 0
for _ in range(depth):
nxt = []
for seq in frontier:
s = ashlib.run_spec(spec, seq)
if s["over"]:
continue
acts = [s["hand"][i] for i, cid in enumerate(s["hand"]) if cost[cid] <= s["energy"]]
for a in acts + ["e"]:
edges += 1
symbols[a] = symbols.get(a, 0) + 1
nxt.append(seq + [a])
if edges >= cap:
break
if edges >= cap or not nxt:
break
frontier = nxt
return en.entropy_permille(list(symbols.values())), edges >= cap
def policy_wins(spec, rule, seeds, over, max_steps):
"""一条策略在每个种子上是否取胜(0/1 列表);over 覆盖参数。策略规则复用 5.2 的 choose。"""
cost, dmg, blk = ({c["id"]: c[k] for c in spec["cards"]} for k in ("cost", "damage", "block"))
def run(sd):
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update(over)
seq = []
for _ in range(max_steps):
sn = ashlib.run_spec(s, seq)
if sn["over"]:
break
seq.append(m5.choose(rule, sn, cost, dmg, blk, s["params"]["enemyAttack"]))
return 1 if ashlib.run_spec(s, seq)["won"] else 0
return [run(sd) for sd in seeds]
def dominance_free(spec, policies, seeds, max_steps):
"""支配缺失度:未被任何策略弱支配的策略占比(‰);弱支配关系复用 2.1 的 weak_dom。"""
rows = [policy_wins(spec, p["rule"], seeds, {}, max_steps) for p in policies]
dom = sorted({j for _, j in d2.relations(rows)[1]})
return 1000 * (len(rows) - len(dom)) // len(rows), [policies[i]["id"] for i in dom]
def band_width(spec, seeds, bd):
"""相变宽度:序参量(胜率)从 >=crossHi 跌到 <=crossLo 所跨的参数整数步数。必须在**受限
regime**(低 turnLimit)下测:若牌库能被一次倒空,胜率退化成「总伤害 vs enemyHp」的硬
比较,相变塌成单点、宽度恒为 1——那不是机制的相变宽度。"""
swept, lo, hi = bd["swept"], bd["from"], bd["to"]
over = {bd["fixedParam"]: bd["fixedValue"], **bd["regime"]}
wr = {v: 1000 * sum(policy_wins(spec, bd["policy"], seeds, {**over, swept: v}, 40)) // len(seeds)
for v in range(lo, hi + 1)}
hi_edge = max((v for v in range(lo, hi + 1) if wr[v] >= bd["crossHiPermille"]), default=None)
lo_edge = min((v for v in range(lo, hi + 1) if wr[v] <= bd["crossLoPermille"]), default=None)
band = 0 if hi_edge is None or lo_edge is None else max(0, lo_edge - hi_edge + 1)
return band, hi_edge, lo_edge
def vector_of(spec, cfg, seeds):
"""一个变体的可玩性向量;熵与种子无关,是规格属性而非样本属性。"""
ent, _ = action_entropy(spec, cfg["entropy"]["enumDepth"], cfg["entropy"]["maxNodes"])
dom, dominant = dominance_free(spec, cfg["policies"], seeds, cfg["maxSteps"])
eg, union = cfg["emergence"], set()
for sd in seeds:
union |= em.reachable({**copy.deepcopy(spec), "seed": sd},
eg["maxSteps"], eg["maxNodes"])[0]
band, hi_edge, lo_edge = band_width(spec, seeds, cfg["band"])
return {"entropy": ent, "domFree": dom, "emergence": 1000 * len(union) // cfg["rules"],
"band": band, "dominant": dominant, "patterns": len(union),
"hiEdge": hi_edge, "loEdge": lo_edge}
def draw(rows, gate, path):
root = ashlib.svg_begin(720, 300)
ashlib.svg_text(root, 20, 26, "可玩性向量:四个机制量(各自相对闸门下限归一化)", 15)
ashlib.svg_text(root, 20, 46, "全部为代理指标:只做相对比较,禁绝对化", 11, "#94a3b8")
for i, r in enumerate(rows):
y = 66 + i * 52
ashlib.svg_text(root, 20, y + 16, f"{r['id']} gate={'PASS' if r['pass'] else 'FAIL'}", 12)
for k, d in enumerate(DIMS):
val, lo, x, den = r[d], gate[d + "Min"], 130 + k * 145, max(gate[d + "Min"] * 2, 1)
ashlib.svg_text(root, x, y + 12, f"{LABEL[d]} {val}", 10, "#cbd5e1")
ashlib.svg_bar(root, x, y + 16, 130, 12, val, max(den, val),
"#22c55e" if val >= lo else "#ef4444", None)
return ashlib.svg_save(root, path)
def main():
t0 = time.perf_counter_ns()
base = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
seeds, gate = cfg["seeds"][: cfg["cellsPerConfig"]], cfg["gate"]
rows = []
for var in cfg["variants"]:
spec = {**copy.deepcopy(base), "params": {**base["params"], **var.get("params", {})}}
if "cards" in var:
spec["cards"] = copy.deepcopy(var["cards"])
vec = vector_of(spec, cfg, seeds)
failed = [d for d in DIMS if vec[d] < gate[d + "Min"]]
rows.append({"id": var["id"], "note": var["note"], **vec, "failed": failed, "pass": not failed})
n = ashlib.write_csv(os.path.join(OUT, "play13_playability.csv"), ["variant", *DIMS, "gate"],
[[r["id"]] + [r[d] for d in DIMS] + ["PASS" if r["pass"] else "FAIL"] for r in rows])
size = draw(rows, gate, os.path.join(OUT, "play13_playability.svg"))
report = {"id": "gd_play_13_playability", "seeds": len(seeds), "rules": cfg["rules"], "gate": gate,
"rows": rows, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play13_playability.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"{'变体':<9}{'策略熵':>7}{'支配缺失度':>11}{'涌现度':>8}{'相变宽度':>9}{'可达模式':>9} 闸门")
for r in rows:
print(f"{r['id']:<9}{r['entropy']:>7}{r['domFree']:>11}{r['emergence']:>8}{r['band']:>9}"
f"{r['patterns']:>9} {'PASS' if r['pass'] else 'FAIL ' + ','.join(r['failed'])}")
print("闸门下限 " + " ".join(f"{d}>={gate[d + 'Min']}" for d in DIMS))
print(f"csv={n} rows svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play13_playability.csv out/play13_playability.svg out/play13_playability.json")
ET.parse(os.path.join(OUT, "play13_playability.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
变体 策略熵 支配缺失度 涌现度 相变宽度 可达模式 闸门
ashdeck 2251 1000 2692 10 35 PASS
mono 650 1000 230 2 3 FAIL entropy,emergence,band
rich 2235 1000 4307 2 56 FAIL band
tight 2321 1000 1538 6 20 PASS
闸门下限 entropy>=1200 domFree>=500 emergence>=1000 band>=4
csv=4 rows svg=5616 bytes elapsed=21946 ms
wrote out/play13_playability.csv out/play13_playability.svg out/play13_playability.json
B 层:把闸门下限扫一遍
闸门下限是人定的,所以判决本身也要被检验。B 层不重算仿真,直接读 S 层产物 out/play13_playability.json:把每一维的下限各自扫一遍,报出每个变体在该维上第一次判 FAIL 的下限(临界下限),再算出各维的相对余量(‰,除以各自下限才可比)。
#!/usr/bin/env python3
# file: sim/gd_play_13_playability_warn.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_13_playability_warn.py
"""卷二 13.1 可玩性的操作化定义(B 层):**永远 exit 0**,只提示不使构建失败。
闸门下限是**人定的**,所以判决本身也要被检验。读 out/play13_playability.json(13.1 S 层
产物)做两件事:① 把**每一维的闸门下限各自扫一遍**,报出每个变体在该维上第一次被判 FAIL 的
下限值——即该变体在该维上的**临界下限**;② 算出每个变体离各自下限还剩多少**余量**(margin),
余量最小的那一维就是「机制再改一点就翻盘」的维度。每条含 severity=soft 与 hardFail=false。
四分量都是**代理指标(proxy)**,只做相对比较,禁绝对化。产出 out/play13_playability_warn.json。
"""
import json
import os
OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "out")
ROOT = os.path.dirname(OUT)
DIMS = ("entropy", "domFree", "emergence", "band")
def main():
with open(os.path.join(OUT, "play13_playability.json"), encoding="utf-8") as f:
rep = json.load(f)
with open(os.path.join(ROOT, "config/gd_play_13_playability.json"), encoding="utf-8") as f:
cfg = json.load(f)
rb, gate, rows = cfg["robustness"], rep["gate"], rep["rows"]
ids = [r["id"] for r in rows]
crit = {}
for d in DIMS:
crit[d] = {r["id"]: next((v for v in rb["sweep"][d] if r[d] < v), None) for r in rows}
# 相对余量(‰):四个分量单位不同(千分比 / 步数),只有除以各自下限才可比。
margin = {r["id"]: {d: 1000 * (r[d] - gate[d + "Min"]) // gate[d + "Min"] for d in DIMS}
for r in rows}
warn = []
for r in rows:
fail = [d for d in DIMS if r[d] < gate[d + "Min"]]
if fail:
warn.append({"code": "DIM_BELOW_GATE", "severity": "soft", "variant": r["id"], "dims": fail,
"message": f"{r['id']} 在 {','.join(fail)} 上低于下限"
f"(临界下限 " + "、".join(f"{d}={crit[d][r['id']]}" for d in fail)
+ "):这不是阈值卡得太紧,是这一维本身没达标"})
continue
thin = [d for d in DIMS if margin[r["id"]][d] < rb["marginWarnPermille"]]
if thin:
warn.append({"code": "THIN_MARGIN", "severity": "soft", "variant": r["id"], "dims": thin,
"margin": {d: margin[r["id"]][d] for d in thin},
"message": f"{r['id']} 过关但相对余量薄:"
+ "、".join(f"{d} 只余 {margin[r['id']][d]}‰" for d in thin)
+ "——把该维下限再抬一点就会翻盘"})
warn.append({"code": "PROXY_ONLY", "severity": "soft",
"message": "四分量全部来自规格仿真,是代理指标:只做配置之间的相对比较,"
"禁写「可玩性提升 N 倍」"})
report = {"id": "gd_play_13_playability_warn", "tier": "B", "hardFail": False,
"note": "B 档软告警:只提示闸门下限的位置与余量,不使任何构建失败",
"gate": gate, "variants": ids, "criticalBound": crit, "margin": margin,
"warnings": warn}
with open(os.path.join(OUT, "play13_playability_warn.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("临界下限(该维下限扫到多少时该变体第一次判 FAIL):")
for d in DIMS:
print(f" {d:<10} 下限 {gate[d + 'Min']:>5} " +
" ".join(f"{c}={crit[d][c]}" for c in ids))
print("相对余量 margin(‰)= (分量值 − 下限) / 下限 × 1000:")
for c in ids:
print(f" {c:<8} " + " ".join(f"{d}={margin[c][d]}" for d in DIMS))
for w in warn:
print(f"SOFT WARN {w['code']}: {w['message']}")
print(f"warnings={len(warn)} hardFail=false → exit 0")
print("wrote out/play13_playability_warn.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
临界下限(该维下限扫到多少时该变体第一次判 FAIL):
entropy 下限 1200 ashdeck=2400 mono=800 rich=2400 tight=2400
domFree 下限 500 ashdeck=None mono=None rich=None tight=None
emergence 下限 1000 ashdeck=3000 mono=1000 rich=5000 tight=2000
band 下限 4 ashdeck=12 mono=3 rich=3 tight=8
相对余量 margin(‰)= (分量值 − 下限) / 下限 × 1000:
ashdeck entropy=875 domFree=1000 emergence=1692 band=1500
mono entropy=-459 domFree=1000 emergence=-770 band=-500
rich entropy=862 domFree=1000 emergence=3307 band=-500
tight entropy=934 domFree=1000 emergence=538 band=500
SOFT WARN DIM_BELOW_GATE: mono 在 entropy,emergence,band 上低于下限(临界下限 entropy=800、emergence=1000、band=3):这不是阈值卡得太紧,是这一维本身没达标
SOFT WARN DIM_BELOW_GATE: rich 在 band 上低于下限(临界下限 band=3):这不是阈值卡得太紧,是这一维本身没达标
SOFT WARN THIN_MARGIN: tight 过关但相对余量薄:emergence 只余 538‰、band 只余 500‰——把该维下限再抬一点就会翻盘
SOFT WARN PROXY_ONLY: 四分量全部来自规格仿真,是代理指标:只做配置之间的相对比较,禁写「可玩性提升 N 倍」
warnings=4 hardFail=false → exit 0
wrote out/play13_playability_warn.json
自检表
| 检查项 | 合格标准 |
|---|---|
| 四分量可分辨 | 退化变体 mono 至少在三个分量上塌下来,否则指标无用 |
| 闸门成文 | 四个下限写进配置,改口径只改配置 |
| 有临界下限 | 每一维扫出「再抬一点就翻盘」的位置,判决不是黑箱 |
| 软告警不硬失败 | 低于下限与余量薄都只打印 SOFT WARN,exit 0 |
| 无浮点 | 熵、缺失度、涌现度是千分比整数,宽度是整数步数 |
| 代理指标声明 | 四分量是 proxy,只做相对比较,禁绝对化 |
向量算出来了,下一个问题是它有多可信:熵与种子无关,相变宽度却对种子数敏感——算这个向量到底要采多少种子。
阅读导航:上一节:12.3 指标的置信区间 · 下一节:13.2 可玩性指标与采样 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。