8.1 平台跳跃的机制解剖

把平台跳跃的六个机制字段——水平速度、跳跃高度、重力、土狼时间、输入缓冲、平台判定——各绑到 core/genre/platformer.ts 里一段确定性代码,位置与速度一律用千分格整数表达,禁浮点;规格骨架与 play_spec.json 同构、只覆盖 rules / params 两段,W 层把字段画进单文件 HTML 并真玩,P 层逐字段断言,两处 digest 与基线逐字一致。

8.1 平台跳跃的机制解剖

《独立游戏关卡设计:环境叙事》 讲的是关卡设计口径:空间怎么引导、难度曲线怎么铺、模块怎么复用、环境怎么叙事。本节是机制口径:把「跳跃」写成可执行、可枚举、可算容差的规格字段,一行渲染逻辑都不写。若要看关卡怎么进生产线,《关卡生产管线 2026》 已经写过;本节只解剖跳跃一个机制,而且每一格都绑到一段能跑、能断言的确定性代码上。

《灰烬牌库》是这套三卷本的贯穿案例。卷三要回答的是「同一份规格换成别的品类还成不成立」,所以本章不新造规格:照搬 config/play_spec.json 的六段骨架,新建 core/genre/platformer.ts,只 import 共享底座的 rng.ts 与 digest.ts,只覆盖 rules / params 两段——同一份规格,另一个品类。

机制不是名词,是字段

「跳跃高度」「土狼时间」这些词在策划案里到处都是,但词本身不可断言。可断言的形态是字段:一个字段 = 一段确定性的代码 + 一条可检验的性质。把平台跳跃拆开,恰好六个字段:

机制字段内核位置语义可断言的性质W 层证据
水平速度pfRun 的 runSpeed 与逐帧 vx 赋值> / < 把 vx 设成 ±runSpeed,. 归零x − startX 恰等于逐帧 vx 之和(整数守恒)顶部 x / vx 读数与平台条
跳跃高度pfApexMilli 与 rules.jumpVelapex = jumpVel² / (2·gravity),整数地板除闭式 apex = 5000,实测最高点 4750(首帧先扣一次重力)橙色虚线 apex
重力pfRun 的 vy -= gravity每帧垂直速度减 gravity,帧首即生效未吸附轨迹的 y 位移 = 逐帧 vy 之和玩家方块的高度变化
土狼时间pfRun 的 coyote 计数离开平台后仍有 coyoteFrames 帧可起跳边缘起跳(越过平台右沿)时 6→0 直接失败落地瞬间的青 / 粉变色
输入缓冲pfRun 的 buffer 计数落地前 bufferFrames 帧内按下即算数缓冲 6→0 时第二跳丢失,通关变失败「缓冲 6 帧」读数
平台判定pfRun 的吸附分支下落中跨过顶面、且水平落在 [x, x+w) 内即吸附抬高平台顶面或挪动 x 会改变落点归属平台条宽度与缺口间距

这张表就是「机制解剖」的全部产出:六个字段,六行可断言的性质。后面两节都在这张表上做文章——8.2 把「逐跳按压偏移」缩放成技巧曲线,8.3 扫平台宽度找「关卡要求多少容差」的带宽。

规格骨架与 play_spec.json 同构

平台跳跃变体的规格不是另起炉灶,而是把贯穿案例的六段骨架原样搬过来,只重写 rules / params:

  • id / spec_version / name / seed:标识与随机源,seed=80801;
  • naming:把共享摘要契约的八个键映射到品类词——turn→frame、energy→vx、playerHp→y、enemyHp→remainingX、block→maxHeight、played→landed、hand→landings、cardCount→platformCount;
  • state:{x: 0, y: 0};
  • rules:platforms / goalX / runSpeed / jumpVel / gravity / coyoteFrames / bufferFrames;
  • params:frameLimit / fallLimit;
  • invariant:platformCount==platforms.length / xConserved / yConserved / frame<=frameLimit。

naming 那一段是关键:它让平台跳跃状态能投影回共享的 12 键摘要契约(pfCommonState),于是同一个 digestSnap 能摘要两个品类。这是「同一份规格实例化到别的品类」最硬的证据——不是文档里说同构,是代码里共用同一个摘要函数。

定点口径:位置与速度都是千分格

平台跳跃最容易滑向浮点——重力加速度、抛物线顶点、容差帧数看起来天生是实数。本内核的做法是把单位钉死:位置与速度一律「千分格」(1 格 = 1000 单位),水平速度与跳跃初速是千分格 / 帧,重力是千分格 / 帧²。位置由整数速度逐帧累加,于是 x − startX 恰等于逐帧 vx 之和;垂直方向因为吸附那一帧会改写 y,所以守恒量单独累加未吸附的 rawY,rawY − startY 恰等于逐帧 vy 之和。这不是近似:整数加法与整数地板除在 Python 与 TS 里逐位一致,需要容差的只有「帧数」这一个整数维度,不需要任何浮点比较。

W 层:把六个字段画出来

web/build_proto08.mjs 复用 web/pack.mjs 的 bundle(),把 rng.ts / digest.ts / platformer.ts 三个模块擦类型后内联进单文件 out/proto08.html,再把六个字段画到 canvas 上:平台条(宽度 = 落点判定)、橙色虚线 apex(跳跃高度)、终点旗(通关判定)、玩家方块(青 = 着地、粉 = 空中),外加 x / y / vx / vy / 土狼 / 缓冲的实时读数。页面暴露 globalThis.__ashdeck,供 Playwright 注入逐帧命令串并取回 digest。

// file: web/build_proto08.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto08.mjs
//
// 卷三 8.1 平台跳跃的机制解剖(W 层):把品类内核 core/genre/platformer.ts 与共享底座
// rng.ts / digest.ts 按手工拓扑序擦类型后,连同 config/gd_proto_08_platformer.json 一起
// 内联进**一个** HTML(out/proto08.html)。页面暴露 globalThis.__ashdeck:
//   run(seq?) -> 品类摘要(与 P 层测试冻结的那条逐字相同);snapshot() -> 最近快照;
//   proxy() -> 本关要求的容差帧数(代理指标,只做相对比较,禁绝对化);canvasDrawn。
// 构建期顺手在 Node 侧跑一遍内核,把 nodeDigest 落进 out/proto08_build.json 作为「P 侧」证据。
// 擦类型复用 web/pack.mjs 的 bundle(),本章不重写任何擦除逻辑。

import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { pfRun, pfDigest, pfReqTol, pfApexMilli } from "../core/genre/platformer.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, ".."); // $HOME/.gdbook/ashdeck

// 手工拓扑序:先声明后使用。去掉跨文件 import 后同一作用域内不得重名。
const MODULES = [
  "core/src/ashdeck/rng.ts",
  "core/src/ashdeck/digest.ts",
  "core/genre/platformer.ts",
];

const spec = JSON.parse(readFileSync(join(ROOT, "config/gd_proto_08_platformer.json"), "utf8"));
const MAXN = 16; // 与 P 层同一口径:±16 帧
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "proto08.html");

const script = core + `

const SPEC = ` + inlineConfig(spec) + `;
const MAXN = ` + MAXN + `;
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
let snap = null;

// 世界坐标 -> 画布像素。整数定点世界(千分格)只在这里做一次线性映射,物理本身不含浮点。
const WX = 40000, YS = 0.04;
const sx = (x) => 20 + (x * (cv.width - 40)) / WX;
const sy = (y) => 300 - y * YS;

// 六个机制字段的画法:平台条(宽度=落点判定)、终点旗(通关判定)、玩家方块(青=着地 粉=空中)、
// 以及一条把 apex 高度画出来的虚线(跳跃高度 = jumpVel^2 / (2*gravity),整数地板除)。
function render(s) {
  const r = SPEC.rules;
  ctx.fillStyle = "#111827";
  ctx.fillRect(0, 0, cv.width, cv.height);
  ctx.font = "13px system-ui, sans-serif";
  for (const pf of r.platforms) {
    const x0 = sx(pf.x), x1 = sx(pf.x + pf.w);
    ctx.fillStyle = "#334155";
    ctx.fillRect(x0, sy(pf.top), x1 - x0, 300 - sy(pf.top) + 40);
    ctx.fillStyle = "#64748b";
    ctx.fillText("w=" + pf.w, x0 + 4, sy(pf.top) + 16);
  }
  const apex = sy(pfApexMilli(SPEC));
  ctx.strokeStyle = "#f59e0b";
  ctx.setLineDash([4, 4]);
  ctx.beginPath();
  ctx.moveTo(20, apex);
  ctx.lineTo(cv.width - 20, apex);
  ctx.stroke();
  ctx.setLineDash([]);
  ctx.fillStyle = "#f59e0b";
  ctx.fillRect(sx(r.goalX), sy(0) - 40, 3, 40);
  const px = sx(s.x), py = sy(s.y);
  ctx.fillStyle = s.onGround ? "#38bdf8" : "#f472b6";
  ctx.fillRect(px - 5, py - 14, 10, 14);
  ctx.fillStyle = "#e2e8f0";
  ctx.fillText("帧 " + s.frame + "/" + s.frameLimit + "   x " + s.x + "   y " + s.y +
    "   vx " + s.vx + "   vy " + s.vy, 20, 26);
  ctx.fillText("着地 " + (s.onGround ? "是" : "否") + "   跳跃 " + s.jumps + "   落点 " +
    s.landed + "/" + s.platformCount + "   最高点 " + s.maxHeight + "   剩余 " + s.remainingX +
    "   " + (s.won ? "通关" : s.over ? "未通关" : "进行中"), 20, 46);
  ctx.fillText("虚线 = apex " + pfApexMilli(SPEC) + "(千分格)   土狼 " + r.coyoteFrames +
    " 帧   缓冲 " + r.bufferFrames + " 帧", 20, 66);
  document.getElementById("out").textContent = pfDigest(s);
  document.getElementById("tol").textContent = String(pfReqTol(SPEC, MAXN));
}

globalThis.__ashdeck = {
  spec: SPEC,
  run(seq) {
    const sp = JSON.parse(JSON.stringify(SPEC));
    snap = pfRun(sp, typeof seq === "string" ? seq : undefined);
    render(snap);
    return pfDigest(snap);
  },
  snapshot() { return snap; },
  proxy() { return pfReqTol(SPEC, MAXN); },
  canvasDrawn: false,
};

globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真,
// 让通用验证器的 waitForFunction 等到「已合成一帧」而不是「已调用 draw」。
requestAnimationFrame(() => requestAnimationFrame(() => {
  globalThis.__ashdeck.canvasDrawn = true;
}));
`;

const body = `<h1>《灰烬牌库》· 平台跳跃变体(第 8 章)</h1>
<div class="sub">同一份规格骨架,只覆盖 rules / params 两段:水平速度、跳跃高度、重力、土狼时间、输入缓冲、平台判定</div>
<canvas id="cv" width="640" height="360"></canvas>
<div class="card"><div class="k">品类摘要 digest(与 P 层测试冻结的那条逐字相同)</div><div id="out" class="k">—</div></div>
<div class="card"><div class="k">代理指标 · 本关要求的容差帧数(只做相对比较,禁绝对化)</div><div class="big" id="tol">—</div></div>`;

const html = page({
  title: "《灰烬牌库》· 第 8.1 节平台跳跃的机制解剖",
  subtitle: "品类内核 + 共享底座:读同一份骨架,产出同一条摘要",
  body,
  script,
});
const bytes = writePage(OUT, html);

const nodeDigest = pfDigest(pfRun(spec));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "proto08_build.json"),
  JSON.stringify({
    spec: spec.id, modules: MODULES.length, kernelChars: core.length,
    page: "out/proto08.html", pageBytes: bytes, maxN: MAXN, nodeDigest,
  }, null, 2) + "\n");

console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built " + OUT + "  " + bytes + " B");
console.log("NODE_DIGEST=" + nodeDigest);

构建与真玩分两步跑——先构建、再断言,只跑断言等于在验上一版页面:

# snippet: 构建 + 无头真玩
NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto08.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs --page out/proto08.html --shot out/shots/proto08.png
modules=3 kernelChars=9346
built /Users/bingrong.yan/.gdbook/ashdeck/out/proto08.html  16569 B
NODE_DIGEST={"block":4750,"cardCount":3,"damageConserved":true,"dealt":30000,"enemyHp":0,"energy":250,"hand":["P1","P2"],"over":true,"played":2,"playerHp":0,"turn":120,"won":true}
=== 通用断言 ===
  ok   两遍独立加载 digest 逐字一致(确定性)
  ok   canvas 已绘制(230400 px >= 1)
  ok   截图已落盘(81782 B)
PAGE_DIGEST={"block":4750,"cardCount":3,"damageConserved":true,"dealt":30000,"enemyHp":0,"energy":250,"hand":["P1","P2"],"over":true,"played":2,"playerHp":0,"turn":120,"won":true}
OK W 层通过

三条断言全绿:PAGE_DIGEST 与构建期 Node 侧算出的 NODE_DIGEST 一字不差——页面与内核本来就是同一段源码的两个宿主。摘要里 played=2、hand=["P1","P2"] 是两次落点,turn=120 是通关帧,block=4750 是实测最高点(maxHeight),energy=250 是落点那一刻的水平速度。

P 层:把机制字段接到断言上

W 层把字段画出来,P 层把字段断言掉。core/test/gd_proto_08_1_anatomy.test.ts 的做法是:改一个字段,最终摘要必须变;改不动,就说明那一格是文档装饰而不是机制。基线关卡上六格全部可观测,唯一要专门构造的是土狼时间——基线策略两跳都从地面起跳,土狼根本用不上,所以要另造一条「越过平台右沿才按跳」的探测串才能把它观测出来。内核的帧循环与状态机如下:

// snippet: core/genre/platformer.ts(节选:pfApexMilli / pfRun 帧循环 / pfCommonState)
/** 理论跳跃高度(千分格):apex = 初速² / (2·重力),整数地板除。 */
export function pfApexMilli(spec: PfSpec): number {
  const v = spec.rules.jumpVel, g = spec.rules.gravity;
  return Math.floor((v * v) / (2 * g));
}

/** 规格驱动的确定性状态机:逐帧「取水平输入 → 起跳判定(土狼 + 缓冲)→ 积分 → 平台吸附」。 */
export function pfRun(spec: PfSpec, strategy?: string): PfSnapshot {
  // …(前置:读 rules / params,初始化 x / y / vx / vy / onGround / coyote / buffer)
  for (let i = 0; i < seq.length && !over; i += 1) {
    const ch = seq[i];
    frame = i + 1;
    if (ch === ">" || ch === "r") vx = r.runSpeed;
    else if (ch === "<" || ch === "l") vx = -r.runSpeed;
    else vx = 0;
    if (JUMP_CHARS.has(ch)) buffer = r.bufferFrames;
    // 起跳:缓冲还有效、且(在地面或土狼时间内)。
    if (buffer > 0 && (onGround || coyote > 0)) {
      vy = r.jumpVel; onGround = false; coyote = 0; buffer = 0; jumps += 1;
    }
    const prevY = y;
    x += vx; sumVx += vx;
    vy -= r.gravity; y += vy; sumVy += vy; rawY += vy;
    // 平台吸附:下落中、本帧跨过平台顶面、且水平落在平台范围内。
    let landedNow = false;
    if (vy <= 0) {
      for (let k = 0; k < r.platforms.length; k += 1) {
        const pf = r.platforms[k];
        if (x >= pf.x && x < pf.x + pf.w && prevY >= pf.top && y <= pf.top) {
          y = pf.top; vy = 0; landedNow = true;
          if (!onGround) { landed += 1; landings.push("P" + k); }
          break;
        }
      }
    }
    if (landedNow) { onGround = true; coyote = r.coyoteFrames; }
    else if (onGround) { onGround = false; coyote = r.coyoteFrames; }
    else if (coyote > 0) coyote -= 1;
    if (buffer > 0) buffer -= 1;
    if (y > maxHeight) maxHeight = y;
    // 通关 = 站在平台上抵达终点(掉进缺口一路前冲不算通关)。
    if (onGround && x >= r.goalX) { over = true; won = true; }
    else if (frame >= p.frameLimit) { over = true; won = false; }
    else if (y < -p.fallLimit) { over = true; won = false; }
  }
  // …(收尾:返回含 xConserved / yConserved / apexMilli 的快照)
}

/** 把品类状态投影进共享摘要契约的 12 个槽位(与 digestSnap 的键集逐个对应)。 */
export function pfCommonState(s: PfSnapshot) {
  return {
    block: s.maxHeight, cardCount: s.platformCount, damageConserved: s.xConserved && s.yConserved,
    dealt: s.x, enemyHp: s.remainingX, energy: s.vx, hand: s.landings, over: s.over,
    played: s.landed, playerHp: s.y, turn: s.frame, won: s.won,
  };
}
水平速度     runSpeed 250 -> 200            ok
跳跃高度     jumpVel 500 -> 600             ok
重力       gravity 25 -> 30               ok
土狼时间     coyote 6 -> 0(边缘起跳)            ok
输入缓冲     buffer 6 -> 0                  ok
平台判定     platforms[1].w 3000 -> 1000    ok
✔ 基线摘要冻结:与实跑逐字一致
✔ 确定性:同规格同策略两次运行摘要相同
✔ 不变量在基线、空策略、扰动三种情形下都成立
✔ 整数守恒:水平位移 = 逐帧 vx 之和,未吸附垂直位移 = 逐帧 vy 之和
✔ 跳跃高度是机制:apex 闭式 = 5000,实测最高点 4750(首帧已扣一次重力)
✔ 六格机制字段表逐行给出「改了就变」的证据
✔ 土狼时间与输入缓冲各自可观测:土狼 6→0 边缘跳失败,缓冲 6→0 第二跳丢失
✔ 平台判定是机制:抬高第一块落点平台会改变落点归属
✔ 品类状态投影回共享的 12 键摘要契约
✔ 规格骨架与 play_spec.json 同构:六段一个不缺,naming 覆盖共享键
ℹ tests 10
ℹ pass 10
ℹ fail 0

十条全绿。基线摘要冻结在 turn=120、played=2、block=4750、won=true——与 W 层页面里那条 PAGE_DIGEST 逐字相同。最后一条把 naming 的八个键与 play_spec.json 的键集对上,等于用断言证明了「同一份骨架」。

本节实跑清单

命令产物实测
node web/build_proto08.mjsout/proto08.html3 模块内联,内核 9346 字符,页面 16569 B
node web/verify_mech.mjs --page out/proto08.html --shot out/shots/proto08.pngout/shots/proto08.png3 断言全绿,截图 81782 B
node --test 'core/test/gd_proto_08_1_anatomy.test.ts'断言结果10 测试全绿,0 失败

六个字段已经拆开、各自可断言。但字段拆开只说明「机制是什么」,不说明「它有多难」——下一节把「逐跳按压偏移」缩放成一条可算的技巧曲线,量一量这关到底要玩家精确到几帧。

阅读导航:上一节:7.3 三消的节奏 · 下一节:8.2 平台跳跃的技巧曲线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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