8.2 平台跳跃的技巧曲线

把「技巧」操作化成一个可算的量:玩家每一跳的按压时刻相对帧级完美点偏移 ±n 帧,枚举 (2n+1)^跳数 条序列报通关率,得到一条单调不增的技巧曲线;n≤2 恒为 1000‰,tol1000 = 2 帧、tol500 = 8 帧。通关率是代理指标,只做相对比较。

8.2 平台跳跃的技巧曲线

《独立游戏关卡设计:环境叙事》 讲的是难度曲线怎么铺:哪一段该紧、哪一段该松、空间怎么暗示下一步。本节是机制口径:把「这一关要玩家多准」写成一个能算出来的整数,而不是一句「感觉有点难」。若要看这些难度目标怎么进关卡生产管线,《关卡生产管线 2026》 已经写过;本节只算一个量——容差帧数。

8.1 已经把跳跃拆成六个可断言的字段。字段拆开只说明「机制是什么」,不说明「它有多难」:runSpeed=250、jumpVel=500、gravity=25 这组数摆在那里,谁也看不出玩家得精确到几帧。本节补上这个量:把玩家的手抖参数化,扫一遍参数,看通关率怎么掉。

技巧是可算的量,不是感觉

「技巧」这个词同样不可断言。要算它,必须先把它换成一个能被程序枚举的东西。本节的换法只有一步:玩家每一跳的按压时刻,相对帧级完美点偏移多少帧。

概念操作化单位 / 取值
帧级完美每一跳都按在最优帧,偏移全为 0n = 0,1 条序列
手抖幅度每一跳的按压偏移独立取 −n … +nn 为整数,帧
序列空间枚举全部 (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 跳时,最大仍通关的 n6 帧
pfJumpSlack(1)只平移第 2 跳时,最大仍通关的 n6 帧
pfReqTol两跳同时独立偏移时,最大仍 1000‰ 的 n2 帧

原因是垂直状态耦合:第 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 平台跳跃的心流带宽 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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