12.1 手感不是渲染:输入-反馈的机制

把手感从渲染层剥出来,写成整数帧的确定性状态机:输入采样 → 缓冲 → 状态推进 → 反馈触发。同一段输入脚本、同一段美术表现,只改土狼时间与输入缓冲两组窗口参数,就得到四条互不相同的帧序列——手感差异全部落在可断言的整数帧上,并由 7 条 P 层断言固定住。

12.1 手感不是渲染:输入-反馈的机制

站内 《独立游戏核心循环与手感设计》 是一篇散文式的手感参数体系:核心循环、土狼时间、输入缓冲、屏幕震动各写一段,配的是不可跑的 Unity csharp 片段。本节做的是它的机制化版本——把同一批参数写成整数帧状态机,让「手感不同」变成「帧序列不同」,再用帧级不变量把它断言掉。上一节把战斗的相变与支配扫了出来,本节把「手感」也拉进机制的射程。

手感活在时序里,不活在画面里

同样一段美术——同一个角色、同一段跳跃动画、同一个落地特效——配不同的帧时序,玩起来就是两个游戏。这就是「手感」的真相:它不活在渲染层,活在输入与反馈之间的帧数里。渲染决定「看到什么」,时序决定「按下去之后多久、在什么条件下、以什么顺序发生」。

既然手感是一串整数帧,它就能被写成机制:一组整数帧参数 + 一台确定性状态机。它的每一条「手感」主张,都能翻译成一条可跑的帧级断言,而不是一句「感觉更跟手了」。

四段式状态机

core/genre/gamefeel.ts 把一帧拆成四段:输入采样 → 缓冲 → 状态推进 → 反馈触发。缓冲是「按下之后还认多久」,土狼时间是「离地之后还认多久」,两者都按整数帧计。反馈触发只做一件事:把当前帧号写进 feedback——这是「反馈在时间轴上的位置」,也就是手感本身。

// file: core/genre/gamefeel.ts
// tier: P
// verify: node --test 'core/test/gd_proto_12_1_game_feel_is_not_rendering.test.ts'
//
// 手感 = 输入-反馈的**时序机制**,不是渲染。本模块把「输入采样 → 缓冲 → 状态推进 →
// 反馈触发」写成**整数帧**的确定性状态机:每帧只做整数加减,无浮点、无 Math.random()。
// 同一段美术表现配不同的帧参数,手感就不同——差异全部落在整数帧序列上,可断言。
// 本模块是卷三第 12 章自己的最小内核,不依赖 core/src/ashdeck/*,只供本章 P/W 消费。

export type FeelInput = { jump: boolean; onPlatform: boolean };

export type FeelParams = {
  gravity: number;
  jumpVelocity: number;
  platformY: number;
  coyoteFrames: number;
  bufferFrames: number;
  knockback: number;
  knockbackDecay: number;
  hitstopFrames: number;
};

export type FeelState = {
  frame: number;
  y: number;
  vy: number;
  grounded: boolean;
  sinceGrounded: number;
  buffer: number;
  jumps: number;
  feedback: number;
  hitstop: number;
  shake: number;
};

// 宽窗口参数:土狼时间 3 帧、输入缓冲 3 帧。
export const FEEL_PARAMS: FeelParams = {
  gravity: 3, jumpVelocity: 9, platformY: 24, coyoteFrames: 3, bufferFrames: 3,
  knockback: 6, knockbackDecay: 2, hitstopFrames: 2,
};

// 紧窗口参数:重力 / 起跳速度 / 台高都不变,土狼与缓冲都归零。
export const FEEL_TIGHT: FeelParams = {
  gravity: 3, jumpVelocity: 9, platformY: 24, coyoteFrames: 0, bufferFrames: 0,
  knockback: 6, knockbackDecay: 2, hitstopFrames: 2,
};

/** 初始状态:站在台面上(y = platformY)。 */
export function feelInitial(p: FeelParams): FeelState {
  return {
    frame: 0, y: p.platformY, vy: 0, grounded: true, sinceGrounded: 0,
    buffer: 0, jumps: 0, feedback: -1, hitstop: 0, shake: 0,
  };
}

/** 单帧推进:采样输入 → 物理积分 → 地面判定 → 跳跃结算。全整数。 */
export function stepFrame(p: FeelParams, s: FeelState, input: FeelInput): FeelState {
  const n: FeelState = {
    frame: s.frame + 1, y: s.y, vy: s.vy, grounded: s.grounded,
    sinceGrounded: s.sinceGrounded, buffer: s.buffer, jumps: s.jumps,
    feedback: s.feedback, hitstop: s.hitstop > 0 ? s.hitstop - 1 : 0,
    shake: s.shake > 0 ? s.shake - p.knockbackDecay : 0,
  };
  // 1) 输入采样 → 缓冲:按下即把窗口置为 bufferFrames,否则逐帧衰减。
  if (input.jump) n.buffer = p.bufferFrames;
  else if (n.buffer > 0) n.buffer -= 1;
  // 2) 重力积分(整数):先位移后减速。
  n.y += n.vy;
  n.vy -= p.gravity;
  // 3) 地面判定:在台面上时地面高度 = platformY,走离后地面高度 = 0。
  const groundY = input.onPlatform ? p.platformY : 0;
  if (n.y <= groundY) {
    if (!n.grounded) n.shake = p.knockback; // 落地反馈
    n.y = groundY; n.vy = 0; n.grounded = true; n.sinceGrounded = 0;
  } else {
    n.grounded = false; n.sinceGrounded += 1;
  }
  // 4) 跳跃结算:本帧按下了 或 缓冲里还有存量,且(在地面 或 离地不超过 coyoteFrames)。
  const canJump = n.grounded || n.sinceGrounded <= p.coyoteFrames;
  const wantsJump = input.jump || n.buffer > 0;
  if (wantsJump && canJump) {
    n.vy = p.jumpVelocity; n.buffer = 0; n.jumps += 1;
    n.hitstop = p.hitstopFrames; n.shake = p.knockback;
    n.feedback = n.frame; // 反馈触发的帧号
  }
  return n;
}

/** 从初始状态起逐帧推进;返回含初始帧的完整帧序列。 */
export function simulateFrames(p: FeelParams, inputs: FeelInput[]): FeelState[] {
  const states: FeelState[] = [feelInitial(p)];
  let s = states[0];
  for (const input of inputs) { s = stepFrame(p, s, input); states.push(s); }
  return states;
}

/** 反馈触发的帧号列表(某帧 feedback === frame 即为触发帧)。 */
export function feedbackFrames(states: FeelState[]): number[] {
  return states.filter((s) => s.feedback === s.frame).map((s) => s.frame);
}

/** 整数摘要:只取整数字段,与宿主无关,可跨 P / W 两处逐字比对。 */
export function feelDigest(states: FeelState[]): string {
  const last = states[states.length - 1];
  const apex = states.reduce((m, s) => (s.y > m ? s.y : m), 0);
  return JSON.stringify({
    frames: last.frame, jumps: last.jumps, apex, feedback: feedbackFrames(states),
  });
}

/** 一次标准起跳的滞空帧数(整数)。 */
export function airtimeFrames(p: FeelParams): number {
  let y = 0, vy = p.jumpVelocity, frames = 0;
  while (frames < 1000) {
    y += vy; vy -= p.gravity; frames += 1;
    if (frames > 1 && y <= 0) break;
  }
  return frames;
}

/** 帧数换算成毫秒的地板近似:感知类派生量,只能按**声明容差**断言。 */
export function airtimeMillisFloor(frames: number, fps: number): number {
  return Math.floor((frames * 1000) / fps);
}

/** 「走离台沿 + 三次按键」的标准输入脚本:四种窗口参数共用同一段美术表现。 */
export function ledgeInputs(): FeelInput[] {
  const seq: FeelInput[] = [];
  for (let f = 0; f < 34; f += 1) {
    const onPlatform = f < 4;                      // f4 起走离台沿
    const jump = f === 6 || f === 20 || f === 26;  // f6 测土狼;f20 常规起跳;f26 测缓冲
    seq.push({ jump, onPlatform });
  }
  return seq;
}

/** 四种「手感模式」:同一输入脚本、同一表现量,只有时序窗口不同。 */
export const FEEL_MODES: Record<string, FeelParams> = {
  baseline: FEEL_PARAMS,                              // coyote 3 / buffer 3
  noBuffer: { ...FEEL_PARAMS, bufferFrames: 0 },      // coyote 3 / buffer 0
  noCoyote: { ...FEEL_PARAMS, coyoteFrames: 0 },      // coyote 0 / buffer 3
  tight: FEEL_TIGHT,                                  // coyote 0 / buffer 0
};

export function runMode(mode: string): FeelState[] {
  const p = FEEL_MODES[mode] ?? FEEL_PARAMS;
  return simulateFrames(p, ledgeInputs());
}

注意 stepFrame 里的顺序:地面判定排在跳跃结算之前。这不是风格问题——它决定了「落地前几帧按下的键」能不能被兑现,也就是输入缓冲是否真的生效。顺序颠倒,缓冲就永远差一帧,noBuffer 与 baseline 会退化成同一条序列。

同一段输入,四种手感

同一段输入脚本(走离台沿、第 6 帧按键测土狼、第 20 帧常规起跳、第 26 帧按键测缓冲),只改 coyoteFrames 与 bufferFrames 两组窗口参数,得到四条帧序列:

baseline {"frames":34,"jumps":3,"apex":33,"feedback":[7,21,28]}  反馈帧 [7,21,28]
noBuffer {"frames":34,"jumps":2,"apex":33,"feedback":[7,21]}  反馈帧 [7,21]
noCoyote {"frames":34,"jumps":3,"apex":24,"feedback":[9,21,28]}  反馈帧 [9,21,28]
tight    {"frames":34,"jumps":1,"apex":24,"feedback":[21]}  反馈帧 [21]

四条摘要互不相同。baseline 第 7 帧就借着土狼窗口起跳;noCoyote 同一次按键改在第 9 帧落地时才兑现;noBuffer 丢了第 27 帧那次按键(jumps 从 3 掉到 2);tight 只剩第 21 帧那次常规起跳。美术表现一个字没改,手感已经是四种。

P 层:逐帧断言

把上面这条主张固化成断言:

// file: core/test/gd_proto_12_1_game_feel_is_not_rendering.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_12_1_game_feel_is_not_rendering.test.ts'
//
// 12.1 手感不是渲染:输入-反馈的机制(P 层)。手感被写成**整数帧**的确定性状态机——
// 输入采样 → 缓冲 → 状态推进 → 反馈触发——本文件逐环节断言,并证明「同一段输入、
// 同一段美术表现,只有两组时序窗口不同,帧序列就不同」。期望值全部由本机实跑得出。

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

import {
  feelInitial, stepFrame, simulateFrames, feelDigest, feedbackFrames,
  ledgeInputs, FEEL_PARAMS, FEEL_MODES,
} from "../genre/gamefeel.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");

const BASE = '{"frames":34,"jumps":3,"apex":33,"feedback":[7,21,28]}';
const NO_BUFFER = '{"frames":34,"jumps":2,"apex":33,"feedback":[7,21]}';
const NO_COYOTE = '{"frames":34,"jumps":3,"apex":24,"feedback":[9,21,28]}';
const TIGHT = '{"frames":34,"jumps":1,"apex":24,"feedback":[21]}';

test("输入采样 → 缓冲:按下当帧把窗口置为 bufferFrames,未按则逐帧衰减到 0", () => {
  // 用一个「早已离地、远超土狼窗口」的状态,避免按下当帧就被跳跃消费掉缓冲。
  const air = {
    frame: 0, y: 100, vy: 0, grounded: false, sinceGrounded: 99,
    buffer: 0, jumps: 0, feedback: -1, hitstop: 0, shake: 0,
  };
  const s1 = stepFrame(FEEL_PARAMS, air, { jump: true, onPlatform: false });
  assert.equal(s1.buffer, FEEL_PARAMS.bufferFrames);
  assert.equal(s1.jumps, 0, "超出土狼窗口时缓冲不消费");
  const s2 = stepFrame(FEEL_PARAMS, s1, { jump: false, onPlatform: false });
  assert.equal(s2.buffer, FEEL_PARAMS.bufferFrames - 1);
  const s3 = stepFrame(FEEL_PARAMS, s2, { jump: false, onPlatform: false });
  assert.equal(s3.buffer, FEEL_PARAMS.bufferFrames - 2);
});

test("反馈触发:站在地面按下 → 当帧起跳、缓冲清零、反馈帧号 = 当前帧", () => {
  const s1 = stepFrame(FEEL_PARAMS, feelInitial(FEEL_PARAMS), { jump: true, onPlatform: true });
  assert.equal(s1.grounded, true);
  assert.equal(s1.jumps, 1);
  assert.equal(s1.buffer, 0);
  assert.equal(s1.feedback, s1.frame);
  assert.equal(s1.hitstop, FEEL_PARAMS.hitstopFrames);
});

test("状态推进:走离台沿后逐帧下落,落回 0 时地面判定复位", () => {
  const st = simulateFrames(FEEL_PARAMS, ledgeInputs());
  const f5 = st[5]; // 走离台沿后的第 1 帧
  const f6 = st[6];
  assert.equal(f5.grounded, false);
  assert.equal(f5.sinceGrounded, 1);
  assert.ok(f6.y < f5.y, "离地后高度应逐帧下降");
  // 落回地面(y = 0)后 grounded 复位、离地计时归零。
  const landed = st.find((s) => s.frame > 10 && s.grounded && s.y === 0);
  assert.ok(landed, "应有落回 0 高度的帧");
  assert.equal(landed?.sinceGrounded, 0);
});

test("反馈触发:只有起跳帧把 feedback 置为当前帧号,其余帧不动", () => {
  const st = simulateFrames(FEEL_PARAMS, ledgeInputs());
  assert.deepEqual(feedbackFrames(st), [7, 21, 28]);
  const noInput = simulateFrames(FEEL_PARAMS, ledgeInputs().map((i) => ({ ...i, jump: false })));
  assert.deepEqual(feedbackFrames(noInput), []);
  assert.equal(noInput[noInput.length - 1].jumps, 0);
});

test("同一段输入、四种时序窗口 → 四条不同的帧序列(手感不同)", () => {
  const got: Record<string, string> = {};
  for (const m of ["baseline", "noBuffer", "noCoyote", "tight"]) {
    got[m] = feelDigest(simulateFrames(FEEL_MODES[m], ledgeInputs()));
  }
  assert.equal(got.baseline, BASE);
  assert.equal(got.noBuffer, NO_BUFFER);
  assert.equal(got.noCoyote, NO_COYOTE);
  assert.equal(got.tight, TIGHT);
  const uniq = new Set(Object.values(got));
  assert.equal(uniq.size, 4, "四种窗口必须给出四条互不相同的帧序列");
});

test("确定性回放:同参数两遍 digest 逐字一致", () => {
  const a = feelDigest(simulateFrames(FEEL_PARAMS, ledgeInputs()));
  const b = feelDigest(simulateFrames(FEEL_PARAMS, ledgeInputs()));
  assert.equal(a, b);
  assert.equal(a, BASE);
});

test("与 W 层构建产物对照:页面 nodeDigest 必须等于 P 层 baseline", () => {
  const p = join(ROOT, "out", "gd_proto_12_build.json");
  if (!existsSync(p)) {
    console.log("  跳过:缺 out/gd_proto_12_build.json(先跑 web/build_proto12.mjs)");
    return;
  }
  const build = JSON.parse(readFileSync(p, "utf8"));
  assert.equal(build.nodeDigest, feelDigest(simulateFrames(FEEL_PARAMS, ledgeInputs())));
  assert.deepEqual(build.modes, ["baseline", "noBuffer", "noCoyote", "tight"]);
});

实跑:

# snippet: 跑 12.1 的 P 层断言(7 条)
node --test 'core/test/gd_proto_12_1_game_feel_is_not_rendering.test.ts'
✔ 输入采样 → 缓冲:按下当帧把窗口置为 bufferFrames,未按则逐帧衰减到 0 (0.620208ms)
✔ 反馈触发:站在地面按下 → 当帧起跳、缓冲清零、反馈帧号 = 当前帧 (0.103375ms)
✔ 状态推进:走离台沿后逐帧下落,落回 0 时地面判定复位 (0.756625ms)
✔ 反馈触发:只有起跳帧把 feedback 置为当前帧号,其余帧不动 (0.689834ms)
✔ 同一段输入、四种时序窗口 → 四条不同的帧序列(手感不同) (0.244958ms)
✔ 确定性回放:同参数两遍 digest 逐字一致 (0.098791ms)
✔ 与 W 层构建产物对照:页面 nodeDigest 必须等于 P 层 baseline (1.260375ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 554.110791

七条全绿。最要紧的是第五条:四种窗口必须给出四条互不相同的帧序列——这正是「手感不是渲染」的可验证形态。末条把 W 层页面构建期落下的 nodeDigest 与 P 层现算的摘要对齐:P 与 W 是同一段源码的两个宿主,摘要必须逐字相同。

与相邻内容的边界

  • 与 《独立游戏核心循环与手感设计》 的边界:那篇给的是参数清单与推荐区间(散文 + 不可跑的 csharp);本节给的是可断言的输入-反馈状态机(整数帧 + 帧级不变量 + 无头真玩)。
  • 与 12.2、12.3 的边界:本节只证明「时序变了手感就变了」;参数怎么算成整数、哪些只能按声明容差断言是 12.2,把「手感」压成不变量与代理指标是 12.3。

阅读导航:上一节:11.3 战斗的相变与支配 · 下一节:12.2 手感参数的可计算化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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