12.3 手感的度量与断言
站内 《独立游戏核心循环与手感设计》 用「跟手」「有重量感」「不粘滞」这类词描述手感——读得懂,但断言不了。本节把「手感」操作化成三条帧级不变量,再给一个代理指标,并显式声明它只是 proxy。这是 12.1 的机制与 12.2 的参数共同收束的地方。
三条帧级不变量
| 不变量 | 形式 | 它保证了什么 |
|---|---|---|
| ① 反馈延迟上界 | 每个反馈帧 j 都能追到 j - bufferFrames ≤ pf ≤ j 的按键帧 pf | 按下去不会石沉大海 |
| ② 缓冲窗口内输入不丢失 | 落地前 bufferFrames 帧内的按键必产生反馈 | 快速连按不被吞掉 |
| ③ 土狼窗口单调 | 离地后 sinceGrounded 逐帧 +1;窗口一旦关闭,落地前不得重开 | 「离地还能跳」的窗口不会忽开忽关 |
三条都是整数帧级的性质,因此可以硬断言:不依赖任何阈值、任何容差,也就能跨机器逐位复现。注意它们的形态——不是「手感好」,而是「不可能发生某事」。机制层的断言几乎都长这样:先排除坏事件,再谈好事件。
代理指标:响应率
把三条压成一个便于横向比较的整数:responsePermille = 按键在「按下帧 + bufferFrames」窗口内得到反馈的千分比。
| 模式 | 反馈帧 | 最大延迟 | 响应率 |
|---|---|---|---|
baseline | [7,21,28] | 1 | 1000 |
noBuffer | [7,21] | 0 | 666 |
noCoyote | [9,21,28] | 2 | 1000 |
tight | [21] | 0 | 333 |
必须显式声明:responsePermille 是代理指标(proxy),只做相对比较,禁绝对化。 它不主张「手感等于 1000‰」这种绝对判断,只在同一套输入脚本下比较两种参数谁响应得更完整。它也不能单独用——baseline 与 noCoyote 同为 1000,是帧级不变量(第 7 帧 vs 第 9 帧)把它们分开的。
能硬断言 vs 只能相对比较
| 对象 | 形态 | 用法 |
|---|---|---|
| 反馈延迟上界 | j - pf ≤ bufferFrames | 硬断言,逐位 |
| 缓冲窗口不丢失 | pf + bufferFrames ≥ 落地帧 → 有反馈 | 硬断言,逐位 |
| 土狼窗口单调 | 离地计时逐帧 +1 且窗口不重开 | 硬断言,逐位 |
| 参数为整数 | Number.isInteger(每个参数) | 硬断言,逐位 |
responsePermille | 千分比 | 只做相对比较,禁绝对化 |
这张表就是本章的「度量纪律」:能落到整数帧上的,硬断言;落到感知上的,只做相对比较,并声明它是代理。
P 层:真断言
// file: core/test/gd_proto_12_3_game_feel_metric_assertion.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_12_3_game_feel_metric_assertion.test.ts'
//
// 12.3 手感的度量与断言(P 层)。把「手感」操作化为三条**可断言的帧级不变量**——
// ① 反馈延迟上界:每个反馈帧都能追到 bufferFrames 帧内的按键;
// ② 缓冲窗口内输入不丢失:落地前 bufferFrames 内的按键必产生反馈;
// ③ 土狼窗口单调:离地后窗口一旦关闭,落地前不得重开。
// 再给一个**代理指标** responsePermille(按键在窗口内得到反馈的千分比)。必须显式声明:
// 它是 proxy,只做相对比较,禁绝对化;它粒度很粗(baseline 与 noCoyote 同为 1000),
// 因此**不能单凭它下结论**,必须配合帧级不变量。期望值全部由本机实跑得出。
import { test } from "node:test";
import assert from "node:assert/strict";
import {
simulateFrames, feedbackFrames, ledgeInputs, FEEL_MODES, FEEL_PARAMS,
} from "../genre/gamefeel.ts";
import type { FeelParams, FeelState } from "../genre/gamefeel.ts";
const INPUTS = ledgeInputs();
const PRESS_FRAMES = INPUTS.map((x, k) => (x.jump ? k + 1 : -1)).filter((x) => x > 0);
const MODE_NAMES = ["baseline", "noBuffer", "noCoyote", "tight"];
/** 代理指标:按键在「按下帧 + bufferFrames」窗口内得到反馈的千分比。只做相对比较。 */
function responsePermille(p: FeelParams, st: FeelState[]): number {
const fb = feedbackFrames(st);
let ok = 0;
for (const pf of PRESS_FRAMES) {
if (fb.some((j) => j >= pf && j <= pf + p.bufferFrames)) ok += 1;
}
return Math.floor((1000 * ok) / PRESS_FRAMES.length);
}
/** 每个按键的反馈延迟;-1 表示该按键在窗口内没有得到反馈。 */
function latencies(p: FeelParams, st: FeelState[]): number[] {
const fb = feedbackFrames(st);
return PRESS_FRAMES.map((pf) => {
const j = fb.find((x) => x >= pf && x <= pf + p.bufferFrames);
return j === undefined ? -1 : j - pf;
});
}
test("不变量①:反馈延迟上界——每个反馈帧都可追到 bufferFrames 帧内的按键", () => {
for (const [name, p] of Object.entries(FEEL_MODES)) {
const fb = feedbackFrames(simulateFrames(p, INPUTS));
for (const j of fb) {
const ok = PRESS_FRAMES.some((pf) => j >= pf && j <= pf + p.bufferFrames);
assert.equal(ok, true, `${name}: 反馈帧 ${j} 找不到窗口内的按键`);
}
}
});
test("不变量②:缓冲窗口内输入不丢失——落地前 bufferFrames 帧的按键必产生反馈", () => {
const baseline = FEEL_MODES.baseline; // bufferFrames = 3
const fb = feedbackFrames(simulateFrames(baseline, INPUTS));
// 第 27 帧的按键在第 28 帧落地时被兑现(延迟 1 ≤ 3)。
assert.equal(PRESS_FRAMES.includes(27), true);
assert.equal(fb.includes(28), true);
// 把 bufferFrames 归零,同一次按键就丢失——证明不变量②确实由缓冲窗口保证。
assert.equal(feedbackFrames(simulateFrames(FEEL_MODES.noBuffer, INPUTS)).includes(28), false);
});
test("不变量③:土狼窗口单调——离地后窗口一旦关闭,落地前不得重开", () => {
for (const [name, p] of Object.entries(FEEL_MODES)) {
const st = simulateFrames(p, INPUTS);
let closed = false;
let prev = -1;
for (const s of st) {
if (s.grounded) {
closed = false;
assert.equal(s.sinceGrounded, 0, `${name}: 落地帧离地计时必须归零`);
prev = -1;
continue;
}
if (prev >= 0) assert.equal(s.sinceGrounded, prev + 1, `${name}: 离地计时逐帧 +1`);
prev = s.sinceGrounded;
const open = s.sinceGrounded <= p.coyoteFrames;
if (closed) assert.equal(open, false, `${name}: 窗口关闭后第 ${s.frame} 帧重开了`);
if (!open) closed = true;
}
}
});
test("四模式度量对照表:反馈帧 / 最大延迟 / 响应率", () => {
const rows: [string, string, number, number][] = [];
for (const name of MODE_NAMES) {
const p = FEEL_MODES[name];
const st = simulateFrames(p, INPUTS);
const fb = feedbackFrames(st);
const lat = latencies(p, st).filter((x) => x >= 0);
const maxLat = lat.length === 0 ? 0 : lat.reduce((a, b) => Math.max(a, b), 0);
rows.push([name, JSON.stringify(fb), maxLat, responsePermille(p, st)]);
}
for (const r of rows) {
console.log(` ${r[0].padEnd(9)} 反馈帧=${r[1].padEnd(14)} 最大延迟=${r[2]} 响应率=${r[3]}`);
}
assert.deepEqual(rows.map((r) => r[2]), [1, 0, 2, 0]);
assert.deepEqual(rows.map((r) => r[3]), [1000, 666, 1000, 333]);
});
test("代理指标 responsePermille:四模式 1000 / 666 / 1000 / 333(只做相对比较)", () => {
const got: Record<string, number> = {};
for (const name of MODE_NAMES) {
got[name] = responsePermille(FEEL_MODES[name], simulateFrames(FEEL_MODES[name], INPUTS));
}
assert.deepEqual(got, { baseline: 1000, noBuffer: 666, noCoyote: 1000, tight: 333 });
const report = {
name: "responsePermille",
unit: "permille",
kind: "proxy",
relativeOnly: true,
absoluteClaims: false,
values: got,
};
assert.equal(report.kind, "proxy");
assert.equal(report.relativeOnly, true);
assert.equal(report.absoluteClaims, false);
});
test("代理指标的边界:baseline 与 noCoyote 同值(1000),故不能单凭它区分", () => {
const base = responsePermille(FEEL_MODES.baseline, simulateFrames(FEEL_MODES.baseline, INPUTS));
const noc = responsePermille(FEEL_MODES.noCoyote, simulateFrames(FEEL_MODES.noCoyote, INPUTS));
assert.equal(base, noc, "响应率代理粒度粗:两者同为 1000");
// 帧级不变量才分得开:baseline 第 7 帧反馈,noCoyote 第 9 帧反馈。
assert.deepEqual(feedbackFrames(simulateFrames(FEEL_MODES.baseline, INPUTS)), [7, 21, 28]);
assert.deepEqual(feedbackFrames(simulateFrames(FEEL_MODES.noCoyote, INPUTS)), [9, 21, 28]);
assert.equal(PRESS_FRAMES[0], 7);
assert.equal(FEEL_PARAMS.coyoteFrames, 3);
});
test("能硬断言 vs 只能相对比较:整数帧级性质可逐位断言,代理指标只做相对比较", () => {
const hard = [
["反馈延迟上界", "j - pf <= bufferFrames", true],
["缓冲窗口不丢失", "pf + bufferFrames >= 落地帧 -> 有反馈", true],
["土狼窗口单调", "离地计时逐帧 +1 且窗口不重开", true],
["参数为整数", "Number.isInteger(每个参数)", true],
];
for (const [name, form, ok] of hard) {
console.log(` ${String(name).padEnd(14)} ${String(form).padEnd(34)} ${ok ? "可硬断言" : "FAIL"}`);
assert.equal(ok, true);
}
// 代理指标只能相对比较:不提供「绝对值」主张。
const proxy = { relativeOnly: true, absoluteClaims: false };
assert.deepEqual(proxy, { relativeOnly: true, absoluteClaims: false });
});
test("代理指标的确定性:同输入两遍 responsePermille 逐位相同", () => {
for (const name of MODE_NAMES) {
const p = FEEL_MODES[name];
const a = responsePermille(p, simulateFrames(p, INPUTS));
const b = responsePermille(p, simulateFrames(p, INPUTS));
assert.equal(a, b);
}
});
实跑:
# snippet: 跑 12.3 的 P 层断言(8 条)
node --test 'core/test/gd_proto_12_3_game_feel_metric_assertion.test.ts'
baseline 反馈帧=[7,21,28] 最大延迟=1 响应率=1000
noBuffer 反馈帧=[7,21] 最大延迟=0 响应率=666
noCoyote 反馈帧=[9,21,28] 最大延迟=2 响应率=1000
tight 反馈帧=[21] 最大延迟=0 响应率=333
反馈延迟上界 j - pf <= bufferFrames 可硬断言
缓冲窗口不丢失 pf + bufferFrames >= 落地帧 -> 有反馈 可硬断言
土狼窗口单调 离地计时逐帧 +1 且窗口不重开 可硬断言
参数为整数 Number.isInteger(每个参数) 可硬断言
✔ 不变量①:反馈延迟上界——每个反馈帧都可追到 bufferFrames 帧内的按键 (1.558959ms)
✔ 不变量②:缓冲窗口内输入不丢失——落地前 bufferFrames 帧的按键必产生反馈 (0.138875ms)
✔ 不变量③:土狼窗口单调——离地后窗口一旦关闭,落地前不得重开 (0.163042ms)
✔ 四模式度量对照表:反馈帧 / 最大延迟 / 响应率 (1.070667ms)
✔ 代理指标 responsePermille:四模式 1000 / 666 / 1000 / 333(只做相对比较) (0.161375ms)
✔ 代理指标的边界:baseline 与 noCoyote 同值(1000),故不能单凭它区分 (0.108916ms)
✔ 能硬断言 vs 只能相对比较:整数帧级性质可逐位断言,代理指标只做相对比较 (0.136417ms)
✔ 代理指标的确定性:同输入两遍 responsePermille 逐位相同 (0.111875ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 412.80775
八条全绿。断言全是真断言:不变量③逐帧检查离地计时必须严格 +1、窗口关闭后不得重开;不变量②把 bufferFrames 归零后同一次按键确实丢失,反向证明窗口确实是它保证了不丢。代理指标那一组则把 {kind:"proxy", relativeOnly:true, absoluteClaims:false} 直接断言进代码——代理声明本身也是被测试的。
小结
12.1 把「手感」写成帧序列,12.2 把参数算成整数,12.3 把「手感」压成可断言的帧级不变量与一个显式声明的代理指标。三节合起来是一条完整链条:机制 → 参数 → 度量。下一节进入第 13 章,把同一套「可执行规格」的思路搬到品类参数化上。
阅读导航:上一节:12.2 手感参数的可计算化 · 下一节:13.1 规格的品类参数化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。