14.1 机制到体验的映射
《小游戏开发实战》卷一 5.2 动机模型与付费意愿分层 那篇把「玩家为什么掏钱」分成几档人群,是产品口径的动因分析;本节做的是动因到机制的映射接口——把「玩家想要什么」写成机制参数上的可观测约束。前者回答「该卖给谁」,后者回答「想要的东西在规格里对应哪个旋钮、动了它之后哪个观测量该往哪走」。这是 MDA 里 A 层(体验)与 D 层(动态)之间那段接口,也是本卷第一次把体验写进可执行规格。
体验目标不是观测量,而是约束
「紧张」「爽快」「压迫感」「抉择密度」都是主观的。这一节不假装能测量它们——没有任何一个观测量等于体验本身。能做、也必须做的只有一件事:把体验目标翻译成一条关于机制参数的、可观测的、可证伪的约束。
约束的形状固定:把某个机制参数往某个方向调,某个观测列应当往某个方向走。这条约束可以被仿真直接证伪,这正是它比一句设计口号有用的地方。口号说「要更紧张」,谁都同意,谁都无法反驳;约束说「敌人攻击 +2,存活血量应下降」,跑一遍就知道对不对。
这也决定了本节的口径:观测列读出来的数字是代理指标(proxy),不是体验的度量。代理指标只能用来做相对比较(A 配置比 B 配置更接近目标),不能绝对化——正文里不出现「体验提升 N 倍」「好玩 30%」这类说法,因为那需要一个从未被定义过的体验单位。
映射的四元组
一条映射写成四元组,四个位置各有确定含义:
| 记号 | 含义 | 取值来源 |
|---|---|---|
goal | 体验目标(主观意图的代理名) | 设计语言,如「压迫感」 |
param | 机制参数(规格里可调的整数旋钮) | play_spec.json 的 params |
obs | 观测列(快照里能读到的整数) | run_spec 返回的字段 |
sign | 期望方向:目标变强时观测列该往哪走 | +1 或 −1 |
判定只有三步,全整数:把 param 扰动一个整数步长 step,跨种子聚合观测列(整数均值),比较 Δ 的符号与 sign。Δ ≠ 0 且同号 → MATCH;Δ == 0 → FLAT(旋钮没推动观测量);符号相反 → MISS(方向反了)。三条判定与两个工作点都写进 config,改口径只改配置。
{
"id": "gd_mech_14_mapping",
"note": "卷一 14.1 机制到体验的映射:体验目标 → 机制参数 → 观测列 → 期望方向 sign(全整数,禁浮点)",
"seeds": [201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216],
"probeTurns": 20,
"probePattern": ["p0", "p1", "p2", "p3", "e"],
"operatingPoints": [
{ "param": "enemyHp", "value": 70, "label": "胜局" },
{ "param": "enemyHp", "value": 90, "label": "败局" }
],
"mappings": [
{ "goal": "压迫感", "param": "enemyAttack", "step": 2, "obs": "playerHp", "sign": -1 },
{ "goal": "压迫感", "param": "enemyAttack", "step": 2, "obs": "turn", "sign": 1 },
{ "goal": "节奏(速通)", "param": "energyMax", "step": 1, "obs": "turn", "sign": -1 },
{ "goal": "抉择密度", "param": "energyMax", "step": 1, "obs": "played", "sign": 1 },
{ "goal": "容错", "param": "playerHp", "step": 10, "obs": "playerHp", "sign": 1 },
{ "goal": "容错", "param": "playerHp", "step": 10, "obs": "turn", "sign": -1 },
{ "goal": "战斗长度", "param": "enemyHp", "step": 10, "obs": "turn", "sign": 1 },
{ "goal": "输出总量", "param": "enemyHp", "step": 10, "obs": "dealt", "sign": 1 },
{ "goal": "通关难度", "param": "enemyHp", "step": 10, "obs": "winPermille", "sign": -1 },
{ "goal": "手牌厚度", "param": "handSize", "step": 1, "obs": "played", "sign": 1 },
{ "goal": "惩罚强度", "param": "punishHp", "step": 4, "obs": "playerHp", "sign": -1 },
{ "goal": "奖励诱惑", "param": "rewardGold", "step": 6, "obs": "playerHp", "sign": 1 }
]
}
判定实验(S 层)
脚本把上面 12 条映射在两个工作点各跑一遍,工作点沿用 12.1 / 12.2 的 enemyHp(胜局 70、败局 90),种子集合与探针策略也照搬,故本节的 Δ 与 12.2 的 solo 同源、可直接对照。
#!/usr/bin/env python3
# file: sim/gd_mech_14_mapping.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_14_mapping.py
"""卷一 14.1 机制到体验的映射(S 层):把「体验目标」写成机制参数上的可观测约束。
映射四元组(体验目标 → 机制参数 → 观测列 → 期望方向 sign):
把机制参数 param 扰动一个整数步长 step,聚合观测列 obs(跨种子整数均值),
Δ = obs(base+step) − obs(base);sign 是「体验目标变强时观测列该往哪走」。
判定:Δ≠0 且 sign(Δ)==sign → MATCH;Δ==0 → FLAT;符号相反 → MISS。
观测列 turn / playerHp / dealt / winPermille 都是**代理指标**:
它们不是体验本身的度量,只用于相对比较,禁止写成「体验提升 N 倍」。
同一张映射表在两个工作点(enemyHp=70 胜局 / 90 败局)各跑一遍——
映射的有效性以工作点为条件,这一点由两次结果之差直接给出。
产出 out/mech14_mapping.csv + out/mech14_mapping.svg + out/mech14_mapping.json。
"""
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_mech_14_mapping.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
COLS = ("turn", "playerHp", "enemyHp", "dealt", "played", "energy", "block")
FILL = {"MATCH": "#22c55e", "FLAT": "#475569", "MISS": "#ef4444"}
def observe(spec, seeds, strategy, over):
"""跨种子聚合:整数均值(地板除),winPermille 是胜场千分比。"""
acc = {k: 0 for k in COLS}
won = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update(over)
snap = ashlib.run_spec(s, strategy)
for k in COLS:
acc[k] += snap[k]
won += 1 if snap["won"] else 0
n = len(seeds)
out = {k: acc[k] // n for k in COLS}
out["winPermille"] = 1000 * won // n
return out
def classify(delta, sign):
if delta == 0:
return "FLAT"
return "MATCH" if (delta > 0) == (sign > 0) else "MISS"
def matrix(rows, ops, path):
"""判定矩阵:行 = 映射,列 = 工作点,格色 = MATCH / FLAT / MISS。"""
cw, ch, x0, y0 = 96, 26, 210, 74
W, H = x0 + cw * len(ops) + 40, y0 + ch * len(rows) + 56
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 16, 28, "机制到体验的映射:同一张表,两个工作点", 15)
ashlib.svg_text(root, 16, 48, "绿 = 方向一致(MATCH) 灰 = 无变化(FLAT) 红 = 方向相反(MISS)", 11, "#94a3b8")
for j, op in enumerate(ops):
ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 - 12,
f"{op['param']}={op['value']}({op['label']})", 11, "#cbd5e1", "middle")
for i, r in enumerate(rows):
y = y0 + i * ch
ashlib.svg_text(root, 16, y + 17, f"{r['goal']} ← {r['param']} → {r['obs']} ({r['sign']:+d})", 11)
for j, op in enumerate(ops):
cell = r["byOp"][op["label"]]
ET.SubElement(root, "rect", {
"x": str(x0 + j * cw + 4), "y": str(y + 3), "width": str(cw - 8),
"height": str(ch - 6), "fill": FILL[cell["verdict"]], "rx": "4",
})
ashlib.svg_text(root, x0 + j * cw + cw // 2, y + 17, f"{cell['delta']:+d}", 11, "#0f172a", "middle")
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)
seeds, maps = cfg["seeds"], cfg["mappings"]
ops = cfg["operatingPoints"]
strategy = cfg["probePattern"] * cfg["probeTurns"]
rows, csv = [], []
for m in maps:
by_op = {}
for op in ops:
base_over = {op["param"]: op["value"]}
cur = op["value"] if m["param"] == op["param"] else spec["params"][m["param"]]
base = observe(spec, seeds, strategy, base_over)
after = observe(spec, seeds, strategy, {**base_over, m["param"]: cur + m["step"]})
delta = after[m["obs"]] - base[m["obs"]]
v = classify(delta, m["sign"])
by_op[op["label"]] = {"base": base[m["obs"]], "after": after[m["obs"]],
"delta": delta, "verdict": v}
csv.append([m["goal"], m["param"], m["step"], m["obs"], m["sign"], op["label"],
base[m["obs"]], after[m["obs"]], delta, v])
rows.append({**m, "byOp": by_op})
counts = {op["label"]: {k: sum(1 for r in rows if r["byOp"][op["label"]]["verdict"] == k)
for k in ("MATCH", "FLAT", "MISS")} for op in ops}
n = ashlib.write_csv(os.path.join(OUT, "mech14_mapping.csv"),
["goal", "param", "step", "obs", "sign", "op", "base", "after", "delta", "verdict"], csv)
size = matrix(rows, ops, os.path.join(OUT, "mech14_mapping.svg"))
report = {"id": "gd_mech_14_mapping", "seeds": len(seeds), "mappings": len(maps),
"operatingPoints": ops, "counts": counts, "rows": rows,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "mech14_mapping.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
for op in ops:
print(f"== 工作点 {op['param']}={op['value']}({op['label']})")
print(f"{'体验目标':<14}{'机制参数':<14}{'观测列':<12}{'sign':>5}{'base':>7}{'after':>7}{'Δ':>7} 判定")
for r in rows:
c = r["byOp"][op["label"]]
print(f"{r['goal']:<14}{r['param']:<14}{r['obs']:<12}{r['sign']:>+5}"
f"{c['base']:>7}{c['after']:>7}{c['delta']:>+7} {c['verdict']}")
print(f" counts={counts[op['label']]}")
print(f"mappings={len(maps)} csv={n} rows svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/mech14_mapping.csv out/mech14_mapping.svg out/mech14_mapping.json")
ET.parse(os.path.join(OUT, "mech14_mapping.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_14_mapping.py(elapsed 是墙钟采样,每次运行都不同,下同):
== 工作点 enemyHp=70(胜局)
体验目标 机制参数 观测列 sign base after Δ 判定
压迫感 enemyAttack playerHp -1 42 34 -8 MATCH
压迫感 enemyAttack turn +1 5 5 +0 FLAT
节奏(速通) energyMax turn -1 5 4 -1 MATCH
抉择密度 energyMax played +1 11 11 +0 FLAT
容错 playerHp playerHp +1 42 52 +10 MATCH
容错 playerHp turn -1 5 5 +0 FLAT
战斗长度 enemyHp turn +1 5 11 +6 MATCH
输出总量 enemyHp dealt +1 70 78 +8 MATCH
通关难度 enemyHp winPermille -1 1000 0 -1000 MATCH
手牌厚度 handSize played +1 11 11 +0 FLAT
惩罚强度 punishHp playerHp -1 42 42 +0 FLAT
奖励诱惑 rewardGold playerHp +1 42 42 +0 FLAT
counts={'MATCH': 6, 'FLAT': 6, 'MISS': 0}
== 工作点 enemyHp=90(败局)
体验目标 机制参数 观测列 sign base after Δ 判定
压迫感 enemyAttack playerHp -1 0 0 +0 FLAT
压迫感 enemyAttack turn +1 11 9 -2 MISS
节奏(速通) energyMax turn -1 11 11 +0 FLAT
抉择密度 energyMax played +1 12 12 +0 FLAT
容错 playerHp playerHp +1 0 0 +0 FLAT
容错 playerHp turn -1 11 13 +2 MISS
战斗长度 enemyHp turn +1 11 11 +0 FLAT
输出总量 enemyHp dealt +1 78 78 +0 FLAT
通关难度 enemyHp winPermille -1 0 0 +0 FLAT
手牌厚度 handSize played +1 12 12 +0 FLAT
惩罚强度 punishHp playerHp -1 0 0 +0 FLAT
奖励诱惑 rewardGold playerHp +1 0 0 +0 FLAT
counts={'MATCH': 0, 'FLAT': 10, 'MISS': 2}
mappings=12 csv=24 rows svg=7377 bytes elapsed=33 ms
wrote out/mech14_mapping.csv out/mech14_mapping.svg out/mech14_mapping.json
counts 两行是本节的核心读数:同一张表、同一批种子、同一个探针,只换了工作点,MATCH 从 6 条掉到 0 条,还多出 2 条 MISS。
读表:胜局 6 条成立,败局 0 条
胜局工作点(enemyHp=70)上,12 条映射里 6 条方向成立:
| 体验目标 | 机制参数 | 观测列 | Δ | 判读 |
|---|---|---|---|---|
| 压迫感 | enemyAttack +2 | playerHp | −8 | 敌人更凶,存活血量应声下降,方向一致 |
| 节奏(速通) | energyMax +1 | turn | −1 | 能量多了,赢得更快,回合数下降 |
| 容错 | playerHp +10 | playerHp | +10 | 起点血量直接抬升存活线 |
| 战斗长度 | enemyHp +10 | turn | +6 | 敌人更肉,战斗拉长——但 5→11 是跳变,不是线性 |
| 输出总量 | enemyHp +10 | dealt | +8 | 打得更久,累计伤害更高 |
| 通关难度 | enemyHp +10 | winPermille | −1000 | 越过临界点后胜率从 1000‰ 直接归零 |
其余 6 条是 FLAT,但成因分两类,必须分开读:
| 类别 | 条目 | 机制原因 |
|---|---|---|
| 饱和 | energyMax → played、handSize → played、enemyAttack → turn、playerHp → turn | 观测量已被别处封顶:探针每回合只能打 3 张、胜负在工作点上已定 |
| 死旋钮 | punishHp → playerHp、rewardGold → playerHp | 这两个参数根本没有被内核读取——映射指向了一个不存在的接口 |
死旋钮这一类最值得警惕:punishHp 与 rewardGold 在 play_spec.json 里存在,字段合法、schema 通过,但 run_spec 从不消费它们。把「惩罚强度」映射到 punishHp 是一条写得出、跑不动的映射——它不会报错,只会永远 Δ=0。
为什么败局全平:turn 的歧义
败局工作点(enemyHp=90)上 6 条 MATCH 全部塌成 FLAT,其中两条还翻了方向。翻方向的恰恰都是观测列 turn:
| 映射 | 胜局 Δ | 败局 Δ | 为什么会翻 |
|---|---|---|---|
enemyAttack +2 → turn(期望 +1) | 0 | −2 | 敌人更凶 → 玩家更早阵亡 → 回合数下降 |
playerHp +10 → turn(期望 −1) | 0 | +2 | 起点血更多 → 玩家多撑两回合 → 回合数上升 |
两条翻车的根源是同一个:turn 在败局工作点上测的不是「多久赢」,而是「多久死」。同一个观测列在同一张表里承载了两个相反的语义,方向自然会打架。这不是脚本的 bug,而是代理指标的效力边界:turn 只有在「玩家必胜」的工作点上才配当「战斗长度」的代理;一旦胜负未定,它测的是时间到死亡。
这条教训可以直接落到工作流上:
- 映射表必须标注工作点。同一张表换工作点会换结论,所以「映射成立」这句话只有在说清
enemyHp=?之后才有意义。 - 观测列要标语义。
turn这类歧义列要么拆成两个(turnWhenWin/turnWhenLose),要么只在胜负固定的工作点上引用。 - Δ=0 不等于「没关系」。饱和与死旋钮都输出 FLAT,但前者是「现在推不动、换个工作点可能推得动」,后者是「永远推不动」。判读时要分开。
下一节把「观测列」这一侧做扎实:不再只借用快照里现成的字段,而是为体验专门构造四个可算的代理指标。
| 检查项 | 合格标准 |
|---|---|
| 映射有定义 | 四元组写成表格,goal / param / obs / sign 各占一列 |
| 方向先声明 | 每条映射先写 sign,再跑仿真,不许事后补方向 |
| 判定可复现 | 种子固定、全整数,同一工作点重复跑逐位相同 |
| 工作点入档 | 工作点写进 config,结论与工作点绑定 |
| 代理指标显式 | 正文声明观测列是代理指标,只做相对比较、禁绝对化 |
阅读导航:上一节:13.3 机制的退场 · 下一节:14.2 可观测的体验指标 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。