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 反馈延迟与回路增益 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。