14.1 机制到体验的映射

「体验目标」不能直接测量,只能落到机制参数上变成可观测的约束。本节给出映射四元组(体验目标 → 机制参数 → 观测列 → 期望方向 sign),并让同一张表在两个工作点各跑一遍:胜局工作点上 12 条映射有 6 条方向成立,换到败局工作点变成 0 条成立、2 条方向反转——映射的有效性以工作点为条件。

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 +2playerHp−8敌人更凶,存活血量应声下降,方向一致
节奏(速通)energyMax +1turn−1能量多了,赢得更快,回合数下降
容错playerHp +10playerHp+10起点血量直接抬升存活线
战斗长度enemyHp +10turn+6敌人更肉,战斗拉长——但 5→11 是跳变,不是线性
输出总量enemyHp +10dealt+8打得更久,累计伤害更高
通关难度enemyHp +10winPermille−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 可观测的体验指标 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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