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 手感参数的可计算化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。