10.3 带宽与难度设计

有了带宽,难度曲线该怎么排?本节用 10.2 的带宽表对照两条排法:定点排布把挑战全程钉在开局技巧档的带宽中心,带宽跟随排布把挑战排在当前技巧档的中心。12 关逐关实跑,报出带宽内关数与偏离代价,并说明带宽跟随排布为什么依赖实测的技巧曲线与带宽表。

10.3 带宽与难度设计

《小游戏开发实战》卷一 7.2 难度曲线与心流 把难度曲线画成一条随关卡上升的线,看的是每个点上难度与技巧差多少;上一节把「合适的难度」量成一段区间(带宽)。这一节把两件事接起来:给定一条技能成长曲线,难度应该怎么排?如果按点模型,做法是让每一关的挑战都「等于」当时的技巧——但 10.2 已经说明整数机制里没有那个点。按带宽模型,做法变成:让每一关的挑战落在该关技巧档的带宽里。本节对照两种排法——把挑战全程钉死在开局那个点,和让挑战跟着带宽走——并报出它们各自有多少关落在带宽内。

难度排布:定点还是跟随

难度设计里最常见的直觉是「第一关调到合适,后面照抄」。它的假设是合适的挑战是一个常数:只要开局那个值让玩家舒服,后面用同一个值也应该舒服。点模型下这几乎是必然的结论——点没有宽度,也就没有「跟着技巧移动」这回事。

带宽模型给的是另一个结论。10.2 测出 b=1 的带宽中心是 7、b=2 是 15、b=3 是 20;技巧从 1 长到 3,合适的挑战中心就从 7 挪到 20。**如果挑战留在 7,技巧长到 3 的玩家面对的就不再是「匹配」,而是「太易」。**于是有两种排法可以直接对照:

排法每一关的挑战对应的心智模型
定点排布全程 = 开局技巧档的带宽中心(7)「合适的难度是一个常数」
带宽跟随= 当前技巧档的带宽中心(7 / 15 / 20)「合适的难度跟着技巧走」

两条排法用的都是同一个代理指标(proxy):每关的胜率千分比。它们不是「哪条更好玩」的结论——本机没有玩家,胜率只是「这套策略在这些种子上赢了多少局」。能比较的是相对量:哪条排法让更多关落在带宽内、哪条排法离带宽中心的总偏离更小。绝对化读法(「跟随排布让玩家更快乐」)一律不成立。

技能成长模型

难度要跟着技巧走,先得有一条技巧随时间增长的模型。本节用最简单的阶梯:12 关里技巧档从 1 长到 3,每档停留若干关。

关卡123456789101112
技巧档 b111222233333
带宽中心777151515152020202020

这条曲线本身就是代理:真实玩家的技巧不会按关卡均匀增长,也不会整齐地停在整数档上。这里用阶梯只是为了把「挑战该不该跟着技巧移动」这件事算清楚——如果连一个理想化的阶梯都显示定点排布会越排越偏,那么真实玩家的非线性增长只会让偏差更大,不会更小。

S 层:两条排布逐关对照

S 层脚本 sim/gd_play_10_schedule.py 读 10.2 的带宽表(写进配置),对 12 关逐关跑两条排布,每关 16 个显式种子,共 384 次对局;再按带宽窗口把每关三分类,报出带宽内关数、太易 / 太难关数,以及「偏离带宽中心」的总代价(每关 |胜率 − 500| 之和)。全程整数、无浮点、无 random。

{
  "id": "gd_play_10_schedule",
  "note": "卷二 10.3 带宽与难度设计:12 关的技能成长模型 + 两条难度排布(定点 vs 带宽跟随)对照;带宽表取自 10.2 的实跑结果,全整数",
  "seedPool": [701, 703, 705, 707, 709, 711, 713, 715, 717, 719, 721, 723, 725, 727, 729, 731],
  "cellsPerScan": 16,
  "fixed": { "enemyHp": 60 },
  "challengeParam": "enemyAttack",
  "levels": 12,
  "skillCurve": [1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3],
  "band": { "1": [7, 8], "2": [14, 17], "3": [18, 23] },
  "window": { "easyPermille": 900, "hardPermille": 250 },
  "turnBudget": 26
}
#!/usr/bin/env python3
# file: sim/gd_play_10_schedule.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_10_schedule.py
"""卷二 10.3 带宽与难度设计(S 层)——按带宽排难度,还是按一个点排难度。

给定一条**技能成长曲线**(12 关里技巧档从 1 长到 3)与 10.2 实测出的**带宽表**,本节对照
两条难度排布:**定点排布**把挑战全程钉在开局技巧档的带宽中心(一个点),**带宽跟随排布**
把挑战排在**当前技巧档**的带宽中心。逐关跑显式种子池得到胜率千分比,再用带宽窗口把每关
三分类(太易 / 带宽内 / 太难),报出两条排布的带宽内关数、越界关数与「偏离带宽中心」的
总代价。全程整数、无浮点、无 random;胜率是代理指标,只做相对比较。产出
out/play10_schedule.json + out/play10_schedule.csv + out/play10_schedule.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_schedule.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 cls(v, easy, hard):
    return "easy" if v > easy else ("hard" if v < hard else "band")


def summarize(levels, easy, hard):
    rows = [cls(l["win"], easy, hard) for l in levels]
    return {"inBand": rows.count("band"), "tooEasy": rows.count("easy"), "tooHard": rows.count("hard"),
            "deviation": sum(abs(l["win"] - 500) for l in levels),
            "challenges": [l["challenge"] for l in levels],
            "wins": [l["win"] for l in levels], "classes": rows}


def draw(n, point, follow, easy, hard, path):
    W, H, x0, y0, y1 = 700, 320, 74, 46, 250
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 16, 28, "难度排布:定点 vs 带宽跟随(纵轴 = 代理胜率‰)", 15)
    ph = (y1 - y0)
    ET.SubElement(root, "rect", {"x": str(x0), "y": str(y1 - easy * ph // 1000),
                                 "width": str(W - x0 - 30), "height": str((easy - hard) * ph // 1000),
                                 "fill": "#14532d", "opacity": "0.55"})
    ashlib.svg_text(root, W - 34, y1 - easy * ph // 1000 - 4, f"easy={easy}", 11, "#86efac", "end")
    ashlib.svg_text(root, W - 34, y1 - hard * ph // 1000 + 12, f"hard={hard}", 11, "#fca5a5", "end")
    step = (W - x0 - 30) // max(1, n - 1)
    for label, series, color in (("定点", point, "#ef4444"), ("跟随", follow, "#22c55e")):
        pts = " ".join(f"{x0 + i * step},{y1 - w * ph // 1000}" for i, w in enumerate(series))
        ET.SubElement(root, "polyline", {"points": pts, "fill": "none", "stroke": color, "stroke-width": "2"})
        ashlib.svg_text(root, W - 34, 60 + (0 if label == "定点" else 18), label, 12, color, "end")
    for i in range(n):
        ashlib.svg_text(root, x0 + i * step, y1 + 18, str(i + 1), 11, "#94a3b8", "middle")
    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, turns = cfg["seedPool"][: cfg["cellsPerScan"]], cfg["turnBudget"]
    param, easy, hard = cfg["challengeParam"], cfg["window"]["easyPermille"], cfg["window"]["hardPermille"]
    band, curve = cfg["band"], cfg["skillCurve"]
    center = {k: (v[0] + v[1]) // 2 for k, v in band.items()}
    base_point = center[str(curve[0])]

    point, follow = [], []
    for i in range(cfg["levels"]):
        b = curve[i]
        c = center[str(b)]
        point.append({"level": i + 1, "skill": b, "challenge": base_point,
                      "win": win_permille(spec, {**cfg["fixed"], param: base_point}, b, seeds, turns)})
        follow.append({"level": i + 1, "skill": b, "challenge": c,
                       "win": win_permille(spec, {**cfg["fixed"], param: c}, b, seeds, turns)})
    sp, sf = summarize(point, easy, hard), summarize(follow, easy, hard)
    elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
    report = {"id": "gd_play_10_schedule", "levels": cfg["levels"], "skillCurve": curve, "band": band,
              "bandCenter": center, "fixedPoint": base_point, "point": point, "follow": follow,
              "summary": {"point": sp, "follow": sf}, "runs": 2 * cfg["levels"] * len(seeds),
              "elapsedMs": elapsed_ms}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play10_schedule.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_schedule.csv"),
                     ["level", "skill", "pointChallenge", "pointWin", "followChallenge", "followWin"],
                     [[p["level"], p["skill"], p["challenge"], p["win"], f["challenge"], f["win"]]
                      for p, f in zip(point, follow)])
    size = draw(cfg["levels"], sp["wins"], sf["wins"], easy, hard,
                os.path.join(OUT, "play10_schedule.svg"))

    print(f"{'lv':>3}{'skill':>6}{'point@':>8}{'win':>6}{'cls':>8}   {'follow@':>8}{'win':>6}{'cls':>8}")
    for p, f, cp, cf in zip(point, follow, sp["classes"], sf["classes"]):
        print(f"{p['level']:>3}{p['skill']:>6}{p['challenge']:>8}{p['win']:>6}{cp:>8}   "
              f"{f['challenge']:>8}{f['win']:>6}{cf:>8}")
    for name, s in (("point", sp), ("follow", sf)):
        print(f"{name:<7} inBand={s['inBand']}  tooEasy={s['tooEasy']}  tooHard={s['tooHard']}"
              f"  deviation={s['deviation']}")
    print(f"elapsed={elapsed_ms} ms  svg={size} bytes")
    print("wrote out/play10_schedule.json out/play10_schedule.csv out/play10_schedule.svg")
    ET.parse(os.path.join(OUT, "play10_schedule.svg"))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_10_schedule.py:

 lv skill  point@   win     cls    follow@   win     cls
  1     1       7   687    band          7   687    band
  2     1       7   687    band          7   687    band
  3     1       7   687    band          7   687    band
  4     2       7  1000    easy         15   625    band
  5     2       7  1000    easy         15   625    band
  6     2       7  1000    easy         15   625    band
  7     2       7  1000    easy         15   625    band
  8     3       7  1000    easy         20   812    band
  9     3       7  1000    easy         20   812    band
 10     3       7  1000    easy         20   812    band
 11     3       7  1000    easy         20   812    band
 12     3       7  1000    easy         20   812    band
point   inBand=3  tooEasy=9  tooHard=0  deviation=5061
follow  inBand=12  tooEasy=0  tooHard=0  deviation=2621
elapsed=9 ms  svg=2706 bytes
wrote out/play10_schedule.json out/play10_schedule.csv out/play10_schedule.svg

读排布:带宽内关数与偏离代价

两条排布的读数差别很大:

排法带宽内关数太易关数太难关数偏离代价 Σabs(win−500)
定点(挑战恒 = 7)3905061
跟随(挑战 = 当前带宽中心)12002621

定点排布只有前 3 关落在带宽内。第 4 关技巧档升到 2、挑战仍是 7,胜率跳到 1000‰——这一关「太易」了,而且是从第 4 关起一直太易到第 12 关。原因是带宽没动、技巧动了:挑战 7 属于 b=1 的带宽 [7,8],对 b=2(带宽 [14,17])和 b=3(带宽 [18,23])来说,7 都远在带宽下沿之下,落在「太易」区。定点排布的总偏离代价是 5061,几乎是跟随排布的两倍。

跟随排布 12 关全部落在带宽内。第 1–3 关挑战 7(b=1 的中心)、第 4–7 关挑战 15(b=2 的中心)、第 8–12 关挑战 20(b=3 的中心),每一关的胜率都在 [250,900] 窗口内(687 / 625 / 812)。它没有让任何一关「恰好匹配」(胜率都不是 500‰),但每关都落在带宽里——这正是带宽模型相对于点模型的价值:目标不是命中一个点,是待在一条带里。

还有一件必须写清楚的事:跟随排布不是自动的。它需要设计者知道每一关玩家大概在哪个技巧档、以及那一档的带宽在哪——这两样都得实测(10.2 的带宽表就是这么来的)。如果技巧曲线估错了,跟随排布会把挑战排到错的档上,同样出带。带宽模型给的是一个可校验的目标(每关的代理读数应该落在窗口内),不是一套免测的公式。

为什么用两个指标而不是一个

「带宽内关数」和「偏离代价」量的是两件事,缺一个都会读偏:

指标量什么读不出的东西
带宽内关数有几关落在窗口内(粗粒度)落在窗口边沿还是中心,它分不出
偏离代价每关离中心多远(细粒度)一关偏到「太易」区还是仍留在带内,它分不出

举例:跟随排布的第 8 关胜率是 812‰,落在 [250,900] 内但离中心 500 有 312;定点排布的第 4 关胜率是 1000‰,已出带、离中心 500。前者「在带内但偏上沿」,后者「出带」——只看偏离代价(312 对 500)会觉得差别不大,只看带宽内关数(1 对 0)又丢掉了「偏多少」。两个一起报,才是完整读数。

自检表

检查项合格标准
两条排法可比定点与跟随只差「挑战取哪个档的中心」,其余完全相同
技能曲线写明12 关的技巧档与带宽中心列成表,不藏在代码里
三分类一致与 10.1 / 10.2 用同一组窗口阈值 easy / hard
代价有定义偏离代价 = Σabs(胜率 − 500),逐关可复算
不绝对化只比较相对量(带宽内关数、偏离代价),不写「更好玩」
诚实边界写明跟随排布依赖实测技巧曲线,估错同样出带

带宽能算、能钉边、能用来排难度。但它只是「体验度量」里的一个指标——下一章把技巧本身也做成曲线:技巧怎么随练习增长、增长曲线与难度曲线怎么对上、以及练到头会撞上什么瓶颈。

阅读导航:上一节:10.2 心流带宽的可计算化 · 下一节:11.1 技巧习得的建模 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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