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.jumpVel | apex = 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.mjs | out/proto08.html | 3 模块内联,内核 9346 字符,页面 16569 B |
node web/verify_mech.mjs --page out/proto08.html --shot out/shots/proto08.png | out/shots/proto08.png | 3 断言全绿,截图 81782 B |
node --test 'core/test/gd_proto_08_1_anatomy.test.ts' | 断言结果 | 10 测试全绿,0 失败 |
六个字段已经拆开、各自可断言。但字段拆开只说明「机制是什么」,不说明「它有多难」——下一节把「逐跳按压偏移」缩放成一条可算的技巧曲线,量一量这关到底要玩家精确到几帧。
阅读导航:上一节:7.3 三消的节奏 · 下一节:8.2 平台跳跃的技巧曲线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。