7.3 动因与机制的对齐
《小游戏开发实战》卷一 5.2 动机模型与付费意愿分层 那篇的落点是卖点对齐——某种动因对应哪种付费档位,属于产品口径;本节说的「对齐」是机制口径:动因分量与机制旋钮之间,能不能做到一个旋钮只推一个分量。上一节把动因写成了整数函数,但函数能算不等于能控——如果一个分量没有任何旋钮能单独推动它,那它在设计上就是不可操作的。
对齐 = 可分离性,不是「相关」
「对齐」这个词很容易被说成一句软话(「让机制服务于动因」)。本节给它一个可判定的定义:
| 归属 | 判据 | 含义 |
|---|---|---|
| 专属旋钮 | 只对该分量 |Δ|>0 | 该分量可被单独控制 |
| 共享旋钮 | 对 ≥2 个分量 |Δ|>0 | 动它会同时动别的分量(冲突) |
| 死旋钮 | 对全部分量 |Δ|=0 | 在这个工作点上推不动任何分量 |
判定办法只有一步:把 7.2 的分量函数固定在一个工作点上,逐个旋钮加一个整数步长,量出 Δ分量。Δ 是代理读数之差,只用于相对比较,不构成「动因提升」的断言。
对齐度 = 有专属旋钮的分量数 / 3。 一个分量只要有至少一个只服务它的旋钮,就算「对齐」;一个旋钮同时服务多个分量,就记一次冲突。这两个计数都是整数的,不涉及任何主观评分。
归属判定只有三行代码,且顺序不能换:先看有没有命中(死旋钮),再看命中数是否 ≥2(共享),最后才落回专属。
# snippet: 归属判定(与脚本里 owner / own_of 两段同规则)
for kn in knobs: # 每个旋钮
hit = [c for c in comps if delta[kn][c] != 0] # 它推动了哪些分量
owner[kn] = "dead" if not hit else "shared" if len(hit) >= 2 else "own:" + hit[0]
own_of = {c: [k for k in knobs if owner[k] == "own:" + c] for c in comps}
aligned = sum(1 for c in comps if own_of[c]) # 有专属旋钮的分量数
alignment = 1000 * aligned // len(components) # 对齐度(千分比整数)
{
"id": "gd_play_07_alignment",
"note": "卷二 7.3 动因与机制的对齐:旋钮 × 动因分量的敏感度矩阵与归属判定(全整数,禁浮点)",
"seeds": [601, 602, 603, 604, 605, 606, 607, 608, 609, 610, 611, 612, 613, 614, 615, 616],
"strategy": { "pattern": ["p0", "p1", "p2", "p3", "e"], "turns": 10 },
"base": { "enemyHp": 70, "turnLimit": 20, "energyMax": 3, "handSize": 5 },
"knobs": [
{ "param": "energyMax", "step": 1, "note": "每回合能量预算" },
{ "param": "handSize", "step": 1, "note": "手牌容量" },
{ "param": "enemyHp", "step": 10, "note": "敌人血量" },
{ "param": "playerHp", "step": 10, "note": "玩家初始血量" },
{ "param": "enemyAttack", "step": 2, "note": "敌人每回合伤害" }
],
"components": ["autonomy", "competence", "relatedness"],
"ownership": { "deadKnob": "对全部分量 |Δ|=0", "sharedKnob": "对 ≥2 个分量 |Δ|>0" }
}
S 层:旋钮 × 分量的敏感度矩阵
脚本复用 7.2 的 components 与 raw_cell(模型定义只有一份,不重写),在工作点 enemyHp=70 / turnLimit=20 / energyMax=3 / handSize=5 上先算基线向量,再逐个旋钮 +step 算扰动向量,相减得到 5 × 3 的 Δ 矩阵,最后按上表判归属。
#!/usr/bin/env python3
# file: sim/gd_play_07_alignment.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_07_alignment.py
"""卷二 7.3 动因与机制的对齐(S 层):旋钮 × 动因分量的敏感度矩阵与归属判定。
「对齐」在本节只有一个可判定的含义:**每个动因分量能不能被某个旋钮单独推动**。
做法是把 7.2 的分量函数固定在同一个工作点上,逐个旋钮 +step,量出 Δ分量:
归属 = 专属(只对该分量 |Δ|>0) / 共享(对 ≥2 个分量 |Δ|>0) / 死旋钮(全为 0)
对齐度 = 有专属旋钮的分量数 / 3。无接口的分量(relatedness)没有任何旋钮 → 完全不可控。
分量与 Δ 都是**代理指标**,只用于相对比较,禁绝对化。全整数、无浮点、无 random。
产出 out/play07_alignment.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_alignment.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
from gd_play_07_model import components, raw_cell # noqa: E402
def vector(spec, strategy, seeds, over):
"""一个工作点上的动因向量(relatedness 恒为 None)。"""
raw = raw_cell(spec, strategy, seeds, over)
s = copy.deepcopy(spec)
s["params"].update(over)
return components(s, raw)
def draw(rows, cols, cells, path):
"""敏感度矩阵:行 = 旋钮,列 = 动因分量,格内 = Δ(色亮 = |Δ| 大)。"""
cw, ch, x0, y0 = 132, 40, 150, 74
W, H = x0 + cw * len(cols) + 40, y0 + ch * len(rows) + 48
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 16, 28, "旋钮 × 动因分量:敏感度矩阵(Δ,代理读数)", 15)
ashlib.svg_text(root, 16, 50, "亮 = 该旋钮推动该分量;一行有多个亮格 = 共享旋钮(冲突)", 11, "#94a3b8")
for j, c in enumerate(cols):
ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 - 12, c, 11, "#cbd5e1", "middle")
for i, r in enumerate(rows):
ashlib.svg_text(root, 16, y0 + i * ch + 25, f"{r['param']} {r['step']:+d}", 11, "#cbd5e1")
for j, c in enumerate(cols):
d = cells[r["param"]][c]
lv = 30 + min(200, abs(d)) * 220 // 1000
ET.SubElement(root, "rect", {
"x": str(x0 + j * cw + 4), "y": str(y0 + i * ch + 4), "width": str(cw - 8),
"height": str(ch - 8), "rx": "4", "data-delta": str(d),
"fill": f"rgb({lv + 40},{lv},{lv + 30})",
})
ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 + i * ch + 26, f"{d:+d}", 12, "#f8fafc", "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 = cfg["seeds"]
strategy = cfg["strategy"]["pattern"] * cfg["strategy"]["turns"]
base = cfg["base"]
comps = [c for c in cfg["components"] if c != "relatedness"]
v0 = vector(spec, strategy, seeds, base)
cur = {kn["param"]: base.get(kn["param"], spec["params"][kn["param"]]) for kn in cfg["knobs"]}
cells, csv_rows = {}, []
for kn in cfg["knobs"]:
after = vector(spec, strategy, seeds, {**base, kn["param"]: cur[kn["param"]] + kn["step"]})
cells[kn["param"]] = {c: (after[c] - v0[c] if after[c] is not None else 0) for c in comps}
cells[kn["param"]]["relatedness"] = 0
csv_rows.append([kn["param"], kn["step"], kn["note"], cells[kn["param"]]["autonomy"],
cells[kn["param"]]["competence"], 0])
owner = {}
for kn in cfg["knobs"]:
hit = [c for c in comps if cells[kn["param"]][c] != 0]
owner[kn["param"]] = ("dead" if not hit else "shared" if len(hit) >= 2 else f"own:{hit[0]}")
own_of = {c: [k["param"] for k in cfg["knobs"] if owner[k["param"]] == f"own:{c}"] for c in comps}
aligned = sum(1 for c in comps if own_of[c])
alignment = 1000 * aligned // len(cfg["components"])
n = ashlib.write_csv(os.path.join(OUT, "play07_alignment.csv"),
["knob", "step", "note", "dAutonomy", "dCompetence", "dRelatedness"], csv_rows)
size = draw(cfg["knobs"], cfg["components"], cells, os.path.join(OUT, "play07_alignment.svg"))
report = {"id": "gd_play_07_alignment", "base": base, "seeds": len(seeds), "v0": v0,
"cells": cells, "owner": owner, "ownOf": own_of,
"alignmentPermille": alignment, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "play07_alignment.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"工作点 base={base}")
print(f"v0(base 上的动因向量)={v0} → 自主 {v0['autonomy']} / 胜任 {v0['competence']} / 关联 None")
print(f"\n{'旋钮':<13}{'step':>5}{'Δ自主':>8}{'Δ胜任':>8}{'Δ关联':>8} 归属")
for kn in cfg["knobs"]:
c = cells[kn["param"]]
print(f"{kn['param']:<13}{kn['step']:>+5}{c['autonomy']:>8}{c['competence']:>8}"
f"{c['relatedness']:>8} {owner[kn['param']]}")
for c in cfg["components"]:
print(f" 分量 {c}: 专属旋钮 = {own_of.get(c, []) or '(无)'}")
print(f"alignmentPermille={alignment}(3 个分量里有专属旋钮的分量数 = {aligned})")
print(f"seeds={len(seeds)} knobs={len(cfg['knobs'])} csv={n} rows svg={size} bytes "
f"elapsed={report['elapsedMs']} ms")
print("wrote out/play07_alignment.csv out/play07_alignment.svg out/play07_alignment.json")
ET.parse(os.path.join(OUT, "play07_alignment.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_07_alignment.py:
工作点 base={'enemyHp': 70, 'turnLimit': 20, 'energyMax': 3, 'handSize': 5}
v0(base 上的动因向量)={'autonomy': 200, 'competence': 1000, 'relatedness': None} → 自主 200 / 胜任 1000 / 关联 None
旋钮 step Δ自主 Δ胜任 Δ关联 归属
energyMax +1 200 0 0 own:autonomy
handSize +1 133 0 0 own:autonomy
enemyHp +10 -200 -1000 0 shared
playerHp +10 0 0 0 dead
enemyAttack +2 0 0 0 dead
分量 autonomy: 专属旋钮 = ['energyMax', 'handSize']
分量 competence: 专属旋钮 = (无)
分量 relatedness: 专属旋钮 = (无)
alignmentPermille=333(3 个分量里有专属旋钮的分量数 = 1)
seeds=16 knobs=5 csv=5 rows svg=4847 bytes elapsed=9 ms
wrote out/play07_alignment.csv out/play07_alignment.svg out/play07_alignment.json
读表:三个分量、三种命运
自主有两个专属旋钮。 energyMax +1 让自主从 200 涨到 400(Δ+200),handSize +1 让自主到 333(Δ+133),而两者对胜任都是 0。自主是唯一可单独控制的分量——想让它动,动能量或手牌容量就行,不会顺带改胜负。
胜任一个专属旋钮都没有。 唯一能推胜任的是 enemyHp,而它同时是自主的强旋钮(Δ自主 −200)。也就是说,要调「胜任」就必然动「自主」:敌人血量是这两条曲线的公共自变量。这不是脚本的取舍,而是《灰烬牌库》机制结构的事实——胜负由「打出的总伤害 ≥ 敌人血量」决定,而伤害又受能量与手牌约束,于是同一个旋钮必然穿过两个分量。
两个死旋钮值得单独说。 playerHp +10 与 enemyAttack +2 在这个工作点上对两个分量的 Δ 都是 0。原因不是它们「不影响对局」,而是在这个工作点上胜负已经由输出决定:多给 10 点血、多挨 2 点伤害,都还没到翻盘的量级。死旋钮是工作点相关的——换一个更紧的工作点(如 enemyHp=90),它们很可能活过来。这与上一卷 14.1 的「死旋钮」是同一个坑,但成因不同:那里是内核根本不读该字段,这里是内核读了、但读数还没跨过阈值。
关联没有任何旋钮。 它不是「旋钮太弱」,而是规格里没有描述他者的字段——没有接口就没有旋钮,连死旋钮都算不上。
| 分量 | 专属旋钮 | 共享旋钮 | 可单独控制 |
|---|---|---|---|
| 自主 | energyMax、handSize | enemyHp | 是 |
| 胜任 | (无) | enemyHp | 否 |
| 关联 | (无) | (无) | 否(无接口) |
对齐的工程含义:先补接口,再谈权重
对齐度 333‰ 说的是:三个动因分量里只有一个能被单独推动。要让胜任也可控,必须新增旋钮而不是调权重——比如给规格加一个「弱点/抗性」字段,让敌人血量与伤害阈值解耦;要让关联可算,得先给规格加同伴或协作目标。对齐问题在规格层,不在模型层:7.2 的权重怎么调都改不了这张矩阵,因为矩阵的零是结构性的。
| 分量的处境 | 规格层动作 | 不动规格的代价 |
|---|---|---|
| 自主(已有专属旋钮) | 无需动作 | — |
| 胜任(只有共享旋钮) | 新增与伤害阈值解耦的旋钮 | 每次调难度都会连带改自主读数 |
| 关联(无接口) | 新增同伴 / 协作目标字段 | 该分量在模型里永远是 None |
| 检查项 | 合格标准 |
|---|---|
| 对齐可判定 | 归属用 |Δ| 与计数判定,不用主观评分 |
| 旋钮可分离 | 报告专属 / 共享 / 死旋钮三类,并给出分量侧的专属旋钮表 |
| 冲突显式 | 共享旋钮点名它同时推动哪些分量 |
| 死旋钮归因 | 区分「内核不读该字段」与「工作点上未跨阈值」 |
| 无接口诚实 | 无接口的分量记为无旋钮,不用 0 冒充「已对齐」 |
| 代理声明 | Δ 是 proxy 差值,只做相对比较,禁绝对化 |
三节下来,动因从「三种心理需要」变成了「一张敏感度矩阵」。下一章换一个入口:不再从需要出发,而是把玩家分成类型,看类型到机制的映射能不能算——那时本节这张矩阵会以「类型 × 旋钮」的形态再出现一次。
阅读导航:上一节:7.2 动因模型的可计算化 · 下一节:8.1 玩家类型学 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。