8.3 平台跳跃的心流带宽

把「挑战(关卡要求的容差帧数)− 技巧(玩家可达到的容差帧数)」做成带宽区间而不是单点:扫第一块落点平台宽度得到单调不降的容差曲线,再给高手 / 熟练 / 休闲三档玩家各算一条带宽上下界与越界判据,全部读数都是代理指标,只做相对比较、禁绝对化。

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(帧)含义
expert0每一跳都按在最优帧附近
trained2有手感,偶有偏移
casual6手抖,但仍在单跳 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 自走棋的机制解剖 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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