7.2 反馈延迟与回路增益

延迟不改增益,只把递推的读指针往后挪 d 回合:x_{t+1} = trunc(G·x_{t-d}/1000) + c。本机实跑同一份 G=900‰:d=0 单调逼近(方向反转 0 次、6 回合稳定),d=1/2/4 变成振荡逼近(反转 18/12/7 次、稳定要 10/16/21 回合);发散档同理,延迟把爆发拆成阶梯。

7.2 反馈延迟与回路增益

《小游戏开发实战》卷一 6.2 循环时长与反馈 关心的是一圈循环多久转完;独立游戏平衡与经济设计 把反馈写成概念,不区分「反馈什么时候到达」。本节问的是后者:同一条回路,反馈晚 d 回合到达,会变成什么样。答案不是「更慢」这么含糊——增益决定是否收敛,延迟决定怎么收敛。

延迟只挪读指针,不动增益

延迟在递推里只改一处:增益读到的是 d 回合前的状态。

x_{t+1} = trunc(G · x_{t-d} / 1000) + c

d = 0 就是上一节的回路。G、c、x 全都沿用,唯一新增的参数是 d:

记号含义本节取值
d反馈延迟(回合)0 / 1 / 2 / 4
G回路增益(千分比)900(收敛档)与 1100(发散档)
初始历史前 d+1 个回合的真实存量[60, 66, 72],故意不平

初始历史故意不平:真实对局的前几回合本来就不会停在一个数上,而平坦历史会把延迟最重要的效果掩盖掉——三档都从同一个数出发时,振荡根本不会显形。

延迟在游戏里从哪来

延迟不是实现缺陷,它是结算顺序的固有属性。把 d 显式写进规格,比把它藏在代码里某次 await 后面诚实得多:

来源典型 d表现
结算顺序1本回合的收益下一回合才入账
动画与演出1–2伤害数字播完才更新血条与掉落
匹配与对手选择2–4本局连胜要过几局才反映到对手强度上
赛季与版本调整很多平衡性改动要到下个赛季才生效

延迟只挪一个读指针

递推里被延迟改动的地方只有一处——读的是哪一个回合的存量:

# snippet: 延迟递推的核心——只有读指针被挪后 d 格
def run_loop(gain, injection, turns, x0, delay=0, history=None):
    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

S 层:同一增益,四种延迟

#!/usr/bin/env python3
# file: sim/gd_mech_07_delay.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_07_delay.py
"""卷一 7.2 反馈延迟与回路增益(S 层):同一增益,加上 d 回合延迟。

延迟只改递推的**读指针**,不改增益:

    x_{t+1} = trunc(G * x_{t-d} / 1000) + c        (d = 0 即 7.1 的无延迟回路)

结论(本机实跑):增益 G 决定**是否**收敛,延迟 d 决定**怎么**收敛。
无延迟(d=0)是单调逼近;有延迟(d>=1)是振荡逼近——状态交替超调,靠 (d+1) 个回合
一轮地往不动点走。发散档同理:延迟让爆发变成阶梯,每 (d+1) 回合跳一次。

产出 out/mech07_delay.csv 与 out/mech07_delay.svg。不用浮点、不用 random 模块。
"""
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
from gd_mech_07_feedback import run_loop, trunc_div, fixed_band, band_dist, ratios  # noqa: E402

CSV_PATH = os.path.join(OUT, "mech07_delay.csv")
SVG_PATH = os.path.join(OUT, "mech07_delay.svg")
THRESHOLD = 200  # 发散档「到 200 需要几回合」的阈值


def reversals(xs):
    """方向反转次数:Δ 的符号变了就算一次。单调逼近 = 0,振荡逼近 > 0。"""
    d = [b - a for a, b in zip(xs, xs[1:]) if b != a]
    return sum(1 for a, b in zip(d, d[1:]) if (a > 0) != (b > 0))


def settle(xs, band, e0):
    """收敛到「偏离不超过一半」之后再没反弹的回合数。0 表示一开始就在带内。"""
    half = e0 // 2
    last = -1
    for t, x in enumerate(xs):
        if band_dist(x, band) > half:
            last = t
    return last + 1


def draw(curves, turns, hi, lo, path):
    """收敛档四条延迟曲线:d=0 单调,d>=1 振荡。"""
    W, H = 700, 340
    x0, x1, y0, y1 = 70, 640, 40, 270
    span = hi - lo if hi > lo else 1
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, x0, 24, "同一增益 G=900‰,延迟 d=0/1/2/4 的收敛形态", 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 t in range(0, turns + 1, 4):
        ashlib.svg_text(root, x0 + t * (x1 - x0) // turns, y1 + 18, t, 11, "#94a3b8", "middle")
    colors = ["#38bdf8", "#f59e0b", "#a78bfa", "#ef4444"]
    for i, (label, xs) in enumerate(curves):
        pts = " ".join(f"{x0 + k * (x1 - x0) // turns},"
                       f"{y1 - (min(max(v, lo), hi) - lo) * (y1 - y0) // span}"
                       for k, v in enumerate(xs))
        ET.SubElement(root, "polyline", {"points": pts, "fill": "none", "stroke": colors[i], "stroke-width": "2"})
        ashlib.svg_text(root, x1 + 6, 70 + 22 * i, label, 11, colors[i])
    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 = cfg["delay"]["turns"]
    hist = cfg["delay"]["history"]
    delays = cfg["delay"]["delays"]
    series = {}
    for gain in cfg["delay"]["gains"]:
        for d in delays:
            series[(gain, d)] = run_loop(gain, c, turns, cfg["x0"], delay=d, history=hist)
    band = fixed_band(900, c)
    e0 = band_dist(hist[0], band)
    conv = []
    for d in delays:
        xs = series[(900, d)]
        rev = reversals(xs)
        conv.append({"delay": d, "settle": settle(xs, band, e0), "reversals": rev,
                     "xEnd": xs[-1], "shape": "monotone" if rev == 0 else "oscillatory"})
    div = []
    for d in delays:
        xs = series[(1100, d)]
        reach = next((t for t, x in enumerate(xs) if x >= THRESHOLD), None)
        div.append({"delay": d, "reachTurns": reach, "xEnd": xs[-1]})
    rows = [[t] + [series[(900, d)][t] for d in delays] + [series[(1100, d)][t] for d in delays]
            for t in range(turns + 1)]
    ashlib.write_csv(CSV_PATH,
                     ["turn"] + [f"convD{d}" for d in delays] + [f"divD{d}" for d in delays], rows)
    curves = [(f"d={d}", series[(900, d)]) for d in delays]
    size = draw(curves, turns, max(max(series[(900, d)]) for d in delays), min(hist) - 2, SVG_PATH)
    for d in delays:
        print(f"G=900 d={d}  x={series[(900, d)]}")
    for d in delays:
        print(f"G=1100 d={d}  x={series[(1100, d)]}")
    for r in conv:
        print(f"convergent d={r['delay']}  settle={r['settle']}  reversals={r['reversals']}  "
              f"xEnd={r['xEnd']}  shape={r['shape']}")
    for r in div:
        print(f"divergent  d={r['delay']}  reach{THRESHOLD}={r['reachTurns']}  xEnd={r['xEnd']}")
    print(f"fixedBand G=900 -> [{band[0]}, {band[-1]}]  e0={e0}  history={hist}  c={c}  turns={turns}")
    print(f"svg={size} bytes  elapsed={(time.perf_counter_ns() - t0) // 1000000} ms")
    print("wrote out/mech07_delay.csv out/mech07_delay.svg")
    ET.parse(SVG_PATH)
    return 0


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

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

G=900 d=0  x=[60, 66, 72, 76, 80, 84, 87, 90, 93, 95, 97, 99, 101, 102, 103, 104, 105, 106, 107, 108, 109]
G=900 d=1  x=[60, 66, 72, 71, 76, 75, 80, 79, 84, 83, 87, 86, 90, 89, 93, 92, 95, 94, 97, 96, 99]
G=900 d=2  x=[60, 66, 72, 66, 71, 76, 71, 75, 80, 75, 79, 84, 79, 83, 87, 83, 86, 90, 86, 89, 93]
G=900 d=4  x=[60, 66, 72, 66, 66, 66, 71, 76, 71, 71, 71, 75, 80, 75, 75, 75, 79, 84, 79, 79, 79]
G=1100 d=0  x=[60, 66, 72, 91, 112, 135, 160, 188, 218, 251, 288, 328, 372, 421, 475, 534, 599, 670, 749, 835, 930]
G=1100 d=1  x=[60, 66, 72, 84, 91, 104, 112, 126, 135, 150, 160, 177, 188, 206, 218, 238, 251, 273, 288, 312, 328]
G=1100 d=2  x=[60, 66, 72, 78, 84, 91, 97, 104, 112, 118, 126, 135, 141, 150, 160, 167, 177, 188, 195, 206, 218]
G=1100 d=4  x=[60, 66, 72, 78, 78, 78, 84, 91, 97, 97, 97, 104, 112, 118, 118, 118, 126, 135, 141, 141, 141]
convergent d=0  settle=6  reversals=0  xEnd=109  shape=monotone
convergent d=1  settle=10  reversals=18  xEnd=99  shape=oscillatory
convergent d=2  settle=16  reversals=12  xEnd=93  shape=oscillatory
convergent d=4  settle=21  reversals=7  xEnd=79  shape=oscillatory
divergent  d=0  reach200=8  xEnd=930
divergent  d=1  reach200=13  xEnd=328
divergent  d=2  reach200=19  xEnd=218
divergent  d=4  reach200=None  xEnd=141
fixedBand G=900 -> [111, 120]  e0=51  history=[60, 66, 72]  c=12  turns=20
svg=2626 bytes  elapsed=3 ms
wrote out/mech07_delay.csv out/mech07_delay.svg

读这段输出

  • 收敛档的形态分叉:d=0 是 60,66,72,76,80,84,87,... 一路上行,方向反转 0 次;d=1 变成 60,66,72,71,76,75,80,...,走三步退一步。反转次数 d=1 是 18 次,d=2 是 12 次,d=4 是 7 次——延迟越大,摆动的周期越长、摆幅越大。
  • 收敛变慢:同样衰减到偏离的一半,d=0 只要 6 回合,d=1 要 10 回合,d=2 要 16 回合,d=4 在 20 回合内都没做到。增益一点没变,到位的回合数却按 (d+1) 倍拉长。
  • 发散档被拆成阶梯:d=0 时存量第 8 回合就冲过 200;d=1 要 13 回合,d=2 要 19 回合,d=4 在 20 回合内根本没到。延迟没有阻止发散,只是把它摊成了每 (d+1) 回合跳一次的台阶。

也可以把延迟折成「每回合的有效增益」来看:状态每 (d+1) 回合才更新一次,同一份 G 摊到每一回合,相当于几何平均 G^(1/(d+1))。整数口径下不开方,改用稳定回合数度量更干净——7.3 扫稳定域时用的就是这个量。

所以「延迟」在这条回路上是中性的:它不改变 G,也不改变 G 与 1000‰ 的比较结果。它改变的是到达的方式——单调逼近换成振荡逼近,快跳换成阶梯。在只有 20 回合的对局里,「换了个方式」和「来不及」常常是同一件事。

还有一个容易忽略的推论:延迟把增益的作用半径拉长了。G=1100‰ 在 d=0 时每回合乘 1.1,两回合就攒到 1.21 倍;d=4 时同样的两回合可能一次都没乘。短期看延迟像在帮忙,但只要 G 仍大于 1000‰,早晚会补上那几跳——延迟推迟发散,从不取消发散。

换句话说,延迟在这里是时间上的放大器而非阻尼器:它放大的不是增益,而是「到达时间」的差异。

现象无延迟 d=0有延迟 d>=1
是否收敛由 G 决定同样由 G 决定
收敛形态单调逼近振荡逼近(方向反复反转)
稳定所需回合最快按 (d+1) 倍拉长
发散形态指数上扬每 (d+1) 回合跳一次的阶梯
稳态偏差00(落在不动带内)
首轮响应立刻要等 d 回合才动

延迟还解释了 6.3 那张脆弱表里的一类现象:参数动一格、胜率突然塌掉——很多时候不是数值本身跳了,而是回路在有限回合的窗口里刚好从「来得及」滑到「来不及」。这个边界下一节会算出来。

一条纪律收尾:d 是要标定的设计参数,不是实现的偶然。同一份 G,把 d 从 0 调到 2,稳定所需回合就从 6 涨到 16;这个差值在 20 回合的对局里足以决定成败。把 d 写成规格字段,才有资格谈「这条回路稳不稳」。

增益告诉你回路会不会散,延迟告诉你要等多久才看得出来。下一节把这两件事放到同一张参数平面上,问「参数放在哪里才安全」。

阅读导航:上一节:7.1 正反馈与负反馈 · 下一节:7.3 循环的稳定性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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