7.1 正反馈与负反馈

反馈循环不是「赢了更强」这句形容词,而是一条整数递推:x_{t+1} = trunc(G·x_t/1000) + c。本节把回路增益 G 跑成三档——G=1100‰ 发散、G=1000‰ 临界线性、G=900‰ 收敛到不动点区间 [111,120],产出逐回合实测增益、CSV 与纯 xml.etree 曲线,并说清为什么判据是参数 G 而不是观测到的比值。

7.1 正反馈与负反馈

《小游戏开发实战》卷一 6.2 循环时长与反馈 量的是循环多长、占了多少时间;独立游戏平衡与经济设计 把正反馈与负反馈当成概念写在平衡章节里,用散文讲滚雪球与追赶机制。本节换一个口径:把回路写成一条可算的整数递推,让「发散 / 收敛 / 临界」变成增益与 1000‰ 的一次整数比较。6.3 刚量完临界点附近的脆弱区间,本节回答那个现象背后的原因:回路增益。

回路的三样东西

反馈循环不是「赢了更强」这句形容词,而是一条可以跑出数的递推。一个回路要变成可算的东西,只需要三样:一个状态量、一个增益、一个每回合注入。写成整数递推就是 x_{t+1} = trunc(G · x_t / 1000) + c——trunc 是向零截断的整数除法,全程不用浮点。

记号含义取值来源
x_t第 t 回合末的优势存量(整数)相对基准的偏离,可为负
G回路增益(千分比)设计参数,本节取 1100 / 1000 / 900
c每回合固定注入play_spec.params.rewardGold = 12

「增益」在这里是字面意思:偏离每回合被放大多少倍,G 就是那个倍数,只是写成千分比以免浮点。判据也只有一条:

G 与 1000‰ 的关系回路性质长期行为
G > 1000正反馈占优发散:存量自我放大,越滚越大
G = 1000临界线性:每回合只加 c,比率既不衰减也不放大
G < 1000负反馈占优收敛:趋近由 c 与 G 共同决定的不动点

《灰烬牌库》里这两条回路是同一套机制的两种标定。正反馈:领先 → 掉落更多金币 → 买更强卡 → 更领先,G > 1000。负反馈:领先 → 敌人成长更快、掉落变差 → 被拉回,G < 1000。设计动作不是「加不加反馈」,而是把 G 标定在 1000 的哪一侧、离它多远。

S 层:三档跑给你看

三档的取值、回合数与初始存量都写在只读配置里,正文与脚本都以它为准;每回合注入 c 不重复声明,直接读 play_spec.params.rewardGold:

{
  "id": "gd_mech_07_feedback",
  "note": "卷一第 7 章的回路参数:全整数(千分比),禁浮点;每回合注入 c 直接读 play_spec 的 params.rewardGold",
  "turns": 16,
  "x0": 100,
  "regimes": { "divergent": 1100, "critical": 1000, "convergent": 900 },
  "delay": { "delays": [0, 1, 2, 4], "gains": [900, 1100], "history": [60, 66, 72], "turns": 20 },
  "stability": {
    "gains": [700, 800, 850, 900, 930, 950, 970, 990, 1000, 1050],
    "delays": [0, 1, 2, 4],
    "turns": 24,
    "shrinkNum": 1,
    "shrinkDen": 2
  },
  "perturb": { "gainScales": [90, 95, 100, 105, 110], "delays": [0, 1, 2, 4] }
}
#!/usr/bin/env python3
# file: sim/gd_mech_07_feedback.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_07_feedback.py
"""卷一 7.1 正反馈与负反馈(S 层):回路增益的三档。

把「回合 t 的状态量 → 回合 t+1 的状态量」写成整数递推

    x_{t+1} = trunc(G * x_t / 1000) + c

G 是**回路增益**(千分比),c 是每回合固定注入(直接读 play_spec 的 params.rewardGold)。
判据只有一条:G > 1000 正反馈占优(发散)/ G = 1000 临界(线性)/ G < 1000 负反馈占优(收敛)。

产出 out/mech07_feedback.csv(逐回合状态与实测增益)、out/mech07_feedback.svg(三条曲线)、
out/mech07_feedback.json(三档判定)。只读 config/play_spec.json 与
config/gd_mech_07_feedback.json;不用浮点、不用 random 模块、不写 config/。
"""
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_07_feedback.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

CSV_PATH = os.path.join(OUT, "mech07_feedback.csv")
SVG_PATH = os.path.join(OUT, "mech07_feedback.svg")
JSON_PATH = os.path.join(OUT, "mech07_feedback.json")


def trunc_div(v, den=1000):
    """整数定点除法:v/den,向零截断。禁浮点,负数也对称。"""
    return v // den if v >= 0 else -((-v) // den)


def run_loop(gain, injection, turns, x0, delay=0, history=None):
    """回路递推 x_{t+1} = trunc(gain * x_{t-delay} / 1000) + injection。

    history 为 None 时用平坦历史(x_{-delay..0} 全为 x0);给出 history 时用真实
    初始历史。返回长度 turns + 1 的状态序列。
    """
    xs = list(history) if history else [x0]
    for t in range(len(xs) - 1, turns):
        idx = t - delay
        prev = xs[idx] if idx >= 0 else xs[0]
        xs.append(trunc_div(gain * prev) + injection)
    return xs


def fixed_band(gain, injection, lo=0, hi=2000):
    """不动点区间:trunc(gain*x/1000) + injection == x 的 x 集合。整数地板除下是区间。"""
    return [x for x in range(lo, hi) if trunc_div(gain * x) + injection == x]


def band_dist(x, band):
    """x 到不动点**区间**的距离:落在区间内即 0。

    整数地板除下不动点是一个宽度约 1000/(1000-G) 的区间,不是一个点;
    拿区间端点当「不动点」会凭空造出一个永远消不掉的偏离。
    """
    if not band:
        return None
    lo, hi = band[0], band[-1]
    if x < lo:
        return lo - x
    if x > hi:
        return x - hi
    return 0


def ratios(xs):
    """实测逐回合增益(千分比):x_{t+1} * 1000 // x_t。"""
    return [xs[i + 1] * 1000 // xs[i] for i in range(len(xs) - 1)]


def draw(curves, turns, hi, path):
    """三条状态曲线:发散 / 临界 / 收敛。"""
    W, H = 700, 340
    x0, x1, y0, y1 = 70, 640, 40, 270
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, x0, 24, "回路增益三档:状态量 x_t 随回合演化(千分比递推)", 15)
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x1), "y2": str(y1), "stroke": "#64748b"})
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y0), "x2": str(x0), "y2": str(y1), "stroke": "#64748b"})
    for v in range(0, hi + 1, max(1, hi // 5)):
        y = y1 - v * (y1 - y0) // hi
        ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y), "x2": str(x1), "y2": str(y), "stroke": "#1e293b"})
        ashlib.svg_text(root, x0 - 8, y + 4, v, 11, "#94a3b8", "end")
    for t in range(0, turns + 1, 4):
        x = x0 + t * (x1 - x0) // turns
        ashlib.svg_text(root, x, y1 + 18, t, 11, "#94a3b8", "middle")
    for i, (label, _gain, xs, color) in enumerate(curves):
        pts = " ".join(f"{x0 + k * (x1 - x0) // turns},{y1 - min(v, hi) * (y1 - y0) // hi}"
                       for k, v in enumerate(xs))
        ET.SubElement(root, "polyline", {"points": pts, "fill": "none", "stroke": color, "stroke-width": "2"})
        ashlib.svg_text(root, x1 + 6, 60 + 22 * i, label, 11, color)
    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)
    c = spec["params"]["rewardGold"]
    turns, x0 = cfg["turns"], cfg["x0"]
    reg = cfg["regimes"]
    order = [("divergent", reg["divergent"]), ("critical", reg["critical"]), ("convergent", reg["convergent"])]
    series, verdicts = {}, []
    for name, gain in order:
        xs = run_loop(gain, c, turns, x0)
        series[name] = xs
        band = fixed_band(gain, c)
        verdicts.append({
            "regime": name, "gainPermille": gain,
            "x0": xs[0], "xEnd": xs[-1],
            "ratioEndPermille": ratios(xs)[-1],
            "fixedBand": [band[0], band[-1]] if band else None,
            "kind": "divergent" if gain > 1000 else ("critical" if gain == 1000 else "convergent"),
        })
    rows = [[t] + [series[n][t] for n, _ in order] + [ratios(series[n])[t] for n, _ in order]
            for t in range(turns)]
    ashlib.write_csv(CSV_PATH,
                     ["turn", "xDivergent", "xCritical", "xConvergent",
                      "gDivergent", "gCritical", "gConvergent"], rows)
    hi = max(max(v) for v in series.values())
    curves = [("G=1100 发散", 1100, series["divergent"], "#ef4444"),
              ("G=1000 临界", 1000, series["critical"], "#f59e0b"),
              ("G=900 收敛", 900, series["convergent"], "#38bdf8")]
    size = draw(curves, turns, hi, SVG_PATH)
    with open(JSON_PATH, "w", encoding="utf-8") as f:
        json.dump({"id": cfg["id"], "injection": c, "turns": turns, "x0": x0,
                   "regimes": verdicts}, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    for name, gain in order:
        xs = series[name]
        print(f"G={gain}  x={xs}")
        print(f"         ratio={ratios(xs)}")
    for v in verdicts:
        print(f"{v['regime']:<11} gain={v['gainPermille']:>4}‰  xEnd={v['xEnd']:>4}  "
              f"ratioEnd={v['ratioEndPermille']:>4}‰  fixedBand={v['fixedBand']}  kind={v['kind']}")
    print(f"injection c={c} (play_spec.params.rewardGold)  x0={x0}  turns={turns}")
    print(f"svg={size} bytes  elapsed={(time.perf_counter_ns() - t0) // 1000000} ms")
    print("wrote out/mech07_feedback.csv out/mech07_feedback.svg out/mech07_feedback.json")
    ET.parse(SVG_PATH)
    return 0


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

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

G=1100  x=[100, 122, 146, 172, 201, 233, 268, 306, 348, 394, 445, 501, 563, 631, 706, 788, 878]
         ratio=[1220, 1196, 1178, 1168, 1159, 1150, 1141, 1137, 1132, 1129, 1125, 1123, 1120, 1118, 1116, 1114]
G=1000  x=[100, 112, 124, 136, 148, 160, 172, 184, 196, 208, 220, 232, 244, 256, 268, 280, 292]
         ratio=[1120, 1107, 1096, 1088, 1081, 1075, 1069, 1065, 1061, 1057, 1054, 1051, 1049, 1046, 1044, 1042]
G=900  x=[100, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 111, 111, 111, 111, 111, 111]
         ratio=[1020, 1009, 1009, 1009, 1009, 1009, 1009, 1009, 1009, 1009, 1000, 1000, 1000, 1000, 1000, 1000]
divergent   gain=1100‰  xEnd= 878  ratioEnd=1114‰  fixedBand=None  kind=divergent
critical    gain=1000‰  xEnd= 292  ratioEnd=1042‰  fixedBand=None  kind=critical
convergent  gain= 900‰  xEnd= 111  ratioEnd=1000‰  fixedBand=[111, 120]  kind=convergent
injection c=12 (play_spec.params.rewardGold)  x0=100  turns=16
svg=3188 bytes  elapsed=12 ms
wrote out/mech07_feedback.csv out/mech07_feedback.svg out/mech07_feedback.json

读这三行

  • G=1100‰(发散):16 回合从 100 涨到 878,实测比值从 1220‰ 一路降到 1114‰——它逼近的不是 1000,而是 1100。正反馈回路的比值收敛到增益本身。
  • G=1000‰(临界):100 → 292,每回合正好加 12;实测比值 1120‰ → 1042‰ 正在逼近 1000。它既不发散也不停下,而是匀速前进。
  • G=900‰(收敛):100 → 111 后停住,实测比值最后是 1000‰——这恰恰是「已经不动了」的意思,不是临界。存量不再变化时,x_{t+1}/x_t 必然是 1。

最后一条最容易被误读:判据看的是参数 G,不是观测到的比值 g_t。g_t 里混着常数注入 c 的贡献,只有发散档它才收敛到 G;收敛档的 g_t 必然趋于 1000。把 g_t 当判据,会把「已经稳定」读成「正好在临界」。

整数地板除让不动点变成区间

G=900 时不动点不是 120 这个点,而是 [111, 120] 这个区间:trunc(900·111/1000) + 12 = 99 + 12 = 111,trunc(900·120/1000) + 12 = 108 + 12 = 120,区间里每一格都自洽。宽度约为 1000 / (1000 − G):G 越接近 1000,不动带越宽。整数化不制造麻烦,它只是把「不动点」换成了「不动带」——第 11 章讲整数化时还会遇到它。

检查项合格标准
增益可算G 是千分比整数,判据是 G 与 1000‰ 的比较,不是形容词
三档都真跑发散 / 临界 / 收敛各跑 16 回合,落 CSV 与纯 xml.etree SVG
判据与观测分开用参数 G 判定,不用实测比值 g_t 判定
不动点是区间整数地板除下如实报 [111, 120],不假装它是 120

参数决定了「会不会散」,但真实游戏里的反馈几乎从不当回合生效。下一节把同一份增益加上 d 回合延迟,看它会变成什么样。

阅读导航:上一节:6.3 稳健性与告警 · 下一节:7.2 反馈延迟与回路增益 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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