7.2 动因模型的可计算化

动因要能被工程使用,必须把归一化、权重、缺失处理三件事全部写成声明。本节把自主与胜任归一化到 0–1000,在 energyMax × handSize 网格上算出 20 个动因总分 M,并用三组权重各算一遍:最优格不变,但最优与次优的差距从 50 变到 66 再变到 33。

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 动因与机制的对齐 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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