8.2 平台跳跃的技巧曲线
《独立游戏关卡设计:环境叙事》 讲的是难度曲线怎么铺:哪一段该紧、哪一段该松、空间怎么暗示下一步。本节是机制口径:把「这一关要玩家多准」写成一个能算出来的整数,而不是一句「感觉有点难」。若要看这些难度目标怎么进关卡生产管线,《关卡生产管线 2026》 已经写过;本节只算一个量——容差帧数。
8.1 已经把跳跃拆成六个可断言的字段。字段拆开只说明「机制是什么」,不说明「它有多难」:runSpeed=250、jumpVel=500、gravity=25 这组数摆在那里,谁也看不出玩家得精确到几帧。本节补上这个量:把玩家的手抖参数化,扫一遍参数,看通关率怎么掉。
技巧是可算的量,不是感觉
「技巧」这个词同样不可断言。要算它,必须先把它换成一个能被程序枚举的东西。本节的换法只有一步:玩家每一跳的按压时刻,相对帧级完美点偏移多少帧。
| 概念 | 操作化 | 单位 / 取值 |
|---|---|---|
| 帧级完美 | 每一跳都按在最优帧,偏移全为 0 | n = 0,1 条序列 |
| 手抖幅度 | 每一跳的按压偏移独立取 −n … +n | n 为整数,帧 |
| 序列空间 | 枚举全部 (2n+1)^跳数 条偏移组合 | 本关 2 跳,故 (2n+1)² |
| 技巧读数 | 这些序列里通关的比例(千分比) | 通关率 ∈ [0, 1000]‰ |
| 关卡的容差 | 通关率仍为 1000‰ 的最大 n | 帧,越小越难 |
这四步里没有一步是主观的:偏移范围是整数、组合数是整数、通关率是整数地板除。所谓「这一关很难」,在本文的口径下就等价于「通关率在 n=3 就掉下来了」——一个能被断言、能被回归的数字。
扰动入口只有一个:平移第 j 跳
内核里刻意只留一个扰动入口 pfShiftJump(seq, j, d):把第 j 次跳跃的按下帧平移 d 帧,其余帧不动。它由「水平输入串 + 跳跃帧表」重建整条命令串,所以平移之后仍是一条合法的逐帧序列。技巧曲线就是在它上面递归枚举出来的:
// snippet: core/genre/platformer.ts(节选:pfShiftJump / pfTolRate / pfReqTol)
/** 把第 j 次跳跃的按下帧平移 d 帧(其余帧不变),是技巧曲线的唯一扰动入口。 */
export function pfShiftJump(seq: string, j: number, d: number): string {
const jf = pfJumpFrames(seq);
if (j < 0 || j >= jf.length) return seq;
const frames = jf.slice();
frames[j] = Math.max(0, Math.min(seq.length - 1, frames[j] + d));
return pfBuildSeq(pfHorizOf(seq), frames, seq.length);
}
/**
* 技巧量(代理指标):玩家逐跳的按压缩放为 ±n 帧,独立枚举每一跳的 2n+1 个取值,
* 报出 (2n+1)^跳数 条序列里的通关率(千分比)。n=0 是帧级完美,n 越大越手抖。
*/
export function pfTolRate(spec: PfSpec, n: number): PfTolResult {
const base = spec.strategy;
const k = pfJumpFrames(base).length;
let wins = 0, total = 0;
const rec = (j: number, seq: string): void => {
if (j === k) { total += 1; if (pfRun(spec, seq).won) wins += 1; return; }
for (let d = -n; d <= n; d += 1) rec(j + 1, pfShiftJump(seq, j, d));
};
rec(0, base);
return { n, wins, total, rate: total === 0 ? 0 : Math.floor((1000 * wins) / total) };
}
/** 关卡要求的容差帧数:使玩家(逐跳独立平移 ±n)仍 1000‰ 通关的最大 n。 */
export function pfReqTol(spec: PfSpec, maxN: number): number {
for (let n = 0; n <= maxN; n += 1) if (pfTolRate(spec, n).rate < 1000) return n - 1;
return maxN;
}
注意 pfTolRate 里那个 wins:它是枚举出来的,不是抽样估计出来的,所以 n ≤ 12 时读数是精确整数而非统计量。组合数一旦超过预算(本关 2 跳,n=12 只有 625 条,还远没到预算),才切到 pfTolSample 的抽样版——8.2 的测试里两者做过一致性对照。
枚举:n = 0…12 的完整曲线
把上面三条函数接到断言上就是 core/test/gd_proto_08_2_skill_curve.test.ts:它冻结整条曲线、断言单调不增、断言两个门槛值、核对组合数,并验证「单跳各自宽容、联合却很窄」这件反直觉的事。
// file: core/test/gd_proto_08_2_skill_curve.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_08_2_skill_curve.test.ts'
//
// 卷三 8.2 平台跳跃的技巧曲线(P 层):把「技巧」操作化成一个可算的量——玩家每一跳的按压
// 时刻相对帧级完美点的偏移,缩放为 ±n 帧;枚举 (2n+1)^跳数 条序列,报出通关率。通关率是
// **代理指标**(proxy),只做相对比较、禁绝对化。曲线在 n ≤ 2 上恒为 1000‰,n=3 起开始
// 掉;这就是「关卡要求的容差帧数」。本节另报单跳 slack 与联合容差的差距(两跳耦合),
// 以及枚举与自实现 PRNG 抽样的一致性。
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 {
pfTolRate, pfTolSample, pfReqTol, pfJumpSlack, pfShiftJump, pfRun, pfJumpFrames,
type PfSpec,
} from "../genre/platformer.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const BASE: PfSpec = JSON.parse(readFileSync(join(ROOT, "config", "gd_proto_08_platformer.json"), "utf8"));
const MAXN = 12;
const CURVE = [1000, 1000, 1000, 959, 888, 818, 757, 666, 567, 493, 435, 389, 329];
const COMBOS = CURVE.map((_, n) => (2 * n + 1) * (2 * n + 1));
test("技巧曲线:通关率随缩放帧数 n 单调不增,n≤2 恒为 1000‰", () => {
const got = [];
for (let n = 0; n <= MAXN; n += 1) got.push(pfTolRate(BASE, n).rate);
assert.deepEqual(got, CURVE);
for (let n = 1; n <= MAXN; n += 1) assert.ok(got[n] <= got[n - 1], `n=${n} 不单调`);
assert.equal(pfTolRate(BASE, 0).total, 1);
});
test("可算的技巧量:tol1000 = 2 帧,tol500 = 8 帧", () => {
assert.equal(pfReqTol(BASE, MAXN), 2);
let tol500 = -1;
for (let n = 0; n <= MAXN; n += 1) if (pfTolRate(BASE, n).rate >= 500) tol500 = n;
assert.equal(tol500, 8);
assert.equal(CURVE[2], 1000);
assert.ok(CURVE[3] < 1000 && CURVE[8] >= 500 && CURVE[9] < 500);
});
test("组合数是 (2n+1)^跳数:n=12 时 625 条,整条曲线 2925 次实跑", () => {
assert.equal(pfJumpFrames(BASE.strategy).length, 2);
assert.equal(COMBOS[0], 1);
assert.equal(COMBOS[12], 625);
assert.equal(COMBOS.reduce((a, b) => a + b, 0), 2925);
const total = COMBOS.reduce((a, b) => a + b, 0);
assert.ok(total * pfRun(BASE).frame < 500000, "整条曲线的实跑量应远小于 50 万帧");
});
test("两跳耦合:单跳各自容忍 6 帧,联合容差只剩 2 帧", () => {
const s0 = pfJumpSlack(BASE, 0, MAXN);
const s1 = pfJumpSlack(BASE, 1, MAXN);
assert.equal(s0, 6);
assert.equal(s1, 6);
assert.equal(pfReqTol(BASE, MAXN), 2);
assert.ok(pfReqTol(BASE, MAXN) < Math.min(s0, s1));
// 单跳平移 3 帧仍通关,但两跳同时平移 3 帧就掉出 1000‰。
assert.equal(pfRun(BASE, pfShiftJump(BASE.strategy, 0, 3)).won, true);
assert.equal(pfRun(BASE, pfShiftJump(BASE.strategy, 1, 3)).won, true);
assert.ok(pfTolRate(BASE, 3).rate < 1000);
});
test("抽样与枚举一致:自实现 PRNG 抽 200 组偏移,读数落在枚举值附近", () => {
const e3 = pfTolRate(BASE, 3).rate, e8 = pfTolRate(BASE, 8).rate;
const a3 = pfTolSample(BASE, 3, 200, 80801).rate, b3 = pfTolSample(BASE, 3, 200, 80802).rate;
const a8 = pfTolSample(BASE, 8, 200, 80801).rate;
assert.equal(a3, 970);
assert.equal(b3, 930);
assert.equal(a8, 510);
assert.ok(Math.abs(a3 - e3) <= 60 && Math.abs(b3 - e3) <= 60, "n=3 抽样应贴近枚举");
assert.ok(Math.abs(a8 - e8) <= 80, "n=8 抽样应贴近枚举");
assert.equal(pfTolSample(BASE, 8, 200, 80801).rate, pfTolSample(BASE, 8, 200, 80801).rate);
});
test("曲线读数只做相对比较:代理指标口径写明,禁绝对化", () => {
const rows: [string, number, number][] = [
["tol1000", 2, 1000],
["tol500", 8, 567],
["tol12", 12, 329],
];
for (const [name, n, want] of rows) {
assert.equal(pfTolRate(BASE, n).rate, want, name);
console.log(` ${name.padEnd(8)} n=${String(n).padStart(2)} 通关率=${String(want).padStart(4)}‰ (proxy)`);
}
});
# snippet: 逐 n 枚举整条曲线
node --input-type=module -e 'import {readFileSync} from "node:fs"; import {pfTolRate} from "./core/genre/platformer.ts"; const B=JSON.parse(readFileSync("config/gd_proto_08_platformer.json","utf8")); for (let n=0;n<=12;n+=1){const r=pfTolRate(B,n); console.log(n, r.total, r.wins, r.rate);}'
n= 0 组合= 1 通关= 1 通关率=1000‰
n= 1 组合= 9 通关= 9 通关率=1000‰
n= 2 组合= 25 通关= 25 通关率=1000‰
n= 3 组合= 49 通关= 47 通关率= 959‰
n= 4 组合= 81 通关= 72 通关率= 888‰
n= 5 组合=121 通关= 99 通关率= 818‰
n= 6 组合=169 通关=128 通关率= 757‰
n= 7 组合=225 通关=150 通关率= 666‰
n= 8 组合=289 通关=164 通关率= 567‰
n= 9 组合=361 通关=178 通关率= 493‰
n=10 组合=441 通关=192 通关率= 435‰
n=11 组合=529 通关=206 通关率= 389‰
n=12 组合=625 通关=206 通关率= 329‰
曲线前三个点钉在 1000‰:玩家每跳只要偏移不超过 ±2 帧就必定通关,这就是这一关要求的容差帧数 2。从 n=3 开始掉,n=8 跌破 500‰,n=12 只剩 329‰。注意 n=11 与 n=12 的 通关 都是 206 条——组合数涨了 96 条,通关数一条没涨,说明能通关的偏移对在 n=11 就已经枚举完了,曲线尾部只是分母在变大。这正好是「技巧曲线只能做相对比较」的原因:329‰ 这个绝对值依赖你把 n 扫到多远,只有拐点位置(2 与 8)是稳的。
把曲线在几个通关率门槛上切一刀,就得到一串「容差帧数」读数。它们全部来自同一条曲线,只是取不同的水平线:
| 门槛 | 含义 | 容差帧数 |
|---|---|---|
| 1000‰ | 全对,一帧不能错 | 2 |
| 950‰ | 几乎全对 | 3 |
| 800‰ | 五分之四能过 | 5 |
| 600‰ | 过半数偏上 | 7 |
| 500‰ | 一半一半 | 8 |
| 400‰ | 过半数偏下 | 10 |
| 300‰ | 三成能过 | 12(扫到上界仍未跌破) |
门槛越高,容差帧数越小、越难;门槛越低,容差帧数越大、越松。所以「这关的难度」不是一个数,而是一条曲线上的一个切点——用哪个门槛取决于你想让玩家「每次都对」还是「多半能过」。8.3 会把这条思路接着往下走:把门槛固定,改关卡本身。
两跳耦合:单跳各 6 帧,联合只剩 2 帧
最容易踩的坑是把「每跳的容差」直接当成「整关的容差」。本关两跳各自都能容忍 ±6 帧,但整关只容忍 ±2 帧——差了三倍。
| 量 | 含义 | 实测 |
|---|---|---|
pfJumpSlack(0) | 只平移第 1 跳时,最大仍通关的 n | 6 帧 |
pfJumpSlack(1) | 只平移第 2 跳时,最大仍通关的 n | 6 帧 |
pfReqTol | 两跳同时独立偏移时,最大仍 1000‰ 的 n | 2 帧 |
原因是垂直状态耦合:第 1 跳早按两帧,起跳点就前移、落点也前移,第 2 跳的可用窗口跟着挪;两跳各自都在自己的窗口里,合起来却可能一起滑出窗口。单跳 slack 只能当上界,整关容差必须枚举联合空间——这也是 pfReqTol 不能写成 min(slack0, slack1) 的原因。
抽样与枚举一致
n 一大,组合数就是 (2n+1)²,再往上还有 (2n+1)^跳数。超过预算就得抽样,而抽样必须可复现,所以用的是自实现 PRNG(Lehmer MINSTD),不是 Math.random():
# snippet: 同一个种子的抽样必须逐位可复现
node --test 'core/test/gd_proto_08_2_skill_curve.test.ts'
tol1000 n= 2 通关率=1000‰ (proxy)
tol500 n= 8 通关率= 567‰ (proxy)
tol12 n=12 通关率= 329‰ (proxy)
✔ 技巧曲线:通关率随缩放帧数 n 单调不增,n≤2 恒为 1000‰
✔ 可算的技巧量:tol1000 = 2 帧,tol500 = 8 帧
✔ 组合数是 (2n+1)^跳数:n=12 时 625 条,整条曲线 2925 次实跑
✔ 两跳耦合:单跳各自容忍 6 帧,联合容差只剩 2 帧
✔ 抽样与枚举一致:自实现 PRNG 抽 200 组偏移,读数落在枚举值附近
✔ 曲线读数只做相对比较:代理指标口径写明,禁绝对化
ℹ tests 6
ℹ pass 6
ℹ fail 0
六条全绿。抽样读数 n=3 → 970‰(枚举 959‰)、n=8 → 510‰(枚举 567‰)都落在枚举值附近;换一个种子(80802)读数变 930‰,但两次同种子抽样逐位相同——可复现,但不等于枚举值,这正是代理指标要写清口径的地方。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
node --test 'core/test/gd_proto_08_2_skill_curve.test.ts' | 断言结果 | 6 测试全绿,0 失败 |
逐 n 枚举(--input-type=module -e) | 13 行曲线读数 | n≤2 恒 1000‰,n=12 剩 329‰ |
| 联合枚举 | 2925 次实跑 | tol1000 = 2 帧,tol500 = 8 帧 |
技巧曲线给出了「这一关要玩家准到 2 帧」。但 2 帧到底是难是易,取决于玩家是谁——高手能稳定在 0 帧,新手可能要 6 帧。下一节把「关卡要求的容差」与「玩家能达到的容差」相减,做成一条带宽而不是一个点。
阅读导航:上一节:8.1 平台跳跃的机制解剖 · 下一节:8.3 平台跳跃的心流带宽 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。