12.2 手感参数的可计算化
站内 《独立游戏核心循环与手感设计》 把手感参数列成一张散文清单:跳跃高度、重力、土狼时间、输入缓冲、屏幕震动衰减……每个都给了推荐区间,但没有一个能被断言。本节把这些参数可计算化:哪些能用整数精确表达、哪些只能按声明容差断言,界线画清楚。这是 12.1 那台状态机的参数面。
哪些参数能用整数表达
八个参数全部是整数:gravity / jumpVelocity / platformY / coyoteFrames / bufferFrames / knockback / knockbackDecay / hitstopFrames。它们分两类:
- 帧数类(土狼时间、输入缓冲、硬直、击退衰减周期):帧数就是它的原生单位,整数是精确表达,可以断言逐位相等。
- 单位类(重力、起跳速度、台高、击退幅度):在「每帧加一个整数」的定点世界里同样是整数,位移与速度递推不产生任何小数,也可以断言逐位相等。
唯一不能逐位相等的是感知类派生量:把 7 帧滞空换算成毫秒,7 × 1000 / 60 = 116.67,只能地板取 116,再按声明容差(60 fps 下一帧约 17 ms)断言。这是本站三卷通用的纪律:整数类逐位相等,感知类按容差——绝不对感知类派生量写「逐位相等」。
| 参数 | 值 | 类型 | 断言方式 |
|---|---|---|---|
gravity | 3 | 单位/帧 | 逐位相等 |
jumpVelocity | 9 | 单位/帧 | 逐位相等 |
platformY | 24 | 单位 | 逐位相等 |
coyoteFrames | 3 | 帧 | 逐位相等(窗口边界) |
bufferFrames | 3 | 帧 | 逐位相等(窗口边界) |
knockback / knockbackDecay | 6 / 2 | 单位/帧 | 逐位相等(整数递推) |
hitstopFrames | 2 | 帧 | 逐位相等 |
| 滞空毫秒 | 116 | 感知派生 | 声明容差 ±17 ms |
W 层:把四种窗口画出来
web/build_proto12.mjs 把状态机内联进单文件 HTML,再把四种窗口的帧序列并排画出来:蓝线是高度曲线,竖线是按键帧,粉点是反馈帧。它复用 web/pack.mjs 的 bundle() 与 inlineConfig(),不自写模块擦除逻辑。
// file: web/build_proto12.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto12.mjs
//
// 12.1 手感不是渲染:把 core/genre/gamefeel.ts 的**整数帧**输入-反馈状态机内联进
// **一个** HTML(out/proto12.html)。页面把同一段输入脚本在四种时序窗口下的帧序列
// 画出来——高度曲线、按键帧、反馈帧——并暴露 globalThis.__ashdeck:
// run(mode?) -> 帧序列 digest(整数摘要,与 P 层 TS 逐字相同);snapshot() -> 帧序列;
// feedback() -> 反馈帧号;canvasDrawn。
// 构建期顺手在 Node 侧跑一遍 baseline,把 nodeDigest 落进 out/gd_proto_12_build.json,
// 作为 P / W 两处对照的「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 { simulateFrames, feelDigest, feedbackFrames, ledgeInputs, FEEL_MODES, FEEL_PARAMS } from "../core/genre/gamefeel.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, ".."); // $HOME/.gdbook/ashdeck
// 手工拓扑序:先声明后使用。本章只有一个自足内核,不含 core/src/ashdeck/*。
const MODULES = ["core/genre/gamefeel.ts"];
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "proto12.html");
const script = core + `
const MODES = ` + inlineConfig(FEEL_MODES) + `;
const INPUTS = ` + inlineConfig(ledgeInputs()) + `;
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
let states = null;
let mode = "baseline";
function trace(m) {
const p = MODES[m] || MODES.baseline;
return simulateFrames(p, INPUTS);
}
function render(m, st) {
const W = cv.width, H = cv.height;
const top = 44, bot = 250;
const maxY = 36;
const sx = (W - 40) / (st.length - 1);
const sy = (y) => bot - Math.floor(((bot - top) * y) / maxY);
ctx.fillStyle = "#111827";
ctx.fillRect(0, 0, W, H);
ctx.font = "12px system-ui, sans-serif";
// 台面高度参考线
ctx.strokeStyle = "#334155";
ctx.beginPath();
ctx.moveTo(20, sy(MODES[m] ? MODES[m].platformY : 24));
ctx.lineTo(W - 20, sy(MODES[m] ? MODES[m].platformY : 24));
ctx.stroke();
// 高度曲线
ctx.strokeStyle = "#38bdf8";
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < st.length; i += 1) {
const x = 20 + Math.floor(i * sx);
const y = sy(st[i].y);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// 按键帧(竖线)与反馈帧(圆点)
for (let i = 0; i < INPUTS.length; i += 1) {
if (!INPUTS[i].jump) continue;
const x = 20 + Math.floor((i + 1) * sx);
ctx.strokeStyle = "#fbbf24";
ctx.beginPath();
ctx.moveTo(x, top - 8);
ctx.lineTo(x, bot + 8);
ctx.stroke();
}
const fb = feedbackFrames(st);
ctx.fillStyle = "#f472b6";
for (const f of fb) {
const x = 20 + Math.floor(f * sx);
const y = sy(st[f].y);
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
}
const apex = st.reduce((a, s) => (s.y > a ? s.y : a), 0);
ctx.fillStyle = "#e2e8f0";
ctx.fillText("模式 " + m + " · 起跳 " + st[st.length - 1].jumps + " 次 · 最高点 " + apex, 20, 24);
ctx.fillText("黄线=按键帧 · 粉点=反馈帧 · 蓝线=高度曲线(台高 " + (MODES[m] ? MODES[m].platformY : 24) + ")", 20, bot + 30);
document.getElementById("out").textContent = feelDigest(st);
document.getElementById("fb").textContent = "[" + fb.join(", ") + "]";
document.getElementById("jumps").textContent = String(st[st.length - 1].jumps);
}
globalThis.__ashdeck = {
modes: Object.keys(MODES),
run(m) {
mode = m || "baseline";
states = trace(mode);
render(mode, states);
return feelDigest(states);
},
snapshot() { return states; },
feedback() { return feedbackFrames(states); },
canvasDrawn: false,
};
globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真,
// 让通用验证器的 waitForFunction 等到「已合成一帧」而不是「已调用 draw」。
requestAnimationFrame(() => requestAnimationFrame(() => {
globalThis.__ashdeck.canvasDrawn = true;
}));
`;
const body = `<h1>《灰烬牌库》· 手感不是渲染</h1>
<div class="sub">同一段输入脚本、同一段美术表现:只有土狼时间与输入缓冲两组整数帧不同</div>
<canvas id="cv" width="640" height="360"></canvas>
<div class="card"><div class="k">帧序列 digest(baseline,应与 P 层逐字相同)</div><div id="out" class="k">—</div></div>
<div class="row">
<div class="card"><div class="k">反馈帧号</div><div class="big" id="fb">—</div></div>
<div class="card"><div class="k">起跳次数</div><div class="big" id="jumps">—</div></div>
</div>`;
const html = page({ title: "《灰烬牌库》· 第 12.1 节手感不是渲染",
subtitle: "整数帧输入-反馈状态机 · 四种时序窗口", body, script });
const bytes = writePage(OUT, html);
const nodeDigest = feelDigest(simulateFrames(FEEL_PARAMS, ledgeInputs()));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "gd_proto_12_build.json"),
JSON.stringify({ spec: "gamefeel", modules: MODULES.length, kernelChars: core.length,
page: "out/proto12.html", pageBytes: bytes, modes: Object.keys(FEEL_MODES),
frames: ledgeInputs().length, nodeDigest }, null, 2) + "\n");
console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built " + OUT + " " + bytes + " B");
console.log("NODE_DIGEST=" + nodeDigest);
构建与无头真玩:
# snippet: 构建 W 层单文件页面并做无头真玩复验
NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto12.mjs
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs --page out/proto12.html --shot out/shots/proto12.png
modules=1 kernelChars=4325
built /Users/bingrong.yan/.gdbook/ashdeck/out/proto12.html 11728 B
NODE_DIGEST={"frames":34,"jumps":3,"apex":33,"feedback":[7,21,28]}
=== 通用断言 ===
ok 两遍独立加载 digest 逐字一致(确定性)
ok canvas 已绘制(230400 px >= 1)
ok 截图已落盘(69924 B)
PAGE_DIGEST={"frames":34,"jumps":3,"apex":33,"feedback":[7,21,28]}
OK W 层通过
页面里那 230400 个非空像素证明「真画了」,PAGE_DIGEST 与 NODE_DIGEST 逐字相同证明「画的是同一段帧序列」。
P 层:把参数边界断言掉
// file: core/test/gd_proto_12_2_game_feel_params_computable.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_12_2_game_feel_params_computable.test.ts'
//
// 12.2 手感参数的可计算化(P 层)。把土狼时间、输入缓冲、重力、击退衰减全部写成
// **整数帧 / 定点参数**:能精确表达的(整数类)就断言逐位相等;只有感知类派生量
// (帧→毫秒)才按**声明容差**断言。本文件同时给出「参数敏感度表」:哪些参数会改变
// 帧序列 digest,哪些只影响表现量而不进时序摘要。
import { test } from "node:test";
import assert from "node:assert/strict";
import {
simulateFrames, feelDigest, feedbackFrames, airtimeFrames, airtimeMillisFloor,
FEEL_PARAMS, FEEL_MODES,
} from "../genre/gamefeel.ts";
const PARAM_KEYS = [
"gravity", "jumpVelocity", "platformY", "coyoteFrames", "bufferFrames",
"knockback", "knockbackDecay", "hitstopFrames",
];
test("参数全整数:八个手感参数都是 Number.isInteger,且与声明值逐位相等", () => {
for (const k of PARAM_KEYS) {
const v = (FEEL_PARAMS as unknown as Record<string, number>)[k];
assert.equal(Number.isInteger(v), true, `${k} 必须是整数`);
}
assert.deepEqual(FEEL_PARAMS, {
gravity: 3, jumpVelocity: 9, platformY: 24, coyoteFrames: 3, bufferFrames: 3,
knockback: 6, knockbackDecay: 2, hitstopFrames: 2,
});
});
test("土狼时间的整数边界:离地第 3 帧可跳,第 4 帧不可(coyoteFrames=3)", () => {
const inputs = Array.from({ length: 14 }, (_, f) => ({ jump: f === 6, onPlatform: f < 4 }));
const p3 = { ...FEEL_PARAMS, coyoteFrames: 3, bufferFrames: 0 };
const p2 = { ...FEEL_PARAMS, coyoteFrames: 2, bufferFrames: 0 };
assert.deepEqual(feedbackFrames(simulateFrames(p3, inputs)), [7]);
assert.deepEqual(feedbackFrames(simulateFrames(p2, inputs)), []);
});
test("输入缓冲的整数边界:落地前 2 帧的输入,buffer=3 生效、buffer=2 丢失", () => {
const inputs = Array.from({ length: 12 }, (_, f) => ({ jump: f === 0 || f === 5, onPlatform: true }));
const base = { ...FEEL_PARAMS, platformY: 0, coyoteFrames: 0 };
assert.deepEqual(feedbackFrames(simulateFrames({ ...base, bufferFrames: 3 }, inputs)), [1, 8]);
assert.deepEqual(feedbackFrames(simulateFrames({ ...base, bufferFrames: 2 }, inputs)), [1]);
assert.deepEqual(feedbackFrames(simulateFrames({ ...base, bufferFrames: 0 }, inputs)), [1]);
});
test("击退衰减是精确的整数递推:shake_k = max(0, knockback - k*decay)", () => {
const inputs = Array.from({ length: 5 }, () => ({ jump: false, onPlatform: true }));
const st = simulateFrames(FEEL_PARAMS, [{ jump: true, onPlatform: true }, ...inputs]);
const seq = st.map((s) => s.shake);
assert.deepEqual(seq, [0, 6, 4, 2, 0, 0, 0]);
for (let k = 1; k < seq.length; k += 1) {
assert.equal(seq[k], Math.max(0, FEEL_PARAMS.knockback - (k - 1) * FEEL_PARAMS.knockbackDecay));
}
});
test("感知派生量按声明容差断言:帧数精确,毫秒按一帧的容差(禁写逐位相等)", () => {
const frames = airtimeFrames(FEEL_PARAMS);
assert.equal(frames, 7); // 整数类:逐位相等
const msFloor = airtimeMillisFloor(frames, 60);
assert.equal(msFloor, 116);
const tolMs = 17; // 声明容差:60 fps 下约一帧
const exact = (frames * 1000) / 60;
assert.ok(Math.abs(msFloor - exact) <= tolMs, "毫秒是感知类派生量,只能按声明容差断言");
});
test("参数敏感度表:哪些参数改帧序列 digest,哪些只改表现量", () => {
const inputs = Array.from({ length: 34 }, (_, f) => ({ jump: f === 6 || f === 20 || f === 26, onPlatform: f < 4 }));
const base = feelDigest(simulateFrames(FEEL_PARAMS, inputs));
const probe: Record<string, number> = {
gravity: 4, jumpVelocity: 10, platformY: 25, coyoteFrames: 0, bufferFrames: 0,
knockback: 7, knockbackDecay: 3, hitstopFrames: 3,
};
const changesTiming: Record<string, boolean> = {};
for (const k of PARAM_KEYS) {
const p = { ...FEEL_PARAMS, [k]: probe[k] };
changesTiming[k] = feelDigest(simulateFrames(p, inputs)) !== base;
}
assert.deepEqual(changesTiming, {
gravity: true, jumpVelocity: true, platformY: true, coyoteFrames: true, bufferFrames: true,
knockback: false, knockbackDecay: false, hitstopFrames: false,
});
// 四种窗口模式全部是「整数参数覆盖」,与 FEEL_PARAMS 同构。
assert.equal(Object.keys(FEEL_MODES).length, 4);
assert.deepEqual(Object.keys(FEEL_MODES).sort(), ["baseline", "noBuffer", "noCoyote", "tight"]);
});
实跑:
# snippet: 跑 12.2 的 P 层断言(6 条)
node --test 'core/test/gd_proto_12_2_game_feel_params_computable.test.ts'
✔ 参数全整数:八个手感参数都是 Number.isInteger,且与声明值逐位相等 (1.029167ms)
✔ 土狼时间的整数边界:离地第 3 帧可跳,第 4 帧不可(coyoteFrames=3) (0.269084ms)
✔ 输入缓冲的整数边界:落地前 2 帧的输入,buffer=3 生效、buffer=2 丢失 (0.125209ms)
✔ 击退衰减是精确的整数递推:shake_k = max(0, knockback - k*decay) (0.614917ms)
✔ 感知派生量按声明容差断言:帧数精确,毫秒按一帧的容差(禁写逐位相等) (0.194333ms)
✔ 参数敏感度表:哪些参数改帧序列 digest,哪些只改表现量 (0.458459ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 547.603834
六条全绿。第五条是本节的口径样板:frames = 7 是整数类,写 assert.equal;msFloor = 116 是感知派生量,只能写 Math.abs(...) <= 17。第六条那张敏感度表划清了两种参数:改 gravity / jumpVelocity / platformY / coyoteFrames / bufferFrames 会改变帧序列摘要,改 knockback / knockbackDecay / hitstopFrames 不会——它们只改表现量,不进时序。
阅读导航:上一节:12.1 手感不是渲染:输入-反馈的机制 · 下一节:12.3 手感的度量与断言 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。