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,每档停留若干关。
| 关卡 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 技巧档 b | 1 | 1 | 1 | 2 | 2 | 2 | 2 | 3 | 3 | 3 | 3 | 3 |
| 带宽中心 | 7 | 7 | 7 | 15 | 15 | 15 | 15 | 20 | 20 | 20 | 20 | 20 |
这条曲线本身就是代理:真实玩家的技巧不会按关卡均匀增长,也不会整齐地停在整数档上。这里用阶梯只是为了把「挑战该不该跟着技巧移动」这件事算清楚——如果连一个理想化的阶梯都显示定点排布会越排越偏,那么真实玩家的非线性增长只会让偏差更大,不会更小。
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) | 3 | 9 | 0 | 5061 |
| 跟随(挑战 = 当前带宽中心) | 12 | 0 | 0 | 2621 |
定点排布只有前 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 技巧习得的建模 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。