7.1 自我决定理论:自主、胜任、关联

自我决定理论说人有自主、胜任、关联三种基本心理需要,但它们都不可直接测量。本节把每种需要落成代理指标(proxy):自主用决策点上的合法动作数、胜任用胜率与残余血量、关联因内核没有社交接口而显式记为 N/A,并在松中紧三个遭遇点上跑出 9 组读数。

7.1 自我决定理论:自主、胜任、关联

《小游戏开发实战》卷一 5.2 动机模型与付费意愿分层 那篇按「谁愿意花钱、花多少」把玩家分成几档,是产品口径的动因分析;本节做的是自我决定动因模型——自主(autonomy)、胜任(competence)、关联(relatedness)三种心理需要的可计算化,以及它们与机制旋钮的对齐,全程不碰商业化。上一卷 14.1 机制到体验的映射 已经立起「体验目标 → 机制参数 → 观测列 → 期望方向」的四元组;本节把其中「体验目标」这一侧换成一个有心理学出处、且能落成整数函数的模型。

三需要不是三种体验,是三种可计算的量

自我决定理论(SDT)主张人有三种基本心理需要:自主(我的选择是我做的)、胜任(我在变强)、关联(我和别人有联系)。它们都是主观感受,没有任何一个观测量等于它们本身。本节的口径与全卷一致:只做代理指标(proxy),只用于相对比较,禁绝对化——不写「动因提升 N 倍」,因为动因没有单位。

代理指标的选择要满足一条硬要求:能从规格与仿真里算出来,而不是问卷里读出来。三条落法:

需要代理读数从哪来方向
自主branchAvg 决策点上的平均合法动作数逐回合观测器越高越自主
自主forcedPermille 被迫选择占比(只剩「结束回合」可做)逐回合观测器越低越自主
胜任winPermille 胜率仿真终态越高越胜任
胜任hpMarginPermille 结束时残余血量占比仿真终态越高越胜任
关联(无)本内核没有社交 / 同伴接口—

最后一行不是省略,而是结论:play_spec.json 里没有任何字段描述「他者」,所以关联在这份规格上不可代理。7.3 会把它变成「无旋钮分量」的正式判定,这里先如实记下。

{
  "id": "gd_play_07_sdt",
  "note": "卷二 7.1 自我决定理论:自主 / 胜任 / 关联三种心理需要的代理指标定义(全整数千分比,禁浮点)",
  "seeds": [501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516],
  "strategies": [
    { "id": "seq0", "label": "规格默认序列", "pattern": ["p0", "p1", "p2", "p3", "e"], "turns": 10 },
    { "id": "cheap", "label": "最小费用优先", "pattern": ["p4", "p3", "p2", "e"], "turns": 12 },
    { "id": "heavy", "label": "大牌优先", "pattern": ["p1", "p0", "e"], "turns": 16 }
  ],
  "encounters": [
    { "id": "e0", "label": "松", "enemyHp": 50, "turnLimit": 20 },
    { "id": "e1", "label": "中", "enemyHp": 70, "turnLimit": 20 },
    { "id": "e2", "label": "紧", "enemyHp": 100, "turnLimit": 20 }
  ],
  "needs": [
    { "key": "autonomy", "name": "自主", "proxy": "branchAvg", "unit": "个/决策点", "dir": "up" },
    { "key": "autonomy", "name": "自主", "proxy": "forcedPermille", "unit": "千分比", "dir": "down" },
    { "key": "competence", "name": "胜任", "proxy": "winPermille", "unit": "千分比", "dir": "up" },
    { "key": "competence", "name": "胜任", "proxy": "hpMarginPermille", "unit": "千分比", "dir": "up" },
    { "key": "relatedness", "name": "关联", "proxy": null, "unit": "-", "dir": "none" }
  ]
}

为什么需要一个逐回合观测器

ashlib.run_spec 只返回终态快照(12 个键),没有「每个决策点有几个合法动作」这种过程量。自主的代理恰恰是过程量:它问的不是「这局赢没赢」,而是「每次轮到我时,我有几个选项」。所以本节自带一个逐回合观测器 trace():它与 run_spec 同规则、同洗牌、同整数语义,额外在每个决策点记下 legal = 可支付手牌数 + 1(「结束回合」永远合法)。

同源不是自说自话,而是可断言的:脚本开头拿默认策略跑两遍,比较两侧的 digest() 字符串。不一致就 assert 失败——这正是 P 层「确定性回放」的思路用在 S 层脚本自检上。

S 层:三需要 × 三个遭遇点

脚本把三种策略(默认序列 / 最小费用优先 / 大牌优先)在三个遭遇点(松 enemyHp=50 / 中 70 / 紧 100)各跑一遍,每格 16 个种子,聚合出三需要的代理读数。

#!/usr/bin/env python3
# file: sim/gd_play_07_sdt.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_07_sdt.py
"""卷二 7.1 自我决定理论(S 层):把自主 / 胜任 / 关联三种心理需要落成逐回合代理指标。

自我决定理论(SDT)说人有三种基本心理需要——自主(autonomy)、胜任(competence)、
关联(relatedness)。本节不假装能测量它们,只把每种需要落成一个**代理指标(proxy)**:
    自主   ← 决策点上的**合法动作数**(含「结束回合」)与其反面「被迫选择占比」
    胜任   ← 胜率 + 结束时剩余血量占比
    关联   ← 本内核**没有社交 / 同伴接口**,故返回 None,显式声明不可代理

为了数出「每个决策点有几个合法动作」,本脚本带一个**逐回合观测器** `trace()`:它与
ashlib.run_spec 同规则、同洗牌,且在默认策略上两者 digest 逐字相同(脚本开头的自检断言)。
三需要 × 三个遭遇点(松 / 中 / 紧)各聚合一遍,看哪些代理随敌人强度动、哪些不动。
全整数、无浮点、无 random。产出 out/play07_sdt.csv + .json + .svg。
"""
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_07_sdt.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def trace(spec, strategy):
    """逐回合观测器:与 ashlib.run_spec 同规则,额外记录每个决策点的合法动作数。"""
    p = spec["params"]
    deck = []
    for c in spec["cards"]:
        deck.extend([c] * c["count"])
    deck = ashlib.shuffle_deck(deck, spec["seed"])
    hand, draw, discard = deck[: p["handSize"]], deck[p["handSize"]:], []
    turn, energy = 1, p["energyMax"]
    php, ehp, block = p["playerHp"], p["enemyHp"], 0
    dealt = played = 0
    over = won = False
    points = []
    for cmd in strategy:
        if over:
            break
        if turn > p["turnLimit"]:
            over = True
            won = False
            break
        legal = [i for i in range(len(hand)) if hand[i]["cost"] <= energy]
        points.append({"turn": turn, "legal": len(legal) + 1, "energy": energy,
                       "hand": len(hand), "forced": len(legal) == 0})
        if cmd.startswith("p"):
            idx = int(cmd[1:])
            if idx < 0 or idx >= len(hand):
                continue
            c = hand[idx]
            if c["cost"] > energy:
                continue
            hand.pop(idx)
            discard.append(c)
            energy -= c["cost"]
            block += c["block"]
            d = min(c["damage"], ehp)
            ehp -= d
            dealt += d
            played += 1
            if ehp <= 0:
                over = True
                won = True
        elif cmd == "e":
            absorbed = min(block, p["enemyAttack"])
            block -= absorbed
            php = max(0, php - (p["enemyAttack"] - absorbed))
            if php <= 0:
                over = True
                won = False
                break
            turn += 1
            block = 0
            energy = p["energyMax"]
            while len(hand) < p["handSize"] and draw:
                hand.append(draw.pop(0))
    snap = {
        "turn": turn, "energy": energy, "playerHp": php, "enemyHp": ehp, "block": block,
        "dealt": dealt, "played": played, "over": over, "won": won,
        "hand": [c["id"] for c in hand], "cardCount": len(hand) + len(draw) + len(discard),
        "damageConserved": dealt == p["enemyHp"] - ehp,
    }
    return snap, points


def proxies(spec, strategy):
    """一个策略在一个种子上的三需要代理读数。"""
    snap, points = trace(spec, strategy)
    n = len(points)
    branch = sum(pt["legal"] for pt in points) // n
    forced = 1000 * sum(1 for pt in points if pt["forced"]) // n
    return {
        "branchAvg": branch,
        "forcedPermille": forced,
        "win": 1 if snap["won"] else 0,
        "dmgPerPlay": snap["dealt"] // snap["played"] if snap["played"] else 0,
        "hpMarginPermille": 1000 * snap["playerHp"] // spec["params"]["playerHp"],
        "relatedness": None,
        "points": n,
    }


def draw(rows, path):
    """每个种子的平均合法动作数(自主代理):0..handSize+1 的竖条。"""
    W, H, x0, y0, y1 = 620, 250, 70, 36, 196
    cap = max(r["branchAvg"] for r in rows) or 1
    bw = (W - 100) // len(rows)
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, x0, 24, "自主代理:每个种子的平均合法动作数(含结束回合)", 14)
    for i, r in enumerate(rows):
        x = x0 + i * bw
        bh = r["branchAvg"] * (y1 - y0) // cap
        ET.SubElement(root, "rect", {
            "x": str(x + 2), "y": str(y1 - bh), "width": str(bw - 4), "height": str(bh),
            "fill": "#38bdf8", "data-seed": str(r["seed"]),
        })
        ashlib.svg_text(root, x + bw // 2, y1 + 16, str(r["seed"] % 100), 9, "#94a3b8", "middle")
    ashlib.svg_text(root, x0, y1 + 34, f"纵轴上限 = {cap};色条越高 = 可选动作越多", 11, "#cbd5e1")
    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)

    ref = ashlib.digest(ashlib.run_spec(spec))
    mine = ashlib.digest(trace(spec, spec["strategy"])[0])
    assert ref == mine, "逐回合观测器与 ashlib.run_spec 不同源"
    print(f"观测器自检:digest 与 ashlib.run_spec 逐字相同 → {mine[:44]}…")

    seeds = cfg["seeds"]
    rows, agg = [], {}
    for enc in cfg["encounters"]:
        for st in cfg["strategies"]:
            strategy = st["pattern"] * st["turns"]
            per = []
            for sd in seeds:
                s = copy.deepcopy(spec)
                s["seed"] = sd
                s["params"]["enemyHp"] = enc["enemyHp"]
                s["params"]["turnLimit"] = enc["turnLimit"]
                per.append({"seed": sd, **proxies(s, strategy)})
            n = len(per)
            key = f"{enc['id']}/{st['id']}"
            agg[key] = {
                "encounter": enc["id"], "label": f"{enc['label']}·{st['label']}",
                "branchAvg": sum(r["branchAvg"] for r in per) // n,
                "forcedPermille": sum(r["forcedPermille"] for r in per) // n,
                "winPermille": 1000 * sum(r["win"] for r in per) // n,
                "dmgPerPlay": sum(r["dmgPerPlay"] for r in per) // n,
                "hpMarginPermille": sum(r["hpMarginPermille"] for r in per) // n,
                "relatedness": None,
            }
            rows.extend([{**r, "encounter": enc["id"], "strategy": st["id"]} for r in per])

    csv_rows = [[r["encounter"], r["strategy"], r["seed"], r["branchAvg"], r["forcedPermille"],
                 r["win"], r["dmgPerPlay"], r["hpMarginPermille"], r["points"]] for r in rows]
    n = ashlib.write_csv(os.path.join(OUT, "play07_sdt.csv"),
                         ["encounter", "strategy", "seed", "branchAvg", "forcedPermille", "win",
                          "dmgPerPlay", "hpMarginPermille", "points"], csv_rows)
    size = draw([r for r in rows if r["strategy"] == "seq0" and r["encounter"] == "e0"],
                os.path.join(OUT, "play07_sdt.svg"))
    report = {"id": "gd_play_07_sdt", "seeds": len(seeds), "aggregate": agg,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    with open(os.path.join(OUT, "play07_sdt.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"\n{'遭遇·策略':<16}{'自主':>6}{'被迫‰':>8}{'胜任‰':>8}{'伤害/张':>9}{'余量‰':>8}{'关联':>7}")
    for enc in cfg["encounters"]:
        for st in cfg["strategies"]:
            a = agg[f"{enc['id']}/{st['id']}"]
            print(f"{a['label']:<16}{a['branchAvg']:>6}{a['forcedPermille']:>8}{a['winPermille']:>8}"
                  f"{a['dmgPerPlay']:>9}{a['hpMarginPermille']:>8}{'N/A':>7}")
    print("\nneeds = autonomy(branchAvg/forcedPermille) competence(winPermille/hpMarginPermille) "
          "relatedness(None)")
    print(f"seeds={len(seeds)} cells={len(agg)} csv={n} rows svg={size} bytes  "
          f"elapsed={report['elapsedMs']} ms")
    print("wrote out/play07_sdt.csv out/play07_sdt.svg out/play07_sdt.json")
    ET.parse(os.path.join(OUT, "play07_sdt.svg"))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_07_sdt.py(elapsed 是墙钟采样,每次运行都不同):

观测器自检:digest 与 ashlib.run_spec 逐字相同 → {"block":0,"cardCount":12,"damageConserved":…

遭遇·策略               自主     被迫‰     胜任‰     伤害/张     余量‰     关联
松·规格默认序列             3     250    1000        5     838    N/A
松·最小费用优先             3      92    1000        5     817    N/A
松·大牌优先               4      53    1000        5     760    N/A
中·规格默认序列             2     219    1000        5     705    N/A
中·最小费用优先             3      38     187        5     134    N/A
中·大牌优先               3      74    1000        6     642    N/A
紧·规格默认序列             1     592       0        6     157    N/A
紧·最小费用优先             3      38       0        5       0    N/A
紧·大牌优先               2     531       0        6       0    N/A

needs = autonomy(branchAvg/forcedPermille) competence(winPermille/hpMarginPermille) relatedness(None)
seeds=16 cells=9 csv=144 rows svg=3758 bytes  elapsed=18 ms
wrote out/play07_sdt.csv out/play07_sdt.svg out/play07_sdt.json

读表:三种代理的可动性完全不同

胜任是三者里最有区分度的:松(enemyHp=50)九格全 1000‰,紧(enemyHp=100)九格全 0‰,中间那一档才拉开——中·最小费用优先 只有 187‰,而同一遭遇点的另外两种策略都是 1000‰。这说明胜任代理真的在测策略差异,而不是在测参数。

自主的读数会随遭遇点一起变,这一点值得警惕。同一策略 seq0 在松 / 中 / 紧三档分别是 3 / 2 / 1:不是敌人强度改了手牌结构,而是对局长度变了——松档三四回合就结束,记录的决策点全在能量充裕的早期;紧档要撑满二十回合,后半段能量耗尽的「被迫选择」被算进了分母。于是自主代理的分母依赖对局长度,这是它的效力边界,与上一卷 turn 在败局工作点上语义翻转是同一类问题。

关联一列全是 N/A。它不是「算出来等于 0」,而是没有接口可算——play_spec.json 里不存在描述他者的字段。

自检表

检查项合格标准
需要可计算每个可代理的需要都给出读数来源与方向,不写问卷项
同源可断言逐回合观测器与 ashlib.run_spec 在默认策略上 digest 逐字相同
缺接口显式relatedness 记 None 而非 0,正文说明「无接口」
代理声明明确写出读数是 proxy,只做相对比较,禁绝对化
边界入档指出自主代理的分母随对局长度变化

三需要里只有一个半能算:自主能算但有分母歧义,胜任能算且有区分度,关联根本不能算。下一节把这三个读数拧成一个动因模型——归一化、权重、缺失处理全部写进声明。

阅读导航:上一节:6.3 相变扫描的实现 · 下一节:7.2 动因模型的可计算化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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