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 动因模型的可计算化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。