12.2 手感参数的可计算化

把手感参数可计算化:八个参数全是整数,帧数类与单位类都能断言逐位相等;只有帧到毫秒的感知派生量要按声明容差(60 fps 下一帧约 17 ms)断言。给出土狼时间与输入缓冲的整数边界、击退衰减的精确整数递推,并用一张敏感度表划清改时序与只改表现的参数。

12.2 手感参数的可计算化

站内 《独立游戏核心循环与手感设计》 把手感参数列成一张散文清单:跳跃高度、重力、土狼时间、输入缓冲、屏幕震动衰减……每个都给了推荐区间,但没有一个能被断言。本节把这些参数可计算化:哪些能用整数精确表达、哪些只能按声明容差断言,界线画清楚。这是 12.1 那台状态机的参数面。

哪些参数能用整数表达

八个参数全部是整数:gravity / jumpVelocity / platformY / coyoteFrames / bufferFrames / knockback / knockbackDecay / hitstopFrames。它们分两类:

  • 帧数类(土狼时间、输入缓冲、硬直、击退衰减周期):帧数就是它的原生单位,整数是精确表达,可以断言逐位相等。
  • 单位类(重力、起跳速度、台高、击退幅度):在「每帧加一个整数」的定点世界里同样是整数,位移与速度递推不产生任何小数,也可以断言逐位相等。

唯一不能逐位相等的是感知类派生量:把 7 帧滞空换算成毫秒,7 × 1000 / 60 = 116.67,只能地板取 116,再按声明容差(60 fps 下一帧约 17 ms)断言。这是本站三卷通用的纪律:整数类逐位相等,感知类按容差——绝不对感知类派生量写「逐位相等」。

参数值类型断言方式
gravity3单位/帧逐位相等
jumpVelocity9单位/帧逐位相等
platformY24单位逐位相等
coyoteFrames3帧逐位相等(窗口边界)
bufferFrames3帧逐位相等(窗口边界)
knockback / knockbackDecay6 / 2单位/帧逐位相等(整数递推)
hitstopFrames2帧逐位相等
滞空毫秒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 手感的度量与断言 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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