7.3 循环的稳定性
6.3 的稳健性是别的参数(enemyHp)动一格、胜率动多少;本节问的是回路自己稳不稳。《小游戏开发实战》卷一 6.2
与 独立游戏平衡与经济设计
都不给这条判据——前者量时长,后者讲概念;本节把它写成 (增益, 延迟) 平面上的稳定域。
两张判据:渐近的与有限时域的
7.1 给的判据是渐近的:只要 G < 1000‰,跑够多回合总会收敛;G > 1000‰ 总会发散。7.2 又证明了延迟不改变这个判据。那稳定性还有什么可算的?答案是游戏不给无限回合——play_spec.params.turnLimit = 20,20 回合内没稳下来,和发散在体感上没有区别。于是有两张表:
| 判据 | 只看什么 | 结论 |
|---|---|---|
| 渐近稳定 | 增益 G 与 1000‰ | G < 1000 收敛、= 1000 临界、> 1000 发散(与 d 无关) |
| 有限时域稳定 | 增益 G 与 延迟 d | 20 回合内偏离衰减到一半,才叫稳 |
第二张表才是设计者要的:它不是「会不会散」,而是「这局之内来不来得及」。
S 层:扫 (G, d) 平面
#!/usr/bin/env python3
# file: sim/gd_mech_07_stability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_07_stability.py
"""卷一 7.3 循环的稳定性(S 层):在 (增益 G, 延迟 d) 平面上扫稳定域。
渐近判据还是那一条:G < 1000 收敛 / G = 1000 临界 / G > 1000 发散——**延迟不改变它**。
但游戏只有有限回合:play_spec 的 params.turnLimit = 20。于是真正要问的是
「**在 20 回合内**,偏离能不能衰减到 e0 的四分之一」。
本脚本把两张表都跑出来:渐近判据(与 d 无关)与有限时域判据(与 d 强相关)。
产出 out/mech07_stability.csv、out/mech07_stability.svg、out/mech07_stability.json。
不用浮点、不用 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 # noqa: E402
CSV_PATH = os.path.join(OUT, "mech07_stability.csv")
SVG_PATH = os.path.join(OUT, "mech07_stability.svg")
JSON_PATH = os.path.join(OUT, "mech07_stability.json")
def settle(xs, band, e0, num, den):
"""偏离衰减到 e0*num/den 之后再没反弹的回合数。0 表示一开始就在带内。"""
thr = e0 * num // den
last = -1
for t, x in enumerate(xs):
if band_dist(x, band) > thr:
last = t
return last + 1
def asymptotic(gain):
return "unstable" if gain > 1000 else ("critical" if gain == 1000 else "stable")
def cell(gain, delay, c, x0, turns, num, den):
xs = run_loop(gain, c, turns, x0, delay=delay)
band = fixed_band(gain, c)
e0 = band_dist(x0, band)
st = settle(xs, band, e0, num, den) if band else None
kind = asymptotic(gain)
horizon = "pass" if (kind == "stable" and st is not None and st <= turns) else (
"na" if kind != "stable" else "miss")
return {"gain": gain, "delay": delay, "band": [band[0], band[-1]] if band else None,
"e0": e0, "settle": st, "asymptotic": kind, "horizon": horizon, "xEnd": xs[-1]}
def draw(cells, gains, delays, turns, path):
"""稳定域热力图:绿 = 20 回合内稳定,红 = 未达,灰 = 渐近发散。"""
W, H = 660, 320
cw, ch = 52, 44
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 70, 24, f"有限时域稳定域:{turns} 回合内偏离衰减到 1/4", 15)
for j, d in enumerate(delays):
ashlib.svg_text(root, 60, 70 + j * ch + 28, f"d={d}", 12, "#94a3b8", "end")
for i, g in enumerate(gains):
ashlib.svg_text(root, 78 + i * cw + cw // 2, 56, g, 11, "#94a3b8", "middle")
for cl in cells:
i, j = gains.index(cl["gain"]), delays.index(cl["delay"])
fill = {"pass": "#22c55e", "miss": "#ef4444", "na": "#475569"}[cl["horizon"]]
ET.SubElement(root, "rect", {"x": str(78 + i * cw), "y": str(70 + j * ch),
"width": str(cw - 6), "height": str(ch - 6),
"fill": fill, "data-settle": str(cl["settle"])})
if cl["settle"] is not None:
ashlib.svg_text(root, 78 + i * cw + (cw - 6) // 2, 70 + j * ch + 26,
cl["settle"], 11, "#0f172a", "middle")
ashlib.svg_text(root, 78, 300, "横轴 = 回路增益 G(千分比);纵轴 = 反馈延迟 d(回合);格内数字 = 稳定所需回合", 11)
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"]
st = cfg["stability"]
gains, delays = st["gains"], st["delays"]
turns = spec["params"]["turnLimit"] # 20:有限时域锚定规格,不是随手取的
num, den = st["shrinkNum"], st["shrinkDen"]
cells = [cell(g, d, c, cfg["x0"], turns, num, den) for d in delays for g in gains]
ashlib.write_csv(CSV_PATH, ["gain", "delay", "bandLo", "bandHi", "e0", "settle",
"asymptotic", "horizon", "xEnd"],
[[cl["gain"], cl["delay"], cl["band"][0] if cl["band"] else "",
cl["band"][1] if cl["band"] else "", cl["e0"], cl["settle"],
cl["asymptotic"], cl["horizon"], cl["xEnd"]] for cl in cells])
size = draw(cells, gains, delays, turns, SVG_PATH)
with open(JSON_PATH, "w", encoding="utf-8") as f:
json.dump({"id": "gd_mech_07_stability", "tier": "S", "injection": c, "x0": cfg["x0"],
"horizonTurns": turns, "shrink": [num, den], "cells": cells},
f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
for d in delays:
row = [cl for cl in cells if cl["delay"] == d]
print(f"d={d} " + " ".join(f"G={cl['gain']}:{cl['horizon']}"
f"({cl['settle'] if cl['settle'] is not None else '-'})" for cl in row))
passed = [cl for cl in cells if cl["horizon"] == "pass"]
print(f"horizon={turns} turns (play_spec.params.turnLimit) shrink={num}/{den} "
f"pass={len(passed)}/{len(cells)} injection c={c} x0={cfg['x0']}")
for d in delays:
gs = sorted(cl["gain"] for cl in cells if cl["delay"] == d and cl["horizon"] == "pass")
print(f"d={d} 有限时域稳定域 G <= {max(gs) if gs else 'none'}")
print(f"svg={size} bytes elapsed={(time.perf_counter_ns() - t0) // 1000000} ms")
print("wrote out/mech07_stability.csv out/mech07_stability.svg out/mech07_stability.json")
ET.parse(SVG_PATH)
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_07_stability.py(pass(n) = n 回合稳定):
d=0 G=700:pass(2) G=800:pass(3) G=850:pass(4) G=900:pass(5) G=930:pass(8) G=950:pass(12) G=970:miss(21) G=990:miss(21) G=1000:na(-) G=1050:na(-)
d=1 G=700:pass(3) G=800:pass(5) G=850:pass(7) G=900:pass(9) G=930:pass(15) G=950:miss(21) G=970:miss(21) G=990:miss(21) G=1000:na(-) G=1050:na(-)
d=2 G=700:pass(4) G=800:pass(7) G=850:pass(10) G=900:pass(13) G=930:miss(21) G=950:miss(21) G=970:miss(21) G=990:miss(21) G=1000:na(-) G=1050:na(-)
d=4 G=700:pass(6) G=800:pass(11) G=850:pass(16) G=900:miss(21) G=930:miss(21) G=950:miss(21) G=970:miss(21) G=990:miss(21) G=1000:na(-) G=1050:na(-)
horizon=20 turns (play_spec.params.turnLimit) shrink=1/2 pass=18/40 injection c=12 x0=100
d=0 有限时域稳定域 G <= 950
d=1 有限时域稳定域 G <= 930
d=2 有限时域稳定域 G <= 900
d=4 有限时域稳定域 G <= 850
svg=9401 bytes elapsed=14 ms
wrote out/mech07_stability.csv out/mech07_stability.svg out/mech07_stability.json
四行数字把两件事分得很干净:渐近判据与延迟无关(每一行的 G=1000 / G=1050 都标 na,G>1000 一律发散,不管 d 是几),有限时域判据与延迟强相关(稳定边界从 d=0 的 G≤950‰ 收缩到 d=1 的 ≤930‰、d=2 的 ≤900‰、d=4 的 ≤850‰)。延迟没有让任何一条回路从「收敛」变成「发散」,它只是把收敛推到了 20 回合之外。
| d | 有限时域稳定域 | 读法 |
|---|---|---|
| 0 | G ≤ 950‰ | 无延迟时还能吃下 950,离临界只剩 50‰ |
| 1 | G ≤ 930‰ | 多一回合延迟,边界就掉 20‰ |
| 2 | G ≤ 900‰ | 边界回到 7.1 那个收敛档 |
| 4 | G ≤ 850‰ | 四回合延迟把余量吃掉一半 |
B 层:把设计点放进扰动网格
既然稳定域是拿一个设计点定的,就该问「标定错了 ±5% 还稳吗」。取 G=930‰、d=1 作设计点,把增益缩放 90%~110% 重跑:
#!/usr/bin/env python3
# file: sim/gd_mech_07_gain_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_mech_07_gain_bench.py
"""卷一 7.3 回路稳健性基准(B 层):把**设计点**放进扰动网格。
7.3 的稳定域是拿一个设计点(G=930‰、d=1)定的。真正该问的是:增益标定错了 ±5%/±10%、
或者延迟因为一次网络/动画改动多了一回合,这个设计点还稳吗?
复用 sim/gd_mech_07_stability.py 的 cell / draw 判据,不重写扫描逻辑。
**永远 exit 0**:B 档软告警不硬失败——失稳只打印 SOFT,不抛异常、不返回非零。
产出 out/mech07_gain_warnings.json。
"""
import json
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
import gd_mech_07_stability as stab # noqa: E402
JSON_PATH = os.path.join(OUT, "mech07_gain_warnings.json")
BASE_GAIN, BASE_DELAY = 930, 1
def main():
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(stab.CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
c = spec["params"]["rewardGold"]
turns = spec["params"]["turnLimit"]
num, den = cfg["stability"]["shrinkNum"], cfg["stability"]["shrinkDen"]
scales = cfg["perturb"]["gainScales"]
delays = cfg["perturb"]["delays"]
cells = []
for sc in scales:
gain = BASE_GAIN * sc // 100
for d in delays:
cl = stab.cell(gain, d, c, cfg["x0"], turns, num, den)
cl["scale"] = sc
cells.append(cl)
base = next(cl for cl in cells if cl["scale"] == 100 and cl["delay"] == BASE_DELAY)
lost = [cl for cl in cells if cl["horizon"] != "pass"]
by_delay = {}
for d in delays:
row = [cl for cl in cells if cl["delay"] == d]
by_delay[d] = {"pass": sum(1 for cl in row if cl["horizon"] == "pass"), "total": len(row)}
warnings = [{
"code": "DESIGN_POINT_LOST", "severity": "soft",
"gain": cl["gain"], "delay": cl["delay"], "scale": cl["scale"],
"horizon": cl["horizon"],
"message": f"增益扰动到 {cl['scale']}%(G={cl['gain']}‰)、延迟 {cl['delay']} 回合时,"
f"设计点在 {turns} 回合内不再稳定({cl['horizon']})",
} for cl in lost]
base_lost = base["horizon"] != "pass"
margin = turns - base["settle"] if base["settle"] is not None else 0
warnings.append({
"code": "BASELINE_MARGIN", "severity": "soft",
"settle": base["settle"], "horizon": turns, "margin": margin,
"message": f"基准设计点 G={BASE_GAIN}‰ d={BASE_DELAY} 需 {base['settle']} 回合稳定,"
f"距 {turns} 回合的上限只剩 {margin} 回合余量",
})
payload = {
"id": "gd_mech_07_gain_warnings", "tier": "B", "hardFail": False,
"note": "B 档软告警:只提示风险,不使任何构建失败",
"baseGain": BASE_GAIN, "baseDelay": BASE_DELAY, "horizonTurns": turns,
"baseStable": not base_lost, "stableCells": len(cells) - len(lost), "totalCells": len(cells),
"byDelay": {str(k): v for k, v in by_delay.items()},
"warnings": warnings,
}
with open(JSON_PATH, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
for sc in scales:
row = [cl for cl in cells if cl["scale"] == sc]
npass = sum(1 for cl in row if cl["horizon"] == "pass")
print(f"scale={sc:>3}% G={row[0]['gain']:>4}‰ pass={npass}/{len(row)} "
f"settle={[cl['settle'] if cl['settle'] is not None else '-' for cl in row]}")
for w in warnings:
print(f"SOFT WARN {w['code']}: {w['message']}")
print(f"baseStable={not base_lost} stableCells={len(cells) - len(lost)}/{len(cells)} "
f"byDelay={by_delay} hardFail=false")
print(f"SOFT: hardFail=false(B 档软告警不硬失败) elapsed={(time.perf_counter_ns() - t0) // 1000000} ms")
print("wrote out/mech07_gain_warnings.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_07_gain_bench.py(告警段截前四条):
scale= 90% G= 837‰ pass=4/4 settle=[4, 7, 10, 16]
scale= 95% G= 883‰ pass=4/4 settle=[0, 0, 0, 0]
scale=100% G= 930‰ pass=2/4 settle=[8, 15, 21, 21]
scale=105% G= 976‰ pass=0/4 settle=[21, 21, 21, 21]
scale=110% G=1023‰ pass=0/4 settle=['-', '-', '-', '-']
SOFT WARN DESIGN_POINT_LOST: 增益扰动到 100%(G=930‰)、延迟 4 回合时,设计点在 20 回合内不再稳定(miss)
SOFT WARN DESIGN_POINT_LOST: 增益扰动到 105%(G=976‰)、延迟 0 回合时,设计点在 20 回合内不再稳定(miss)
SOFT WARN DESIGN_POINT_LOST: 增益扰动到 105%(G=976‰)、延迟 1 回合时,设计点在 20 回合内不再稳定(miss)
SOFT WARN DESIGN_POINT_LOST: 增益扰动到 105%(G=976‰)、延迟 2 回合时,设计点在 20 回合内不再稳定(miss)
baseStable=True stableCells=10/20 byDelay={0: {'pass': 3, 'total': 5}, 1: {'pass': 3, 'total': 5}, 2: {'pass': 2, 'total': 5}, 4: {'pass': 2, 'total': 5}} hardFail=false
SOFT: hardFail=false(B 档软告警不硬失败) elapsed=5 ms
wrote out/mech07_gain_warnings.json
结果分三档,都值得记下来:增益往下偏是安全的(90% 时四档延迟全部稳定);往上偏一格就危险(105% 时四档全灭,因为 G=976‰ 离 1000‰ 只剩 24‰);110% 直接跨过临界(G=1023‰ 标 na,渐近发散,再怎么跑也不会稳)。而基准点本身只剩 5 回合余量——BASELINE_MARGIN 那条软告警说的就是这件事。
三条纪律在这节落定:判据分两层(渐近的看 G,有限时域的看 G 与 d);延迟侵蚀的是余量而不是稳定域;告警只报风险、不阻断构建(hardFail: false,脚本永远 exit 0)。
| 检查项 | 合格标准 |
|---|---|
| 判据分层 | 渐近稳定与有限时域稳定分开写,不混成一句 |
| 延迟归因正确 | 说「延迟推后收敛」,不说「延迟导致发散」 |
| 稳定域有边界 | 每个 d 给出 G 的上界,不是只给一张热力图 |
| 扰动不硬失败 | 失稳只写 severity: soft,脚本 exit 0 |
第 7 章把回路从「滚雪球」这个词变成了一条递推、一个增益、一个延迟和一张稳定域。但回路只说了「领先会不会滚大」,没说玩家什么时候拿到奖励、多久给一次——那是下一章的事:奖励节奏谱。
阅读导航:上一节:7.2 反馈延迟与回路增益 · 下一节:8.1 奖励节奏谱 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。