8.3 平台跳跃的心流带宽
《独立游戏关卡设计:环境叙事》 讲的是难度怎么铺:一段紧一段松、用空间暗示下一跳、让玩家在失败里学到东西。本节是机制口径:把「这一段该多紧」算成一条有上下界的区间。若要看关卡怎么从设计稿进生产线,《关卡生产管线 2026》 已经写过;本节只算一个区间——心流带宽。
8.2 算出了「这一关要玩家准到 2 帧」。但「2 帧」本身不说明难易:它要和玩家能准到几帧放在一起看才有意义。高手能稳定在 0 帧,新手可能要 6 帧——同一关对三个人就是三种难度。本节把这两者相减,得到一个余量,再把余量切成「太难 / 在带宽内 / 太易」三段。
挑战与技巧相减,得到的是区间
单个数字骗人。心流的常见写法是「挑战略高于技巧」,但「略高」是多少?本节的换法是把两个量都做成整数、相减、再用一个窗口判:
- 挑战
req:关卡要求的容差帧数,来自 8.2 的pfReqTol——该关仍 1000‰ 通关的最大 n。 - 技巧
cap:玩家能达到的容差帧数,是玩家模型的参数(不是关卡属性)。 - 余量
req − cap:正值说明玩家比关卡要求的更准,负值说明够不着。 - 带宽
[lo, hi]:余量落在这个闭区间里就算「在带宽内」。
关键在于带宽是一段区间:req 与 cap 都是整数,所以余量也是整数,一段连续的 req 就对应一段连续的关卡参数区间。给一个单点阈值(比如「余量 ≥ 0」)会把「刚好够」和「远远够」混为一谈——前者紧张、后者无聊,体验完全不同。
玩家模型与关卡族
玩家模型压成一个整数 cap。三档玩家对应三个值,取值来自 8.2 曲线的读法:帧级完美是 0,略有手感是 2,手比较抖是 6。
| 玩家档 | cap(帧) | 含义 |
|---|---|---|
| expert | 0 | 每一跳都按在最优帧附近 |
| trained | 2 | 有手感,偶有偏移 |
| casual | 6 | 手抖,但仍在单跳 slack 之内 |
关卡族只动一个参数:第一块落点平台的宽度 w1。平台越宽,落点越宽容,req 越小(关卡越松);越窄越难。其余几何(缺口、第二块平台、终点)整体跟着 w1 平移,保证只有一个自变量:
{
"spec_version": 1,
"id": "gd_proto_08_band",
"name": "平台跳跃变体 · 心流带宽扫描",
"seed": 80801,
"base": "config/gd_proto_08_platformer.json",
"naming": {
"turn": "frame",
"energy": "vx",
"playerHp": "y",
"enemyHp": "remainingX",
"block": "maxHeight",
"played": "landed",
"hand": "landings",
"cardCount": "platformCount"
},
"state": {
"x": 0,
"y": 0
},
"rules": {
"family": {
"p1x": 14000,
"gap2": 8000,
"w2": 8000,
"top": 0,
"goalOffset": 5000
},
"runSpeed": 250,
"jumpVel": 500,
"gravity": 25,
"coyoteFrames": 6,
"bufferFrames": 6
},
"params": {
"frameLimit": 160,
"fallLimit": 20000
},
"invariant": [
"platformCount==platforms.length",
"xConserved",
"yConserved",
"frame<=frameLimit"
],
"sweep": {
"param": "platform1Width",
"grid": [1000, 1500, 2000, 2500, 3000, 3500, 4000, 5000, 6000],
"solve": { "aLo": 6, "aHi": 60, "bLo": 30, "bHi": 140, "maxN": 16 }
},
"bandFrames": { "lo": 0, "hi": 1 },
"players": [
{ "id": "expert", "cap": 0 },
{ "id": "trained", "cap": 2 },
{ "id": "casual", "cap": 6 }
],
"strategy": ">>>>>>>>>>>>>>>>>>>>>>r>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>r>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>"
}
扫描:w1 从 1000 到 6000
九格网格,每格先用 pfBestStrategy 找一对最宽容的跳跃帧(让单跳 slack 的较小者最大),再跑 pfReqTol。这一步很关键:如果随便挑一对跳跃帧,req 会在网格上非单调——不是关卡变松了,而是基准策略没对准。测试把它全部冻结:
// file: core/test/gd_proto_08_3_flow_band.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_08_3_flow_band.test.ts'
//
// 卷三 8.3 平台跳跃的心流带宽(P 层):带宽是一段关卡区间,不是单点。本节把「挑战」定义成
// 关卡要求的容差帧数 req(该关仍 1000‰ 通关的最大按压缩放),把「技巧」定义成玩家可达到的
// 容差帧数 cap(玩家模型参数)。余量 = req − cap:< lo 太难(够不着),> hi 太易(白送),
// 落在 [lo, hi] 内即在带宽里。扫描第一块落点平台宽度 w1 得到 req 曲线,再对三档玩家各报
// 一条带宽上下界。全部读数是**代理指标**(proxy),只做相对比较,禁绝对化。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
pfRun, pfReqTol, pfBestStrategy, pfBuildSeq, pfJumpSlack, pfDigest, type PfSpec,
} from "../genre/platformer.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const BAND = JSON.parse(readFileSync(join(ROOT, "config", "gd_proto_08_band.json"), "utf8"));
const F = BAND.rules.family, SW = BAND.sweep, W = BAND.bandFrames;
/** 同一族里的第 w1 关:只改第一块落点平台的宽度,其余几何随之前移。 */
function level(w1: number): PfSpec {
const p1x = F.p1x, p2x = p1x + w1 + F.gap2;
return {
spec_version: 1, id: "gd_proto_08_lv" + w1, name: "平台跳跃关卡 w1=" + w1, seed: BAND.seed,
naming: BAND.naming, state: { x: 0, y: 0 },
rules: {
platforms: [{ x: 0, w: 6000, top: F.top }, { x: p1x, w: w1, top: F.top }, { x: p2x, w: F.w2, top: F.top }],
goalX: p2x + F.goalOffset, runSpeed: BAND.rules.runSpeed, jumpVel: BAND.rules.jumpVel,
gravity: BAND.rules.gravity, coyoteFrames: BAND.rules.coyoteFrames, bufferFrames: BAND.rules.bufferFrames,
},
params: { frameLimit: BAND.params.frameLimit, fallLimit: BAND.params.fallLimit },
invariant: BAND.invariant, strategy: "",
};
}
type Row = { w1: number; req: number; single: [number, number]; jumps: number[] };
const rows: Row[] = [];
for (const w1 of SW.grid) {
const lv = level(w1);
const best = pfBestStrategy(lv, SW.solve.aLo, SW.solve.aHi, SW.solve.bLo, SW.solve.bHi, SW.solve.maxN);
assert.ok(best !== null, `w1=${w1} 应有解`);
lv.strategy = pfBuildSeq(">".repeat(lv.params.frameLimit), best as number[], lv.params.frameLimit);
assert.equal(pfRun(lv).won, true, `w1=${w1} 最优策略应通关`);
rows.push({ w1, req: pfReqTol(lv, SW.solve.maxN), single: [pfJumpSlack(lv, 0, SW.solve.maxN), pfJumpSlack(lv, 1, SW.solve.maxN)], jumps: best as number[] });
}
const bandOf = (cap: number): number[] =>
rows.filter((r) => r.req - cap >= W.lo && r.req - cap <= W.hi).map((r) => r.w1);
const margin = (cap: number, w1: number): number => (rows.find((r) => r.w1 === w1) as Row).req - cap;
const verdict = (cap: number, w1: number): string => {
const m = margin(cap, w1);
return m < W.lo ? "太难" : m > W.hi ? "太易" : "在带宽内";
};
test("req 曲线:关卡的容差帧数随落点平台变宽单调不降", () => {
assert.deepEqual(rows.map((r) => r.req), [1, 1, 2, 2, 2, 6, 6, 6, 6]);
for (let i = 1; i < rows.length; i += 1) assert.ok(rows[i].req >= rows[i - 1].req, `w1=${rows[i].w1} 应不降`);
for (const r of rows) console.log(` w1=${String(r.w1).padStart(4)} req=${r.req} 单跳=[${r.single}] 跳跃帧=${JSON.stringify(r.jumps)}`);
});
test("三档玩家各有一条带宽:上下界与宽度都报出来", () => {
const want: Record<string, { cap: number; lo: number; hi: number }> = {
expert: { cap: 0, lo: 1000, hi: 1500 },
trained: { cap: 2, lo: 2000, hi: 3000 },
casual: { cap: 6, lo: 3500, hi: 6000 },
};
for (const pl of BAND.players) {
const b = bandOf(pl.cap);
assert.ok(b.length > 0, `${pl.id} 带宽不应为空`);
assert.equal(b[0], want[pl.id].lo);
assert.equal(b[b.length - 1], want[pl.id].hi);
console.log(` ${pl.id.padEnd(8)} cap=${pl.cap} 带宽=[${b[0]},${b[b.length - 1]}] 宽度=${b.length} 格`);
}
assert.deepEqual(bandOf(0), [1000, 1500]);
assert.deepEqual(bandOf(2), [2000, 2500, 3000]);
assert.deepEqual(bandOf(6), [3500, 4000, 5000, 6000]);
});
test("带宽是区间不是单点:技巧越强,整条带宽越靠硬的一侧", () => {
const e = bandOf(0), t = bandOf(2), c = bandOf(6);
assert.ok(e[e.length - 1] < t[0], "expert 带宽应完全在 trained 左侧");
assert.ok(t[t.length - 1] < c[0], "trained 带宽应完全在 casual 左侧");
assert.ok(t[0] - e[e.length - 1] > 0 && c[0] - t[t.length - 1] > 0);
});
test("越出带宽的判据:余量 < lo 太难、> hi 太易", () => {
assert.equal(margin(6, 1000), -5);
assert.equal(verdict(6, 1000), "太难");
assert.equal(verdict(6, 3000), "太难");
assert.equal(verdict(2, 1000), "太难");
assert.equal(verdict(0, 2000), "太易");
assert.equal(verdict(0, 6000), "太易");
assert.equal(verdict(2, 6000), "太易");
assert.equal(verdict(0, 1000), "在带宽内");
assert.equal(verdict(2, 2000), "在带宽内");
assert.equal(verdict(6, 3500), "在带宽内");
console.log(` expert w1=2000 余量=${margin(0, 2000)} -> ${verdict(0, 2000)}`);
console.log(` casual w1=3000 余量=${margin(6, 3000)} -> ${verdict(6, 3000)}`);
});
test("带宽读数只做相对比较:门槛与窗口随结论一起报(proxy)", () => {
assert.equal(W.lo, 0);
assert.equal(W.hi, 1);
const base = JSON.parse(readFileSync(join(ROOT, "config", "gd_proto_08_platformer.json"), "utf8"));
const d = pfDigest(pfRun(base));
assert.ok(d.includes('"won":true'));
assert.equal(rows.length, SW.grid.length);
console.log(` 窗口=[${W.lo},${W.hi}] 帧 网格=${JSON.stringify(SW.grid)}`);
});
# snippet: 跑心流带宽扫描
node --test 'core/test/gd_proto_08_3_flow_band.test.ts'
w1=1000 req=1 单跳=[2,2] 跳跃帧=[18,54]
w1=1500 req=1 单跳=[3,3] 跳跃帧=[19,56]
w1=2000 req=2 单跳=[4,4] 跳跃帧=[20,58]
w1=2500 req=2 单跳=[5,5] 跳跃帧=[21,60]
w1=3000 req=2 单跳=[6,6] 跳跃帧=[22,62]
w1=3500 req=6 单跳=[6,6] 跳跃帧=[22,68]
w1=4000 req=6 单跳=[6,6] 跳跃帧=[22,70]
w1=5000 req=6 单跳=[6,6] 跳跃帧=[22,74]
w1=6000 req=6 单跳=[6,6] 跳跃帧=[22,78]
expert cap=0 带宽=[1000,1500] 宽度=2 格
trained cap=2 带宽=[2000,3000] 宽度=3 格
casual cap=6 带宽=[3500,6000] 宽度=4 格
expert w1=2000 余量=2 -> 太易
casual w1=3000 余量=-4 -> 太难
窗口=[0,1] 帧 网格=[1000,1500,2000,2500,3000,3500,4000,5000,6000]
✔ req 曲线:关卡的容差帧数随落点平台变宽单调不降
✔ 三档玩家各有一条带宽:上下界与宽度都报出来
✔ 带宽是区间不是单点:技巧越强,整条带宽越靠硬的一侧
✔ 越出带宽的判据:余量 < lo 太难、> hi 太易
✔ 带宽读数只做相对比较:门槛与窗口随结论一起报(proxy)
ℹ tests 5
ℹ pass 5
ℹ fail 0
req 在九格上单调不降(1,1,2,2,2,6,6,6,6),且出现一个明显的台阶:w1 从 2500 涨到 3500 时 req 从 2 跳到 6。台阶不是噪声——w1 ≥ 3500 之后,第二跳的落点窗口已经宽到不构成约束,req 被单跳 slack 的 6 帧封顶,再宽也不涨。这种「被另一个约束封顶」的平台段,正是带宽扫描要找的结构。
越出带宽的判据
带宽 [lo, hi] = [0, 1]:余量 0 表示玩家刚好够得着,余量 1 表示还有一帧富余。三段判据:
余量 req − cap | 判定 | 玩家感受 | 例子 |
|---|---|---|---|
< 0(低于 lo) | 太难 | 够不着,反复失败 | casual 在 w1=3000(余量 −4) |
0 … 1 | 在带宽内 | 紧张但做得到 | expert 在 w1=1000(余量 1) |
> 1(高于 hi) | 太易 | 白送,没挑战 | expert 在 w1=2000(余量 2) |
三档玩家的带宽互不重叠且依次右移:expert [1000,1500]、trained [2000,3000]、casual [3500,6000]。这条结论很实用——同一段关卡序列不可能同时让三档玩家都进带宽,想让 casual 有挑战就得把 w1 压到 3000 以下,而那会让 expert 直接掉进「太难」。所以带宽只能按目标玩家选,不能通吃。
必须显式声明:这里所有读数——req、cap、余量、带宽上下界——都是代理指标(proxy)。它们把「心流」压缩成一个可算的整数区间,只做相对比较,禁绝对化:req=2 不代表这关「好玩 2 分」,[3500,6000] 也不代表这个宽度区间对真实玩家一定合适。真实判断要靠卷二的玩家测试。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
node --test 'core/test/gd_proto_08_3_flow_band.test.ts' | 断言结果 | 5 测试全绿,0 失败 |
九格 req 扫描 | 单调不降曲线 | 1,1,2,2,2,6,6,6,6,在 w1=3000→3500 出现台阶 |
| 三档带宽 | 三条区间 | expert [1000,1500] / trained [2000,3000] / casual [3500,6000] |
第 8 章到此收束:8.1 把跳跃拆成六个可断言字段,8.2 把它变成一个可算的技巧量,8.3 把技巧与挑战相减做成一条带宽。三节的读数全部来自同一份规格骨架,只是覆盖了不同的 rules / params——这正是卷三要证的「同一份规格跨品类成立」在一个品类内部的第一次闭环。
阅读导航:上一节:8.2 平台跳跃的技巧曲线 · 下一节:9.1 自走棋的机制解剖 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。