7.2 动因模型的可计算化
《小游戏开发实战》卷一 5.2 动机模型与付费意愿分层 那篇的「动机模型」是一张人群分档表,用来决定卖什么、卖给谁,属于产品口径;本节的「动因模型」是一个整数函数,用来回答「改哪个旋钮、动因向量往哪走」,不涉及任何商业化。上一节把三种需要各自落成了一个代理读数,但读数只是原料——本节要把原料拧成可计算的模型,并把这个过程中每一个选择都写成声明。
一个模型 = 三张必须写死的声明
上一节的读数有三个麻烦:量纲不一(branchAvg 是「个」,winPermille 是千分比)、多分量要合成一个数、还有分量根本算不出来。任何「动因模型」只要不写清这三件事,就不是模型,而是一个没标刻度的仪表盘。三张声明:
| 声明 | 内容 | 本节取值 |
|---|---|---|
| 归一化 | 每个分量的原始读数怎么映射到 [0,1000] | 自主 1000*(branchAvg-1)//handSize;胜任直接取 winPermille |
| 权重 | 多分量怎么合成一个总分 M | 三组权重各算一遍,看结论稳不稳 |
| 缺失 | 算不出来的分量怎么处理 | relatedness 无接口 → 不参与加权,只报完整度 |
自主的归一化式子是刻意设计的:branchAvg 的理论范围是 1 … handSize+1(只能结束回合 ↔ 全部手牌都能打),减 1 再除以 handSize 正好把它压到 [0,1000]。归一化不是美化,是把「口径」写进公式——换一个归一化式子,同一批格子会得到不同的 M。
{
"id": "gd_play_07_model",
"note": "卷二 7.2 动因模型的可计算化:归一化、权重、缺失分量处理与参数网格(全整数,禁浮点)",
"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 },
"grid": {
"fixed": { "enemyHp": 70 },
"row": { "param": "energyMax", "values": [1, 2, 3, 4, 5] },
"col": { "param": "handSize", "values": [3, 4, 5, 6] }
},
"model": {
"components": [
{ "key": "autonomy", "name": "自主", "formula": "1000*(branchAvg-1)//handSize", "range": [0, 1000] },
{ "key": "competence", "name": "胜任", "formula": "winPermille", "range": [0, 1000] },
{ "key": "relatedness", "name": "关联", "formula": null, "range": null }
],
"weightSets": [
{ "id": "w11", "weights": { "autonomy": 1, "competence": 1 } },
{ "id": "w21", "weights": { "autonomy": 2, "competence": 1 } },
{ "id": "w12", "weights": { "autonomy": 1, "competence": 2 } }
],
"missingPolicy": "无接口的分量不参与加权,完整度 = 可代理分量数 / 3"
}
}
S 层:网格上的动因向量与三组权重
脚本在 energyMax ∈ [1,5] × handSize ∈ [3,6] 的 20 个格子上,每格跑 16 个种子(固定 enemyHp=70),先聚合出原始读数,再过一遍分量函数得到动因向量,最后用三组权重各合成一个总分 M。
#!/usr/bin/env python3
# file: sim/gd_play_07_model.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_07_model.py
"""卷二 7.2 动因模型的可计算化(S 层):把三种需要写成一族整数函数并声明全部口径。
「动因模型」要能被工程使用,必须把三件事写死:
1) 归一化:每个分量的原始读数(如 branchAvg)怎么映射到 [0,1000];
2) 权重:多个分量怎么合成一个总分(权重是**声明**,不是事实);
3) 缺失:没有接口的分量怎么处理(本内核 relatedness 无接口 → 不参与加权)。
本节在 (energyMax × handSize) 网格上算动因向量 M,并换三组权重各算一遍——
看**同一批格子在不同权重下的排序是否改变**。改变 = 权重不是中立的,必须写进声明。
代理指标只用于相对比较,禁绝对化。全整数、无浮点、无 random。
产出 out/play07_model.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_model.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
from gd_play_07_sdt import trace # noqa: E402
def raw_cell(spec, strategy, seeds, over):
"""一个参数格:跨种子聚合出自主与胜任两个分量的**原始读数**。"""
branch = forced = wins = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update(over)
snap, points = trace(s, 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
wins += 1 if snap["won"] else 0
k = len(seeds)
return {"branchAvg": branch // k, "forcedPermille": forced // k,
"winPermille": 1000 * wins // k}
def components(spec, raw):
"""模型的分量函数:归一化到 [0,1000],relatedness 无接口返回 None。"""
hs = spec["params"]["handSize"]
a = 1000 * (raw["branchAvg"] - 1) // hs
return {"autonomy": max(0, min(1000, a)), "competence": raw["winPermille"],
"relatedness": None}
def score(vec, weights):
"""加权合成:只对**有值**的分量加权,分母是可代理分量的权重和。"""
num = sum(weights[k] * vec[k] for k in weights if vec[k] is not None)
den = sum(weights[k] for k in weights if vec[k] is not None)
return num // den if den else 0
def draw(cells, rows, cols, path):
"""M(w11 权重)在网格上的热力图:行 = energyMax,列 = handSize。"""
cw, ch, x0, y0 = 104, 52, 96, 64
W, H = x0 + cw * len(cols) + 40, y0 + ch * len(rows) + 56
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 16, 26, "动因总分 M 在 (energyMax × handSize) 网格上", 15)
ashlib.svg_text(root, 16, 46, "权重 w11;格内 = M,颜色越亮 = 代理读数越高(只作相对比较)", 11, "#94a3b8")
for j, c in enumerate(cols):
ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 - 12, f"handSize={c}", 11, "#cbd5e1", "middle")
for i, r in enumerate(rows):
ashlib.svg_text(root, 16, y0 + i * ch + 30, f"energyMax={r}", 11, "#cbd5e1")
for j, c in enumerate(cols):
m = cells[(r, c)]["M"]
lv = 40 + m * 200 // 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-m": str(m),
"fill": f"rgb({lv},{lv + 40},80)",
})
ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 + i * ch + 32, str(m), 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"]
rows, cols = cfg["grid"]["row"]["values"], cfg["grid"]["col"]["values"]
fixed = cfg["grid"]["fixed"]
wsets = cfg["model"]["weightSets"]
cells, csv_rows = {}, []
for rv in rows:
for cv in cols:
s = copy.deepcopy(spec)
s["params"]["energyMax"] = rv
s["params"]["handSize"] = cv
raw = raw_cell(spec, strategy, seeds, {**fixed, "energyMax": rv, "handSize": cv})
vec = components(s, raw)
m = {w["id"]: score(vec, w["weights"]) for w in wsets}
cells[(rv, cv)] = {"raw": raw, "vec": vec, "M": m["w11"], "byWeights": m}
csv_rows.append([rv, cv, raw["branchAvg"], raw["forcedPermille"], raw["winPermille"],
vec["autonomy"], vec["competence"], m["w11"], m["w21"], m["w12"]])
ranking = {w["id"]: sorted(cells, key=lambda k: (-cells[k]["byWeights"][w["id"]], k))
for w in wsets}
top = {w["id"]: ranking[w["id"]][0] for w in wsets}
spread = {}
for w in wsets:
vals = sorted((cells[k]["byWeights"][w["id"]] for k in cells), reverse=True)
nxt = max((v for v in vals if v < vals[0]), default=None)
spread[w["id"]] = {"top": vals[0], "tied": sum(1 for v in vals if v == vals[0]),
"gap": (vals[0] - nxt) if nxt is not None else 0}
n = ashlib.write_csv(os.path.join(OUT, "play07_model.csv"),
["energyMax", "handSize", "branchAvg", "forcedPermille", "winPermille",
"autonomy", "competence", "M_w11", "M_w21", "M_w12"], csv_rows)
size = draw(cells, rows, cols, os.path.join(OUT, "play07_model.svg"))
coverage = 2 * 1000 // 3
report = {"id": "gd_play_07_model", "seeds": len(seeds), "weights": wsets,
"fixed": fixed, "coveragePermille": coverage,
"cells": {f"{r}/{c}": cells[(r, c)] for r, c in cells}, "top": {k: list(v) for k, v in top.items()},
"spread": spread,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "play07_model.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"网格固定 {fixed};M = 加权(自主, 胜任),relatedness 无接口不参与\n")
print(f"{'energyMax':>9}{'handSize':>9}{'branch':>8}{'被迫‰':>7}{'自主':>6}{'胜任':>6}"
f"{'M_w11':>7}{'M_w21':>7}{'M_w12':>7}")
for rv in rows:
for cv in cols:
c = cells[(rv, cv)]
print(f"{rv:>9}{cv:>9}{c['raw']['branchAvg']:>8}{c['raw']['forcedPermille']:>7}"
f"{c['vec']['autonomy']:>6}{c['vec']['competence']:>6}"
f"{c['byWeights']['w11']:>7}{c['byWeights']['w21']:>7}{c['byWeights']['w12']:>7}")
print(f"\ncoveragePermille={coverage}(3 个需要里只有 2 个可代理,relatedness 无接口)")
for w in wsets:
sp = spread[w["id"]]
print(f" {w['id']}: top={top[w['id']]} M={sp['top']} 并列={sp['tied']}格 "
f"与次优差={sp['gap']}")
print(f"seeds={len(seeds)} cells={len(cells)} csv={n} rows svg={size} bytes "
f"elapsed={report['elapsedMs']} ms")
print("wrote out/play07_model.csv out/play07_model.svg out/play07_model.json")
ET.parse(os.path.join(OUT, "play07_model.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_07_model.py:
网格固定 {'enemyHp': 70};M = 加权(自主, 胜任),relatedness 无接口不参与
energyMax handSize branch 被迫‰ 自主 胜任 M_w11 M_w21 M_w12
1 3 1 738 0 0 0 0 0
1 4 1 548 0 0 0 0 0
1 5 1 578 0 0 0 0 0
1 6 1 521 0 0 0 0 0
2 3 1 531 0 1000 500 333 666
2 4 2 418 250 1000 625 500 750
2 5 2 443 200 1000 600 466 733
2 6 2 460 166 1000 583 444 722
3 3 2 216 333 1000 666 555 777
3 4 2 111 250 1000 625 500 750
3 5 2 183 200 1000 600 466 733
3 6 3 191 333 1000 666 555 777
4 3 2 73 333 1000 666 555 777
4 4 3 22 500 1000 750 666 833
4 5 3 106 400 1000 700 600 800
4 6 3 50 333 1000 666 555 777
5 3 2 7 333 1000 666 555 777
5 4 3 0 500 1000 750 666 833
5 5 3 22 400 1000 700 600 800
5 6 4 14 500 1000 750 666 833
coveragePermille=666(3 个需要里只有 2 个可代理,relatedness 无接口)
w11: top=(4, 4) M=750 并列=3格 与次优差=50
w21: top=(4, 4) M=666 并列=3格 与次优差=66
w12: top=(4, 4) M=833 并列=3格 与次优差=33
seeds=16 cells=20 csv=20 rows svg=5982 bytes elapsed=25 ms
wrote out/play07_model.csv out/play07_model.svg out/play07_model.json
读表:分量是阶跃的,权重改变的是区分度
三件值得拆开读的事。
第一,M 在网格上是台阶,不是斜面。 energyMax=1 整行全 0:一点能量只够打一张牌,branchAvg 恒为 1,自主分量归零;同时这个配置打不穿 70 点血,胜任也是 0。跨过 energyMax=2 这道坎,胜任直接跳到 1000。动因分量对参数是阈值响应,不是线性响应——所以「把能量 +1 会让动因提升 x%」这种说法在本模型里没有立足点。
第二,自主分量对 handSize 的响应不单调。 同为 energyMax=4,handSize 从 3 到 4 时自主从 333 升到 500,再到 5 时反而掉回 400。原因是归一化式子的分母就是 handSize:手牌变多既让 branchAvg 上升(分子),也让分母变大,两者方向相反。归一化式子是模型的一部分,不是无关紧要的装饰。
第三,换权重不换最优格,但换区分度。 三组权重给出的最优格都是 (energyMax=4, handSize=4),且都有 3 个格子并列——排序在这个网格上对权重不敏感。但最优与次优的差距随权重变:w11 是 50、w21 是 66、w12 是 33。也就是说,权重调的是「模型对次优解的分辨力」:w21 最偏袒自主,把 (4,4) 与 (5,4) 这类只差自主的格子拉得最开;w12 偏袒胜任,差距被压缩。
自检表
| 检查项 | 合格标准 |
|---|---|
| 归一化写死 | 每个分量的归一化式子写进 config,不是藏在代码里 |
| 权重是声明 | 权重成组列出,且报告结论对权重的敏感性 |
| 缺失有策略 | 无接口分量显式不参与加权,并报出完整度 666‰ |
| 不绝对化 | M 是代理读数合成,只做格间相对比较 |
| 分量单调性 | 检查并报告归一化是否引入非单调(本节 handSize 一例) |
模型有了,但 relatedness 从头到尾是 None,M 只覆盖 2/3 个需要。下一节把「模型能不能被机制旋钮单独推动」变成一个可判定的矩阵:每个旋钮推哪些分量、哪些分量没有任何专属旋钮。
阅读导航:上一节:7.1 自我决定理论:自主、胜任、关联 · 下一节:7.3 动因与机制的对齐 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。