13.1 可玩性的操作化定义

把可玩性操作化成四个机制量组成的向量:策略空间熵、支配缺失度、涌现度、相变宽度,再用多指标联合闸门判定。S 层在规格上把四个量算出来,四个变体里退化变体在三个分量上塌下来;B 层把每一维的闸门下限扫一遍,报出每个变体的临界下限与相对余量,看哪一维再抬一点就翻盘。

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 可玩性指标与采样 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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