12.1 机制之间的耦合

把「机制耦合」写成一个可算的整数:在一个工作点上对每个参数做单参数扰动,看其它量的整数差,落成 7×5 的耦合矩阵与热力格。本节解释为什么 dealt 列恒零、为什么 rewardGold 与 punishHp 是死旋钮,以及为什么耦合是工作点的函数,而不是机制的内禀属性。

12.1 机制之间的耦合

《小游戏开发实战》卷一 4.1 包体预算 那篇量的是包体预算——纹理、音频、代码各占多少 MB,是一条与玩法无关的工程红线。本节量的是机制耦合:机制 A 的参数动一格,机制 B 的产出跟着动多少。两者都用整数说话,但一个是字节数,一个是机制之间的偏导。

把「联动」写成一个整数

「这两个机制是耦合的」在日常讨论里几乎无法反驳,因为它没有量。本节给它一个可计算的替身:

记号定义
base一个工作点:所有参数取定值的那一组设定
O(p + step)把参数 p 单独加 step,其余不动,跑同一组种子得到的观测均值
M[p][o]耦合矩阵项 = O(p + step)[o] − base[o],整数差
千分比1000 * M[p][o] // base[o],把不同量纲的列拉到可比

观测列取五个:dealt(总伤害)、played(出牌数)、turn(消耗回合)、playerHp(剩余血)、winPermille(胜率千分比)。行是七个参数:energyMax / handSize / enemyAttack / turnLimit / playerHp / rewardGold / punishHp。

这张表的每一格都是一个受控实验:同一组种子、同一条探针策略、同一个工作点,唯一变化的只有那一行参数。正因为如此,M[p][o] 才是可复现的整数,而不是「调了调感觉变强了」。矩阵不是因果图——它只说「同时变」,不说「因为所以」;但它至少把「同时变」这件事变成了一个能贴进验收表的数。

探针策略:为什么必须固定

矩阵里还有一个人为选择的量:探针策略。本节用「每回合依次出 p0 p1 p2 p3 再结束回合」重复 20 次,这是刻意选的一条不最优、但稳定的策略。

选择理由
固定不变策略一变,所有格子的含义都变,矩阵就失去可比性
不最优最优策略会绕开瓶颈,把「机制耦合」测成「最优解的抗扰动性」
足够长20 回合余量让 turnLimit 这类上限有机会被触及(实测仍未触及,见下)

换句话说,耦合矩阵测的是机制在工作点附近的行为,不是玩家在最优打法下的行为。两者都要测,但混在一张表里就没法读。

为什么不能在规格基线 50 上测

play_spec.json 的基线 enemyHp 是 50,而这条固定探针策略三回合就能打完。在速胜的工作点上,绝大多数旋钮还没来得及起作用游戏就结束了——量出来的矩阵几乎全是零,那不是「机制解耦」,是实验设计失败。所以本节把工作点抬到 enemyHp=70:一局打得完、但不轻松,旋钮才有机会表现。

这句话本身就是本节最要紧的结论:耦合不是机制的内禀属性,而是工作点的函数。同一个 handSize,在速胜点上是死旋钮,在拉锯点上就是活旋钮。

单参数扰动实验(S 层)

下面这段是 S 层脚本。它按 config 给出的工作点、显式种子、显式探针策略,对每一行参数分别做 −1 与 +1 两次扰动,落出 CSV、热力格 SVG 与 JSON 报告。热力格用纯 xml.etree 生成:正差涂琥珀、负差涂蓝、零差留深底——深底越多,矩阵越稀疏。

#!/usr/bin/env python3
# file: sim/gd_mech_12_coupling.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_12_coupling.py
"""卷一 12.1 机制耦合矩阵(S 层):单参数扰动下其它量的响应。

耦合的定义:把参数 p 在**一个工作点**上单独加 step,看观测列 o 变多少。
矩阵项 M[p][o] = O(p + step) − O(base),全部是整数差;千分比 = 1000 * Δ // base[o]。
工作点由 config 指定(本节取 enemyHp=70,见正文为什么不能在基线 50 上测)。

观测列取 dealt / played / turn / playerHp / winPermille 五个。同一组显式种子、
同一条显式探针策略,故任何差异都只来自那一个被扰动的参数。
产出 out/mech12_coupling.csv(两方向)+ out/mech12_coupling.svg(热力格)+ .json。
"""
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_mech_12_coupling.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def observe(spec, seeds, strategy, base, over):
    """在 base(工作点)+ over(扰动)下,跑全部种子,返回整数均值观测。"""
    acc = {k: 0 for k in ("dealt", "played", "turn", "playerHp")}
    wins = 0
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        s["params"].update(base)
        s["params"].update(over)
        snap = ashlib.run_spec(s, strategy)
        for k in acc:
            acc[k] += snap[k]
        wins += 1 if snap["won"] else 0
    n = len(seeds)
    return {**{k: acc[k] // n for k in acc}, "winPermille": 1000 * wins // n}


def heatmap(matrix, rows, cols, base, path):
    """整数差热力格:正 = 琥珀、负 = 蓝、零 = 深底(说明「解耦」)。"""
    cw, ch, x0, y0 = 96, 40, 132, 64
    W, H = x0 + cw * len(cols) + 24, y0 + ch * len(rows) + 40
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 16, 28, "机制耦合矩阵(行 = 被扰动参数 +1,列 = 观测量的整数差)", 15)
    for j, o in enumerate(cols):
        ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 - 12, o, 12, "#94a3b8", "middle")
    for i, p in enumerate(rows):
        y = y0 + i * ch
        ashlib.svg_text(root, x0 - 10, y + ch // 2 + 4, p, 12, "#cbd5e1", "end")
        for j, o in enumerate(cols):
            d = matrix[p][o]
            fill = "#1e293b" if d == 0 else ("#f59e0b" if d > 0 else "#38bdf8")
            ET.SubElement(root, "rect", {"x": str(x0 + j * cw + 2), "y": str(y + 2),
                                         "width": str(cw - 4), "height": str(ch - 4),
                                         "fill": fill, "data-delta": str(d)})
            ink = "#0f172a" if d != 0 else "#64748b"
            ashlib.svg_text(root, x0 + j * cw + cw // 2, y + ch // 2 + 5, d, 13, ink, "middle")
    ashlib.svg_text(root, 16, H - 14, f"工作点 {base}", 12, "#64748b")
    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, rows, cols, step = cfg["seeds"], cfg["rows"], cfg["observables"], cfg["step"]
    base = {cfg["operatingPoint"]["param"]: cfg["operatingPoint"]["value"]}
    strategy = cfg["probePattern"] * cfg["probeTurns"]

    b = observe(spec, seeds, strategy, base, {})
    matrix, csv_rows = {}, []
    for p in rows:
        matrix[p] = {}
        for d in (-step, step):
            r = observe(spec, seeds, strategy, base, {p: spec["params"][p] + d})
            delta = {o: r[o] - b[o] for o in cols}
            if d == step:
                matrix[p] = delta
            csv_rows.append([p, d] + [delta[o] for o in cols])

    ashlib.write_csv(os.path.join(OUT, "mech12_coupling.csv"), ["param", "step"] + cols, csv_rows)
    size = heatmap(matrix, rows, cols, f"{cfg['operatingPoint']['param']}={base['enemyHp']}",
                   os.path.join(OUT, "mech12_coupling.svg"))
    report = {"id": "gd_mech_12_coupling", "operatingPoint": base, "seeds": len(seeds),
              "strategyLength": len(strategy), "step": step, "base": b,
              "matrix": {p: matrix[p] for p in rows}, "cells": len(rows) * 2,
              "runs": len(rows) * 2 * len(seeds),
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    with open(os.path.join(OUT, "mech12_coupling.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@{cfg['operatingPoint']['param']}={base['enemyHp']}: " +
          "  ".join(f"{o}={b[o]}" for o in cols))
    print("param        +1: " + "  ".join(f"{o:>12}" for o in cols))
    for p in rows:
        print(f"{p:<12}     " + "  ".join(f"{matrix[p][o]:>12}" for o in cols))
    print(f"cells={report['cells']}  runs={report['runs']}  elapsed={report['elapsedMs']} ms  svg={size} bytes")
    print("wrote out/mech12_coupling.csv out/mech12_coupling.svg out/mech12_coupling.json")
    ET.parse(os.path.join(OUT, "mech12_coupling.svg"))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_12_coupling.py:

base@enemyHp=70: dealt=70  played=11  turn=5  playerHp=42  winPermille=1000
param        +1:        dealt        played          turn      playerHp   winPermille
energyMax                   0             0            -1             4             0
handSize                    0             0             0             1             0
enemyAttack                 0             0             0            -4             0
turnLimit                   0             0             0             0             0
playerHp                    0             0             0             1             0
rewardGold                  0             0             0             0             0
punishHp                    0             0             0             0             0
cells=14  runs=224  elapsed=27 ms  svg=9019 bytes
wrote out/mech12_coupling.csv out/mech12_coupling.svg out/mech12_coupling.json

读矩阵:一列零 + 两类零行

矩阵里有几处零不是「碰巧」:

现象值解释
dealt 列恒零七行全 0赢了的一局里 dealt 恒等于 enemyHp,它是目标锁定的量,不携带任何机制信息
winPermille 列恒零七行全 0工作点上每条扰动都还赢得下来,胜率这个宏观序参量在这里没有分辨率
turnLimit 行全零±1 都是 0一局只打 5 回合,离上限 20 还有 15 回合余量,这个旋钮从未被触及
rewardGold / punishHp 行全零四格全 0这两个参数在 run_spec 里从未被读取——规格里的死旋钮

最后一类最值得警惕:rewardGold 与 punishHp 在 play_spec.json 的 params 里声明得堂堂正正,仿真却完全不消费它们。耦合矩阵顺带充当了死参数检测器——任何一行全零的参数,要么在本工作点未激活(turnLimit),要么根本没接进机制(rewardGold / punishHp)。两者的处置完全不同:前者调工作点,后者是规格缺陷。

winPermille 列全零还有一层用处:它说明观测列本身也有分辨率问题。想让胜率当耦合的观测列,得先把工作点推到临界点附近(见 6.2 的相变扫描),否则它是一条 1000‰ 的常数,任何扰动都读不出来。

活跃的那几格能直接读出机制含义。energyMax +1 让 turn 少 1(−200‰)、playerHp 多 4(+95‰),因为每回合多 1 点能量等于多打一张牌;enemyAttack +1 让 playerHp 少 4(−95‰),几乎就是敌人每回合多咬一口。这两个数字量级对称:+4 与 −4,说明在这一点附近攻防两个旋钮的边际效应恰好互相抵消——这正是 12.2 要精确化的东西。

原始产物:CSV 的两行读法

正文只贴了 +1 方向的矩阵,脚本实际把两个方向都落进 CSV(用 step 列区分):

param,step,dealt,played,turn,playerHp,winPermille
energyMax,-1,0,0,1,-8,0
energyMax,1,0,0,-1,4,0
handSize,-1,0,0,1,-4,0
handSize,1,0,0,0,1,0
enemyAttack,-1,0,0,0,4,0
enemyAttack,1,0,0,0,-4,0

不对称本身就是判据的一部分:energyMax 的 −1 给出 turn +1、playerHp −8,而 +1 给出 turn −1、playerHp +4。−8 与 +4 不等,说明这个旋钮在工作点附近是非对称的——砍 1 点能量的代价(多打一回合、多挨一轮攻击)远大于加 1 点能量的收益。只看 +1 一行,这半个故事就丢了。

耦合矩阵的三种用途

用途怎么用本节例子
找瓶颈行内非零格最多的参数,就是当前最敏感的旋钮energyMax 同时推动 turn 与 playerHp
找死接线全零行里「声明了却不被消费」的那一类rewardGold / punishHp
找可剪枝全零行可作为组合空间的塌缩依据(见 12.3)三个死旋钮把指数从 8 降到 5

第三点是把 12.1 与 12.3 缝起来的那根线:耦合矩阵不只是描述机制的一张表,它同时是组合空间的剪枝依据。矩阵越稀疏,后面的枚举就越省。

一句话记法

耦合矩阵 = 一次动一格,看别人怎么动。行是被动的旋钮,列是旁观的量,格子里的整数就是联动强度。稀疏说明机制彼此独立,稠密说明机制相互纠缠——两者都不是好坏,但都必须先知道,再谈平衡。

自检表

检查项合格标准
工作点写清楚明说为什么不在基线测,并给出替换工作点的理由
扰动单变量每格只动一个参数,其余全部冻结
矩阵是整数全部整数差 + 千分比,禁浮点
零行要分类区分「本工作点未激活」与「根本没接线」两种零

矩阵只说了一阶效应。两个旋钮同时动,效果是不是两个单独效果之和?下一节给一个可判定的整数判据。

阅读导航:上一节:11.3 数值的可复现 · 下一节:12.2 协同与反协同 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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