7.3 动因与机制的对齐

对齐在本节只有一个可判定的含义:每个动因分量能不能被某个旋钮单独推动。本节在固定工作点上逐个扰动五个旋钮,量出它们对自主与胜任的 Δ,判定专属、共享、死旋钮三类归属:自主有两个专属旋钮,胜任一个都没有,关联无任何旋钮,对齐度只有 333‰。

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、handSizeenemyHp是
胜任(无)enemyHp否
关联(无)(无)否(无接口)

对齐的工程含义:先补接口,再谈权重

对齐度 333‰ 说的是:三个动因分量里只有一个能被单独推动。要让胜任也可控,必须新增旋钮而不是调权重——比如给规格加一个「弱点/抗性」字段,让敌人血量与伤害阈值解耦;要让关联可算,得先给规格加同伴或协作目标。对齐问题在规格层,不在模型层:7.2 的权重怎么调都改不了这张矩阵,因为矩阵的零是结构性的。

分量的处境规格层动作不动规格的代价
自主(已有专属旋钮)无需动作—
胜任(只有共享旋钮)新增与伤害阈值解耦的旋钮每次调难度都会连带改自主读数
关联(无接口)新增同伴 / 协作目标字段该分量在模型里永远是 None
检查项合格标准
对齐可判定归属用 |Δ| 与计数判定,不用主观评分
旋钮可分离报告专属 / 共享 / 死旋钮三类,并给出分量侧的专属旋钮表
冲突显式共享旋钮点名它同时推动哪些分量
死旋钮归因区分「内核不读该字段」与「工作点上未跨阈值」
无接口诚实无接口的分量记为无旋钮,不用 0 冒充「已对齐」
代理声明Δ 是 proxy 差值,只做相对比较,禁绝对化

三节下来,动因从「三种心理需要」变成了「一张敏感度矩阵」。下一章换一个入口:不再从需要出发,而是把玩家分成类型,看类型到机制的映射能不能算——那时本节这张矩阵会以「类型 × 旋钮」的形态再出现一次。

阅读导航:上一节:7.2 动因模型的可计算化 · 下一节:8.1 玩家类型学 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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