12.2 协同与反协同
《小游戏开发实战》卷一 4.1 包体预算 那篇算的是资源占用会不会超预算;本节算的是两个机制一起动时,效果会不会超出各自单独效果之和。这层「超出」在平衡设计里被反复谈论(经济与平衡 那篇有一整套经验叙述),但很少被写成一个能判 true / false 的整数判据。
从一阶到二阶:交互项
12.1 的耦合矩阵是一阶的:一次只动一个参数。两个旋钮同时动时,最简单的问题是——效果可加吗?把「可加」当作零假设,偏差就是交互项:
| 记号 | 定义 |
|---|---|
solo(a) | 只动 a:O(a + step) − O(base) |
solo(b) | 只动 b:O(b + step) − O(base) |
joint | 同时动:O(a + step, b + step) − O(base) |
I | 交互项 = joint − (solo(a) + solo(b)),整数 |
Ĩ | sign(o) * 1000 * I // base[o],归一化到千分比 |
判据:Ĩ ≥ +10‰ → 协同;Ĩ ≤ −10‰ → 反协同;其间 → 可加。三条阈值都写进 config,改口径只改配置不改代码。
定向这一步不能省
turn 是越小越好,playerHp 是越大越好。如果直接把 I 的符号当结论,turn 上的一次「冗余」会被读成协同——两个旋钮各自把回合数压掉 1,一起动还是只压掉 1,I = +1 是正的,可它描述的是边际收益递减,是反协同。所以判据必须先乘上观测列的好坏方向 sign(o):turn 取 −1、playerHp 取 +1。少了这一步,符号会系统性地骗人。
判定实验(S 层)
脚本沿用 12.1 的工作点(enemyHp=70)、种子集合与探针策略,只把扰动步长放大到 2,并对三对机制分别算 solo / joint / I。种子固定、全整数,故 I 是确定量:重复跑逐位相同,不需要置信区间。
#!/usr/bin/env python3
# file: sim/gd_mech_12_synergy.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_12_synergy.py
"""卷一 12.2 协同与反协同(S 层):二阶交互项的整数判据。
判定式(机制对 a、b 与观测列 o):
solo(a) = O(a+step) − O(base) solo(b) = O(b+step) − O(base)
joint = O(a+step, b+step) − O(base)
I = joint − (solo(a) + solo(b)) # 原始交互项,整数
Ĩ = sign(o) * 1000 * I // base[o] # 先按「好坏方向」定向,再归一化
分类:Ĩ ≥ +10‰ → 协同;Ĩ ≤ −10‰ → 反协同;其间 → 可加。
**定向不能省**:turn 越小越好(sign = −1),playerHp 越大越好(sign = +1)。
若跳过定向,turn 上的一次「冗余」会被符号读成协同,结论正好反过来。
种子固定、全整数,故 I 是确定量:重复跑逐位相同。
产出 out/mech12_synergy.csv + out/mech12_synergy.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_synergy.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def observe(spec, seeds, strategy, base, over, keys):
acc = {k: 0 for k in keys}
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]
n = len(seeds)
return {k: acc[k] // n for k in acc}
def bars(rows, path):
"""每组机制对一行:solo(a) / solo(b) / joint / Ĩ 四根发散条,零线居中。"""
cw, ch, x0, y0 = 150, 96, 200, 66
W, H = x0 + cw * len(rows) + 40, y0 + ch + 60
zero = y0 + ch // 2
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 16, 28, "协同 / 反协同:定向后的整数差(零线居中,红 = 反协同)", 15)
ET.SubElement(root, "line", {"x1": "150", "y1": str(zero), "x2": str(W - 20), "y2": str(zero),
"stroke": "#475569"})
for i, r in enumerate(rows):
x = x0 + i * cw
ashlib.svg_text(root, x + 54, y0 + ch + 26, f"{r['a']} × {r['b']} / {r['obs']}", 11, "#94a3b8", "middle")
for k, (name, val) in enumerate((("solo a", r["soloA"]), ("solo b", r["soloB"]),
("joint", r["joint"]), ("I", r["interaction"]))):
bx = x + k * 32
h = abs(val) * 6
y = zero - h if val > 0 else zero
fill = "#f59e0b" if val > 0 else ("#38bdf8" if val < 0 else "#334155")
if name == "I":
fill = "#ef4444" if val < 0 else "#22c55e"
ET.SubElement(root, "rect", {"x": str(bx), "y": str(y), "width": "26",
"height": str(max(h, 1)), "fill": fill})
ashlib.svg_text(root, bx + 13, y - 4 if val > 0 else y + h + 13, val, 11, "#cbd5e1", "middle")
ashlib.svg_text(root, bx + 13, y0 + ch + 44, name, 10, "#64748b", "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, step = cfg["seeds"], cfg["step"]
obs = cfg["observables"]
names = [o["name"] for o in obs]
syn, anti = cfg["classify"]["synergyPermille"], cfg["classify"]["antisynergyPermille"]
base = {cfg["operatingPoint"]["param"]: cfg["operatingPoint"]["value"]}
strategy = cfg["probePattern"] * cfg["probeTurns"]
b = observe(spec, seeds, strategy, base, {}, names)
rows, csv = [], []
for a, bb in cfg["pairs"]:
solo_a = observe(spec, seeds, strategy, base, {a: spec["params"][a] + step}, names)
solo_b = observe(spec, seeds, strategy, base, {bb: spec["params"][bb] + step}, names)
joint = observe(spec, seeds, strategy, base,
{a: spec["params"][a] + step, bb: spec["params"][bb] + step}, names)
for o in obs:
da, db, dj = solo_a[o["name"]] - b[o["name"]], solo_b[o["name"]] - b[o["name"]], \
joint[o["name"]] - b[o["name"]]
inter = dj - (da + db)
pm = o["sign"] * 1000 * inter // b[o["name"]]
kind = "synergy" if pm >= syn else ("antisynergy" if pm <= anti else "additive")
rows.append({"a": a, "b": bb, "obs": o["name"], "base": b[o["name"]], "soloA": da,
"soloB": db, "joint": dj, "interaction": inter,
"orientedPermille": pm, "kind": kind})
csv.append([a, bb, o["name"], b[o["name"]], da, db, dj, inter, pm, kind])
ashlib.write_csv(os.path.join(OUT, "mech12_synergy.csv"),
["a", "b", "obs", "base", "soloA", "soloB", "joint", "I", "Ipermille", "kind"], csv)
plot = [r for r in rows if r["obs"] == "playerHp"]
size = bars(plot, os.path.join(OUT, "mech12_synergy.svg"))
counts = {k: sum(1 for r in rows if r["kind"] == k) for k in ("synergy", "antisynergy", "additive")}
report = {"id": "gd_mech_12_synergy", "operatingPoint": base, "seeds": len(seeds), "step": step,
"base": b, "pairs": len(cfg["pairs"]), "cells": len(rows), "counts": counts,
"classify": cfg["classify"], "rows": rows,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "mech12_synergy.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"{n}={b[n]}" for n in names))
print(f"{'pair':<28}{'obs':<10}{'soloA':>7}{'soloB':>7}{'joint':>7}{'I':>5}{'I‰':>7} kind")
for r in rows:
print(f"{r['a']+' × '+r['b']:<28}{r['obs']:<10}{r['soloA']:>7}{r['soloB']:>7}"
f"{r['joint']:>7}{r['interaction']:>5}{r['orientedPermille']:>7} {r['kind']}")
print(f"cells={len(rows)} counts={counts}")
print(f"elapsed={report['elapsedMs']} ms svg={size} bytes")
print("wrote out/mech12_synergy.csv out/mech12_synergy.svg out/mech12_synergy.json")
ET.parse(os.path.join(OUT, "mech12_synergy.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_12_synergy.py:
base@enemyHp=70: turn=5 playerHp=42
pair obs soloA soloB joint I I‰ kind
energyMax × handSize turn -1 -1 -1 1 -200 antisynergy
energyMax × handSize playerHp 6 2 6 -2 -48 antisynergy
energyMax × enemyAttack turn -1 0 -1 0 0 additive
energyMax × enemyAttack playerHp 6 -8 0 2 47 synergy
handSize × enemyAttack turn -1 0 -1 0 0 additive
handSize × enemyAttack playerHp 2 -8 -5 1 23 synergy
cells=6 counts={'synergy': 2, 'antisynergy': 2, 'additive': 2}
elapsed=31 ms svg=4607 bytes
wrote out/mech12_synergy.csv out/mech12_synergy.svg out/mech12_synergy.json
读表:两处协同、两处反协同
playerHp 那一列(工作点基线 42)给出本节的主角:
| 机制对 | solo a | solo b | joint | I | 结论 |
|---|---|---|---|---|---|
energyMax × handSize | +6 | +2 | +6 | −2 | 反协同:多抽的牌被「一回合能打的张数」卡住,白抽 |
energyMax × enemyAttack | +6 | −8 | 0 | +2 | 协同:加能量的收益在敌人更凶时才兑现,攻防边际恰好对冲 |
handSize × enemyAttack | +2 | −8 | −5 | +1 | 协同:敌人每回合多咬一口时,手牌多一张的价值才显出来 |
energyMax × handSize(turn) | −1 | −1 | −1 | +1 | 反协同:定向后 −200‰,第二次压回合是冗余 |
反协同的那一行最能说明问题:handSize +2 单独能换来 +2 血,可它一旦与 energyMax +2 同时出现,那 +2 就被吞掉了。机制上的原因是能量才是瓶颈——手牌再多,每回合的出牌张数仍被能量封顶,多抽的牌只能躺在手里。这是「协同/反协同」诊断在设计上的直接用处:它指出该先补哪个旋钮。
turn 那一列还藏着第二个反协同:energyMax 与 handSize 各自都能把回合数压掉 1,一起动仍然只压掉 1。这正是不定向就会读错的那一格——原始 I = +1 看起来像协同,定向之后(turn 越小越好,取 sign = −1)才露出「边际收益递减」的真面目,归一化后是 −200‰。
交互项为什么非零:机制解释
反协同那一行值得再拆一层。energyMax 与 handSize 各自单独动时都让 playerHp 变多(+6 与 +2),但两者抢的是同一条瓶颈:每回合能打出的牌张数同时受能量上限与手牌数量约束,真正封顶的是较小的那个。单独加手牌能多挤出 2 点血,是因为它把瓶颈从手牌挪到了能量;一旦能量也一起加上去,手牌那 +2 的边际空间就被能量的增量吸收掉了——joint 仍是 +6,正好等于能量单独的效果。两个旋钮落在瓶颈的两侧,所以它们的联合效果不是相加。
这条解释给出一个可操作的推论:先补当前瓶颈。在 energyMax × handSize 这一对上,反协同的负号在说「别同时加,先加能量」;而 energyMax × enemyAttack 的协同则在说「加能量的收益会在敌人更凶时兑现」,两个旋钮的收益是互补而非竞争的。
| 关系 | 机制成因 | 设计动作 |
|---|---|---|
| 协同 | 两个旋钮作用在不同的瓶颈上,收益互补 | 可以同时调,且一起调的收益大于分别调 |
| 反协同 | 两个旋钮作用在同一瓶颈上,收益竞争 | 只调一个;同时调会浪费其中一份预算 |
| 可加 | 两条作用路径互不相交 | 随便调,按线性外推即可 |
判据的边界
三点必须说清楚,否则判据会被误用:
- 交互项依赖步长。本节用
step = 2;步长越大越容易把非线性放大成非零I。换步长必须重跑,不能把不同步长的I拿来比。 - 交互项依赖工作点。和 12.1 一样,
enemyHp=70上算出的协同 / 反协同换到enemyHp=90未必成立——工作点一动,瓶颈就可能换位。 - 只对两两成立。三阶及以上的交互(三个旋钮一起动)本节没有定义。组合空间的全貌要靠 12.3 的枚举去覆盖,而不是把两两交互叠起来外推。
最后一条尤其要守住:I 是二阶量,它不能替代对组合空间的直接扫描。把两两交互相加去预测三旋钮的效果,是一个没有保证的外推。
自检表
| 检查项 | 合格标准 |
|---|---|
| 交互项有定义 | I = joint − (solo + solo) 写成公式,不是描述 |
| 方向先定向 | 每个观测列声明 sign,否则符号会反向 |
| 判据可判定 | 三条整数阈值(协同 / 反协同 / 可加)写进 config |
| 结论可复现 | 种子固定、全整数,I 是确定量 |
交互项的绝对值都不超过 2,量级远小于一阶效应(solo 列最大到 ±8)。二阶效应这么小,说明组合空间大体上是可分解的——这正是下一节敢对组合空间做剪枝与采样的底气。
阅读导航:上一节:12.1 机制之间的耦合 · 下一节:12.3 组合爆炸的控制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。