10.2 心流带宽的可计算化

带宽是一段挑战区间,不是单点。本节给带宽一个可算的定义:代理胜率落在 [hard, easy] 内的连续整数区间;再用粗扫定位、整数二分把上下沿各钉到 ±1,报出每个技巧档的带宽宽度、中心位移、相邻档重叠,以及技巧轴饱和时带宽如何随之饱和。

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带宽宽度中心相对前一档位移宽度变化与前一档重叠
17..827———
214..17415+8+20
318..23620+5+20
418..23620006
518..23620006

三条读数合起来是本节的核心结论。第一,带宽是区间,而且是一段有宽度的区间: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 带宽与难度设计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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