12.3 手感的度量与断言

把「手感」操作化为三条可断言的帧级不变量——反馈延迟上界、缓冲窗口内输入不丢失、土狼窗口单调——再用真断言跑一遍。另给一个代理指标 responsePermille(响应率千分比),并显式声明它只是 proxy:只做相对比较、禁绝对化,粒度粗到 baseline 与 noCoyote 同为 1000,必须靠帧级不变量才分得开。

12.3 手感的度量与断言

站内 《独立游戏核心循环与手感设计》 用「跟手」「有重量感」「不粘滞」这类词描述手感——读得懂,但断言不了。本节把「手感」操作化成三条帧级不变量,再给一个代理指标,并显式声明它只是 proxy。这是 12.1 的机制与 12.2 的参数共同收束的地方。

三条帧级不变量

不变量形式它保证了什么
① 反馈延迟上界每个反馈帧 j 都能追到 j - bufferFrames ≤ pf ≤ j 的按键帧 pf按下去不会石沉大海
② 缓冲窗口内输入不丢失落地前 bufferFrames 帧内的按键必产生反馈快速连按不被吞掉
③ 土狼窗口单调离地后 sinceGrounded 逐帧 +1;窗口一旦关闭,落地前不得重开「离地还能跳」的窗口不会忽开忽关

三条都是整数帧级的性质,因此可以硬断言:不依赖任何阈值、任何容差,也就能跨机器逐位复现。注意它们的形态——不是「手感好」,而是「不可能发生某事」。机制层的断言几乎都长这样:先排除坏事件,再谈好事件。

代理指标:响应率

把三条压成一个便于横向比较的整数:responsePermille = 按键在「按下帧 + bufferFrames」窗口内得到反馈的千分比。

模式反馈帧最大延迟响应率
baseline[7,21,28]11000
noBuffer[7,21]0666
noCoyote[9,21,28]21000
tight[21]0333

必须显式声明: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 规格的品类参数化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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