12.2 协同与反协同

协同与反协同是可判定的:定义交互项 I = 两机制同时变化的效果 − 各自单独变化的效果之和,先按观测量的好坏方向定向,再归一化成千分比。本节给出整数判据、实跑出的两处协同与两处反协同,并说明为什么「定向」这一步不能省——少了它,反协同会被符号读成协同。

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 asolo bjointI结论
energyMax × handSize+6+2+6−2反协同:多抽的牌被「一回合能打的张数」卡住,白抽
energyMax × enemyAttack+6−80+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 组合爆炸的控制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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