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) 回合跳一次的阶梯 |
| 稳态偏差 | 0 | 0(落在不动带内) |
| 首轮响应 | 立刻 | 要等 d 回合才动 |
延迟还解释了 6.3 那张脆弱表里的一类现象:参数动一格、胜率突然塌掉——很多时候不是数值本身跳了,而是回路在有限回合的窗口里刚好从「来得及」滑到「来不及」。这个边界下一节会算出来。
一条纪律收尾:d 是要标定的设计参数,不是实现的偶然。同一份 G,把 d 从 0 调到 2,稳定所需回合就从 6 涨到 16;这个差值在 20 回合的对局里足以决定成败。把 d 写成规格字段,才有资格谈「这条回路稳不稳」。
增益告诉你回路会不会散,延迟告诉你要等多久才看得出来。下一节把这两件事放到同一张参数平面上,问「参数放在哪里才安全」。
阅读导航:上一节:7.1 正反馈与负反馈 · 下一节:7.3 循环的稳定性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。