10.2 心流带宽的可计算化
《小游戏开发实战》卷一 7.2 难度曲线与心流 把心流画成一条曲线,读的是一个点在时间轴上的高度与斜率;上一节又把匹配画成一张分类图,读的是每个技巧档一段挑战区间。这一节要做的是把那一段的两端变成可复现的整数:带宽的上沿与下沿各是多少、宽度是多少、技巧变一档带宽移动多少、相邻档之间共享多少挑战值。这些都是「点模型」给不出的量——点没有宽度,也就没有位移和重叠。本节先把带宽定义成两条边,再用粗扫 + 整数二分把边钉到 ±1。
带宽的定义:两条边夹出的整数区间
带宽的定义必须建立在一条可验证的性质上:胜率随挑战单调不增。挑战越高,赢的概率越低——这在整数机制里是自然的(敌人攻击更狠,玩家更早出局)。有了单调性,带宽就由两条边唯一确定:
| 边 | 定义 | 越过它会怎样 |
|---|---|---|
上沿 up | 最小的使 win ≤ easy 的挑战值 | 再往低就「太易」(胜率过高) |
下沿 dn | 最大的使 win ≥ hard 的挑战值 | 再往高就「太难」(胜率过低) |
| 带宽 | [up, dn],宽度 dn − up + 1 | 挑战落在这段里,代理读数就在窗口内 |
easy 与 hard 是窗口的两个阈值,本节取 900‰ 与 250‰(与 10.1 的三分类一致)。这两个阈值本身是设计选择,不是自然常数:把窗口放宽,带宽就变宽;收窄,带宽就变窄。所以「带宽有多宽」这个结论必须连同窗口一起报,单独说「带宽 = 6」是不完整的。
和 10.1 一样,这里的一切读数都是代理指标(proxy)。胜率是「这一套策略在这 16 个种子上赢了多少局」的观测值,不是玩家体验。带宽只用来做相对比较:b=2 的带宽比 b=1 宽、b=3 的中心比 b=2 靠右——这些相对结论可以引用;「带宽 6 说明玩家有 6 个难度档可玩」这种绝对化读法不可以。
粗扫 + 二分:把边钉到 ±1
如果像 10.1 那样逐格扫(步长 1),带宽的边天然是整数,但代价是每档要跑 25 到 29 个格子。换个做法:先用粗扫(步长 4)把每条边夹在哪个粗格之间定位出来,再在那一小段上做整数二分。二分每次把区间折半,maxRounds=8 足够把 8 宽的区间折到 1。
这套做法的前提还是单调性:只有「胜率随挑战单调不增」,二分的方向才唯一——win(mid) > easy 说明上沿还在 mid 右边,否则在 mid 左边(或就是 mid)。下沿同理,判据是 win(mid) ≥ hard。
S 层:钉边与带宽表
S 层脚本 sim/gd_play_10_band.py 对 5 个技巧档各做一次「粗扫 + 两条边的二分」,报出带宽、中心、与前一档的位移、宽度变化与重叠格子数。全程整数、无浮点、无 random;带宽用 ashlib 的 svg_begin/svg_text/svg_save 加自写的 <rect> 画成纯 xml.etree SVG,并写出 CSV。
{
"id": "gd_play_10_band",
"note": "卷二 10.2 心流带宽:对每个技巧档先粗扫(步长 4)定位带宽上下沿所在区间,再用整数二分把上下沿各钉到 ±1;报带宽、中心、相邻技巧档的带宽重叠与位移",
"seedPool": [701, 703, 705, 707, 709, 711, 713, 715, 717, 719, 721, 723, 725, 727, 729, 731],
"cellsPerScan": 16,
"fixed": { "enemyHp": 60 },
"skillLevels": [1, 2, 3, 4, 5],
"challenge": { "param": "enemyAttack", "from": 6, "to": 34, "step": 4 },
"window": { "easyPermille": 900, "hardPermille": 250 },
"bisect": { "maxRounds": 8 },
"turnBudget": 26
}
#!/usr/bin/env python3
# file: sim/gd_play_10_band.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_10_band.py
"""卷二 10.2 心流带宽的可计算化(S 层)——把带宽的上下沿各钉到一个整数。
带宽 = 挑战轴上使代理胜率落在 [hard, easy] 千分比内的**连续整数区间**。胜率随挑战单调
不增,所以带宽由两条边确定:上沿 = 最小的使 win <= easy 的挑战值(再往低就「太易」),
下沿 = 最大的使 win >= hard 的挑战值(再往高就「太难」)。本脚本对每个技巧档先粗扫
(步长 4)定位两条边各自落在哪个粗格之间,再在区间上做整数二分把边钉到 ±1。最后报每个
技巧档的带宽、中心、以及与前一档的位移与重叠。全程整数、无浮点、无 random;胜率是代理
指标,只做相对比较。产出 out/play10_band.json + out/play10_band.csv + out/play10_band.svg。
"""
import copy
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_play_10_band.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def seq(budget: int, turns: int) -> list:
return (["p0"] * budget + ["e"]) * turns
def win_permille(spec, over, budget, seeds, turns):
wins = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update(over)
wins += 1 if ashlib.run_spec(s, seq(budget, turns))["won"] else 0
return 1000 * wins // len(seeds)
def edges(spec, fixed, param, b, seeds, turns, lo, hi, easy, hard, max_rounds):
"""粗扫 + 二分:上沿 = 最小 win<=easy 的值;下沿 = 最大 win>=hard 的值。"""
def w(v):
return win_permille(spec, {**fixed, param: v}, b, seeds, turns)
coarse = list(range(lo, hi + 1, 4))
if coarse[-1] != hi:
coarse.append(hi)
vals = {v: w(v) for v in coarse}
up, dn, rounds = None, None, 0
for i in range(len(coarse) - 1):
a0, c0 = coarse[i], coarse[i + 1]
if up is None and vals[a0] > easy >= vals[c0]: # 上沿夹在 (a0, c0]
a, c = a0, c0
while c - a > 1 and rounds < max_rounds:
m = (a + c) // 2
rounds += 1
a, c = (m, c) if w(m) > easy else (a, m)
up = c
if dn is None and vals[a0] >= hard > vals[c0]: # 下沿夹在 [a0, c0)
a, c = a0, c0
while c - a > 1 and rounds < max_rounds:
m = (a + c) // 2
rounds += 1
a, c = (a, m) if w(m) < hard else (m, c)
dn = a
return up, dn, vals, rounds
def draw(skills, rows, lo, hi, easy, hard, path):
W, H, x0, y0, rh = 720, 300, 90, 56, 38
span = hi - lo + 1
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 16, 30, "心流带宽:每个技巧档在挑战轴上的带宽区间", 15)
ashlib.svg_text(root, 16, 48, f"绿条 = 带宽内胜率 [{hard},{easy}]‰,越靠右 = 挑战越高", 12, "#94a3b8")
for i, b in enumerate(skills):
r = rows[i]
y = y0 + i * rh
ashlib.svg_text(root, 80, y + 16, f"b={b}", 13, anchor="end")
ET.SubElement(root, "rect", {"x": str(x0), "y": str(y), "width": str(W - x0 - 40),
"height": "22", "fill": "#1e293b"})
if r["bandLo"] is not None:
bx = x0 + (r["bandLo"] - lo) * (W - x0 - 40) // span
bw = (r["bandHi"] - r["bandLo"] + 1) * (W - x0 - 40) // span
ET.SubElement(root, "rect", {"x": str(bx), "y": str(y), "width": str(max(2, bw)),
"height": "22", "fill": "#22c55e", "data-lo": str(r["bandLo"]),
"data-hi": str(r["bandHi"])})
ashlib.svg_text(root, bx + bw + 6, y + 16, f"{r['bandLo']}..{r['bandHi']}", 12, "#22c55e")
ashlib.svg_text(root, x0, y0 + len(skills) * rh + 18, f"challenge {lo}", 11, "#94a3b8")
ashlib.svg_text(root, W - 40, y0 + len(skills) * rh + 18, f"{hi}", 11, "#94a3b8", "end")
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)
seeds, skills = cfg["seedPool"][: cfg["cellsPerScan"]], cfg["skillLevels"]
ch = cfg["challenge"]
lo, hi = ch["from"], ch["to"]
easy, hard = cfg["window"]["easyPermille"], cfg["window"]["hardPermille"]
turns = cfg["turnBudget"]
max_rounds = cfg["bisect"]["maxRounds"]
rows, total_rounds = [], 0
for b in skills:
up, dn, vals, rounds = edges(spec, cfg["fixed"], ch["param"], b, seeds, turns, lo, hi,
easy, hard, max_rounds)
total_rounds += rounds
bd = [up, dn] if (up is not None and dn is not None and up <= dn) else None
rows.append({"skill": b, "coarse": [[v, vals[v]] for v in sorted(vals)],
"upperEdge": up, "lowerEdge": dn,
"bandLo": bd[0] if bd else None, "bandHi": bd[1] if bd else None,
"bandWidth": (bd[1] - bd[0] + 1) if bd else 0,
"bandCenter": (bd[0] + bd[1]) // 2 if bd else None})
for i in range(1, len(rows)):
p, c = rows[i - 1], rows[i]
if p["bandLo"] is not None and c["bandLo"] is not None:
olo, ohi = max(p["bandLo"], c["bandLo"]), min(p["bandHi"], c["bandHi"])
c["shiftFromPrev"] = c["bandCenter"] - p["bandCenter"]
c["widthDelta"] = c["bandWidth"] - p["bandWidth"]
c["overlap"] = max(0, ohi - olo + 1)
elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
report = {"id": "gd_play_10_band", "seeds": len(seeds), "challengeParam": ch["param"],
"range": [lo, hi], "coarseStep": ch["step"], "window": cfg["window"],
"rows": rows, "bisectRounds": total_rounds, "elapsedMs": elapsed_ms}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play10_band.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
ashlib.write_csv(os.path.join(OUT, "play10_band.csv"),
["skill", "bandLo", "bandHi", "bandWidth", "bandCenter", "shiftFromPrev", "overlap"],
[[r["skill"], r["bandLo"], r["bandHi"], r["bandWidth"], r["bandCenter"],
r.get("shiftFromPrev", ""), r.get("overlap", "")] for r in rows])
size = draw(skills, rows, lo, hi, easy, hard, os.path.join(OUT, "play10_band.svg"))
for r in rows:
print(f"skill b={r['skill']} coarse=" + " ".join(f"{v}:{p}" for v, p in r["coarse"]))
print(f" upperEdge={r['upperEdge']} lowerEdge={r['lowerEdge']}"
f" band={r['bandLo']}..{r['bandHi']} width={r['bandWidth']} center={r['bandCenter']}")
for r in rows[1:]:
if "shiftFromPrev" in r:
print(f"b={r['skill']} vs b={r['skill']-1}: shift={r['shiftFromPrev']}"
f" widthDelta={r['widthDelta']} overlap={r['overlap']}")
print(f"bisectRounds={total_rounds} elapsed={elapsed_ms} ms svg={size} bytes")
print("wrote out/play10_band.json out/play10_band.csv out/play10_band.svg")
ET.parse(os.path.join(OUT, "play10_band.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_10_band.py:
skill b=1 coarse=6:1000 10:0 14:0 18:0 22:0 26:0 30:0 34:0
upperEdge=7 lowerEdge=8 band=7..8 width=2 center=7
skill b=2 coarse=6:1000 10:1000 14:625 18:125 22:0 26:0 30:0 34:0
upperEdge=14 lowerEdge=17 band=14..17 width=4 center=15
skill b=3 coarse=6:1000 10:1000 14:1000 18:812 22:312 26:62 30:62 34:0
upperEdge=18 lowerEdge=23 band=18..23 width=6 center=20
skill b=4 coarse=6:1000 10:1000 14:1000 18:812 22:375 26:62 30:62 34:0
upperEdge=18 lowerEdge=23 band=18..23 width=6 center=20
skill b=5 coarse=6:1000 10:1000 14:1000 18:812 22:375 26:62 30:62 34:0
upperEdge=18 lowerEdge=23 band=18..23 width=6 center=20
b=2 vs b=1: shift=8 widthDelta=2 overlap=0
b=3 vs b=2: shift=5 widthDelta=2 overlap=0
b=4 vs b=3: shift=0 widthDelta=0 overlap=6
b=5 vs b=4: shift=0 widthDelta=0 overlap=6
bisectRounds=20 elapsed=26 ms svg=2781 bytes
wrote out/play10_band.json out/play10_band.csv out/play10_band.svg
读带宽:位移、加宽、重叠
二分把边钉到 ±1 之后,带宽表就能逐列读:
| 技巧档 b | 带宽 | 宽度 | 中心 | 相对前一档位移 | 宽度变化 | 与前一档重叠 |
|---|---|---|---|---|---|---|
| 1 | 7..8 | 2 | 7 | — | — | — |
| 2 | 14..17 | 4 | 15 | +8 | +2 | 0 |
| 3 | 18..23 | 6 | 20 | +5 | +2 | 0 |
| 4 | 18..23 | 6 | 20 | 0 | 0 | 6 |
| 5 | 18..23 | 6 | 20 | 0 | 0 | 6 |
三条读数合起来是本节的核心结论。第一,带宽是区间,而且是一段有宽度的区间:b=1 的带宽宽度是 2、b=2 是 4、b=3 是 6——同一个技巧档,可接受的挑战不止一个值。这正是点模型给不出的东西:点没有宽度。
第二,技巧每上一档,带宽整体右移并加宽:b=1 → b=2 位移 +8、宽度 +2;b=2 → b=3 位移 +5、宽度 +2。技巧越高,合适的挑战越硬,而且可接受的挑战范围越宽。这是「匹配是一段区间」在技巧轴上的直接体现:更强的玩家不是「在更难的一个点上」匹配,而是「在一段更宽、更靠右的挑战里」匹配。
第三,相邻档之间的重叠是 0 或满:b=1 与 b=2 的带宽 [7,8] 与 [14,17] 完全不相交(重叠 0);b=2 与 b=3 也不相交;而 b=3、b=4、b=5 三档的带宽完全相同(重叠 6)。重叠 0 说明挑战值在 7..8 和 14..17 这两段之间是「无人区」——没有一个技巧档在这两段之间的挑战里处在带宽内。这不是设计的漏洞,而是技巧档之间的跨度太大:b=1 与 b=2 的胜率曲线之间隔着一整段从「稳赢」到「多半会输」的过渡。
至于 b=3、b=4、b=5 三档完全重叠,原因与 10.1 相同:energyMax=3 把每回合的有效出牌数封在 3 张,出牌预算超过 3 之后不再转化为胜率,带宽也就不再右移。技巧轴的饱和会直接表现为带宽的饱和——这是 10.3 排难度时必须先知道的事。
自检表
| 检查项 | 合格标准 |
|---|---|
| 带宽有定义 | 上下沿各有一条可判定的判据(win ≤ easy / win ≥ hard) |
| 单调性前提写明 | 先声明「胜率随挑战单调不增」,二分方向才唯一 |
| 边钉到整数 | 粗扫定位 + 整数二分,报出 bisectRounds |
| 窗口随结论一起报 | 带宽宽度必须连同 easy / hard 一起引用 |
| 重叠如实报告 | 相邻档重叠为 0 与完全重叠都写出来,不掩盖 |
| 代理指标声明 | 胜率写明是 proxy,只做相对比较 |
带宽表给出了「每个技巧档可接受的挑战区间」。剩下的是把它用起来:给定一条技能成长曲线,怎么把 12 关的挑战排下去,让玩家尽量一直待在带宽里?下一节对照两种排法——定点排布与带宽跟随排布——并报出它们各自有多少关落在带宽内、偏离带宽中心的总代价是多少。
阅读导航:上一节:10.1 心流:挑战与技巧的匹配 · 下一节:10.3 带宽与难度设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。