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 协同与反协同 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。