6.3 输入缓冲与手感
6.1 把设备拍平成事件,6.2 把事件映射成动作。这一节处理最后、也最「玄」的一层:手感。玩家按下跳跃的那一帧,往往不是它应该生效的那一帧——可能早了 80 毫秒(人还在空中),也可能晚了 30 毫秒(脚刚离开平台)。手感逻辑就是在这段时间窗里做文章。
好在「跳跃缓冲、土狼时间、输入延迟补偿」三件套全是纯时间逻辑,不碰任何设备,可以在 Node 里逐帧喂入真跑。真正主观的只有数值调参。
为什么跳跃会被「吃掉」
先复现问题。假设起跳条件写得很直白:
// snippet: 反面写法,本节不采用
if (jumpDown && grounded) jump();
两个典型场景会失效:
- 落地前按跳:玩家在离地还有 5 厘米时按下跳跃,
grounded仍是false,这一帧的输入被直接丢弃;等他真正落地,手指早松开了。玩家会觉得「我明明按了,怎么没跳」。 - 离地瞬间按跳:玩家刚走下平台边缘,
grounded变成false的那一帧按下跳跃,同样被丢弃。玩家会觉得「就差一帧」。
这两个问题分别用跳跃缓冲和土狼时间解决。
跳跃缓冲与土狼时间
- 跳跃缓冲(jump buffer):把「按下跳跃」这个意图记住一段时间(如 0.12 秒)。只要在这段时间内落地,就补上这次起跳。
- 土狼时间(coyote time):角色离开地面后仍保留一段「还能跳」的宽限(如 0.10 秒),名字来自卡通里土狼跑出悬崖才掉下去的桥段。
下面 src/subsys/input/input-buffer.ts 层级 L1,完整可编译。它把两者加上延迟补偿做进一个逐帧推进的 InputBuffer。
// file: src/subsys/input/input-buffer.ts
// tier: L1
// verify: npx vitest run src/subsys/input/input-buffer.test.ts
//
// helio 输入子系统:手感三件套——跳跃缓冲、土狼时间、输入延迟补偿。
// 三者都是纯时间逻辑,不碰任何设备,可在 Node 里逐帧喂入真跑。
export interface FeelConfig {
/** 跳跃缓冲窗口(秒):落地前这么久的跳跃输入仍然生效。 */
readonly jumpBufferSeconds: number;
/** 土狼时间(秒):离地后这么久内仍可起跳。 */
readonly coyoteSeconds: number;
/** 输入延迟补偿(帧):把输入边沿推迟这么多帧再「看见」。 */
readonly inputLatencyFrames: number;
}
export const DEFAULT_FEEL: FeelConfig = {
jumpBufferSeconds: 0.12,
coyoteSeconds: 0.1,
inputLatencyFrames: 0,
};
/**
* 逐帧推进的手感缓冲器。now 为单调递增秒数;grounded 为角色是否着地;
* jumpDown 为本帧原始跳跃边沿。step 返回本帧是否应起跳。
*/
export class InputBuffer {
readonly #cfg: FeelConfig;
#jumpPressedAt = -Infinity;
#lastGroundedAt = -Infinity;
#history: boolean[] = [];
#jumpCount = 0;
constructor(cfg: FeelConfig = DEFAULT_FEEL) {
this.#cfg = cfg;
}
get jumpCount(): number {
return this.#jumpCount;
}
step(now: number, grounded: boolean, jumpDown: boolean): boolean {
if (grounded) this.#lastGroundedAt = now;
// 延迟补偿:保留最近 delay+1 帧的原始边沿,本帧只看 delay 帧前那一帧。
this.#history.push(jumpDown);
const delay = this.#cfg.inputLatencyFrames;
const idx = this.#history.length - 1 - delay;
const delayed = idx >= 0 ? this.#history[idx]! : false;
while (this.#history.length > delay + 1) this.#history.shift();
if (delayed) this.#jumpPressedAt = now;
const buffered = now - this.#jumpPressedAt <= this.#cfg.jumpBufferSeconds;
const coyote = now - this.#lastGroundedAt <= this.#cfg.coyoteSeconds;
if (buffered && (grounded || coyote)) {
// 消费掉这次跳跃意图,避免同一帧或下一帧重复起跳。
this.#jumpPressedAt = -Infinity;
this.#lastGroundedAt = -Infinity;
this.#jumpCount += 1;
return true;
}
return false;
}
}
两个时间戳 #jumpPressedAt 与 #lastGroundedAt 是全部状态。每帧先更新「最近一次着地」;再判断「最近一次跳跃意图是否还在缓冲窗口内」。当 buffered && (grounded || coyote) 成立就起跳,并把两个时间戳都清成 -Infinity——消费掉这次意图,否则下一帧还会再跳一次(二段跳 bug)。
输入延迟补偿
第三件套是输入延迟补偿:把输入边沿推迟 N 帧再让游戏「看见」。它听起来反直觉——为什么要主动增加延迟?因为网络同步与设备抖动会让输入到达的时机不均匀,固定推迟几帧反而能让所有玩家的手感一致。格斗游戏的「输入延迟」选项就是这个机制:双方各延迟若干帧,把瞬时抖动抹平。
实现上只需保留最近 delay + 1 帧的原始边沿,本帧只读 delay 帧前那一帧。延迟为 0 时退化成「本帧即时生效」,所以它默认是关的(DEFAULT_FEEL.inputLatencyFrames = 0)。
可运行产物:逐帧真跑三件套
下面 src/subsys/input/feel-demo.ts 层级 L1,命令 node src/subsys/input/feel-demo.ts。它构造三组场景,分别「开/关」某一机制,打印起跳帧号与轨迹(* 表示该帧原始按下,^ 表示该帧起跳)。
// file: src/subsys/input/feel-demo.ts
// tier: L1
// verify: node src/subsys/input/feel-demo.ts
//
// 手感三件套的确定性演示:逐帧喂入「是否着地 + 本帧是否按下跳跃」,
// 打印每组的起跳帧号与轨迹,用来对比「开/关」某一件套的差别。
import { InputBuffer, type FeelConfig } from './input-buffer.ts';
const DT = 1 / 60;
const N = 13;
interface Frame {
readonly t: number;
readonly grounded: boolean;
readonly jump: boolean;
}
function run(label: string, cfg: FeelConfig, make: (i: number) => Frame): void {
const buf = new InputBuffer(cfg);
let fired = -1;
const trace: string[] = [];
for (let i = 0; i < N; i++) {
const f = make(i);
const jumped = buf.step(f.t, f.grounded, f.jump);
if (jumped && fired < 0) fired = i;
trace.push(`${i}${f.jump ? '*' : ''}${jumped ? '^' : ''}`);
}
console.log(`[${label}] firedAt=${fired} jumps=${buf.jumpCount}`);
console.log(`[${label}] trace(*按下 ^起跳) ${trace.join(' ')}`);
}
// 场景 A:第 2 帧在空中按下,第 6 帧落地——考验跳跃缓冲。
const landAt6 = (i: number): Frame => ({ t: i * DT, grounded: i >= 6, jump: i === 2 });
console.log('== A 落地前按跳(第 2 帧按,第 6 帧落地)==');
run('buffer=0.15', { jumpBufferSeconds: 0.15, coyoteSeconds: 0, inputLatencyFrames: 0 }, landAt6);
run('buffer=0.00', { jumpBufferSeconds: 0.0, coyoteSeconds: 0, inputLatencyFrames: 0 }, landAt6);
// 场景 B:第 6 帧走下平台,第 7 帧按跳——考验土狼时间。
const offAt6 = (i: number): Frame => ({ t: i * DT, grounded: i < 6, jump: i === 7 });
console.log('== B 离地后按跳(第 6 帧离地,第 7 帧按)==');
run('coyote=0.12', { jumpBufferSeconds: 0, coyoteSeconds: 0.12, inputLatencyFrames: 0 }, offAt6);
run('coyote=0.00', { jumpBufferSeconds: 0, coyoteSeconds: 0, inputLatencyFrames: 0 }, offAt6);
// 场景 C:全程着地,第 2 帧按跳——考验输入延迟补偿。
const groundedAt2 = (i: number): Frame => ({ t: i * DT, grounded: true, jump: i === 2 });
console.log('== C 着地按跳(第 2 帧按)==');
run('latency=0', { jumpBufferSeconds: 0.5, coyoteSeconds: 0, inputLatencyFrames: 0 }, groundedAt2);
run('latency=2', { jumpBufferSeconds: 0.5, coyoteSeconds: 0, inputLatencyFrames: 2 }, groundedAt2);
本机实跑输出(逐字):
== A 落地前按跳(第 2 帧按,第 6 帧落地)==
[buffer=0.15] firedAt=6 jumps=1
[buffer=0.15] trace(*按下 ^起跳) 0 1 2* 3 4 5 6^ 7 8 9 10 11 12
[buffer=0.00] firedAt=-1 jumps=0
[buffer=0.00] trace(*按下 ^起跳) 0 1 2* 3 4 5 6 7 8 9 10 11 12
== B 离地后按跳(第 6 帧离地,第 7 帧按)==
[coyote=0.12] firedAt=7 jumps=1
[coyote=0.12] trace(*按下 ^起跳) 0 1 2 3 4 5 6 7*^ 8 9 10 11 12
[coyote=0.00] firedAt=-1 jumps=0
[coyote=0.00] trace(*按下 ^起跳) 0 1 2 3 4 5 6 7* 8 9 10 11 12
== C 着地按跳(第 2 帧按)==
[latency=0] firedAt=2 jumps=1
[latency=0] trace(*按下 ^起跳) 0 1 2*^ 3 4 5 6 7 8 9 10 11 12
[latency=2] firedAt=4 jumps=1
[latency=2] trace(*按下 ^起跳) 0 1 2* 3 4^ 5 6 7 8 9 10 11 12
三组对照说明一切:A 组开缓冲在第 6 帧(落地帧)起跳、关缓冲则永不触发;B 组开土狼在第 7 帧(离地后一帧)起跳、关土狼则永不触发;C 组延迟 0 在第 2 帧生效、延迟 2 推迟到第 4 帧。下面 src/subsys/input/input-buffer.test.ts 层级 L1,命令 npx vitest run src/subsys/input/input-buffer.test.ts,把这些行为钉成断言。
// file: src/subsys/input/input-buffer.test.ts
// tier: L1
// verify: npx vitest run src/subsys/input/input-buffer.test.ts
import { describe, expect, it } from 'vitest';
import { DEFAULT_FEEL, InputBuffer } from './input-buffer.ts';
const noDelay = (jumpBufferSeconds: number, coyoteSeconds: number): InputBuffer =>
new InputBuffer({ jumpBufferSeconds, coyoteSeconds, inputLatencyFrames: 0 });
describe('跳跃缓冲', () => {
it('落地前按下,落地瞬间自动起跳', () => {
const b = noDelay(0.15, 0.1);
expect(b.step(0.0, false, true)).toBe(false); // 空中按下,只记入缓冲
expect(b.step(0.05, false, false)).toBe(false);
expect(b.step(0.1, true, false)).toBe(true); // 落地:缓冲窗口内 → 起跳
});
it('超出缓冲窗口的旧输入不再生效', () => {
const b = noDelay(0.15, 0.1);
expect(b.step(0.0, false, true)).toBe(false);
expect(b.step(0.4, true, false)).toBe(false); // 0.4s > 0.15s
});
});
describe('土狼时间', () => {
it('离地后短暂窗口内仍可跳,过期则不能', () => {
const ok = noDelay(0.12, 0.12);
expect(ok.step(0.0, true, false)).toBe(false);
expect(ok.step(0.05, false, true)).toBe(true); // 离地 0.05s,窗口内
const late = noDelay(0.12, 0.12);
expect(late.step(0.0, true, false)).toBe(false);
expect(late.step(0.3, false, true)).toBe(false); // 离地 0.3s > 0.12s
});
it('起跳会消费掉土狼窗口,防止二段跳', () => {
const b = noDelay(0.12, 0.12);
expect(b.step(0.0, true, false)).toBe(false);
expect(b.step(0.05, false, true)).toBe(true);
expect(b.step(0.08, false, true)).toBe(false); // 已消费,不能再跳
});
});
describe('输入延迟补偿', () => {
it('边沿被推迟 N 帧后才被看见', () => {
const b = new InputBuffer({ jumpBufferSeconds: 1.0, coyoteSeconds: 1.0, inputLatencyFrames: 2 });
expect(b.step(0.0, true, true)).toBe(false); // frame0:原始按下,仍在延迟
expect(b.step(0.01, true, false)).toBe(false); // frame1
expect(b.step(0.02, true, false)).toBe(true); // frame2:延迟 2 帧后看见 → 跳
expect(b.jumpCount).toBe(1);
});
it('默认配置延迟为 0,本帧即时生效', () => {
expect(DEFAULT_FEEL.inputLatencyFrames).toBe(0);
expect(new InputBuffer().step(0.0, true, true)).toBe(true);
});
});
本机实跑:Test Files 1 passed、Tests 6 passed;tsc --noEmit 零错误。
手感是 L4:必须手玩
上面这些用例证明了逻辑正确,但证明不了手感好。jumpBufferSeconds = 0.12 还是 0.15、土狼时间给 0.10 还是 0.08,是主观的调参问题:窗口太短玩家觉得「吃输入」,太长又会让跳跃显得「粘手」。这些数字只能靠真人反复试玩来定。
因此,本节的手感调参部分属 L4,需在浏览器中手动验证,命令行不可复现。命令行能复现的只有「给定窗口与时间序列,起跳判定是否正确」这一层——也就是上面真跑的那些断言。把逻辑与调参分开,正是让手感逻辑可测的前提。
小结
- 「跳跃被吃掉」的两个根因是落地前按跳与离地瞬间按跳,分别用跳跃缓冲与土狼时间解决。
InputBuffer只用两个时间戳(#jumpPressedAt/#lastGroundedAt)加一个历史环形数组就实现了三件套;起跳后必须消费意图,否则会二段跳。- 输入延迟补偿把边沿推迟 N 帧,用于抹平网络与设备抖动;默认关闭。
- 三件套全是纯时间逻辑,可在 Node 逐帧真跑;数值调参属 L4,需在浏览器中手动验证,命令行不可复现。
至此输入系统闭环:设备归一化(6.1)→ 动作映射(6.2)→ 手感缓冲(6.3)。下一章转向让游戏「可被编辑」——从反射驱动的属性暴露开始。
阅读导航:上一节:6.2 动作映射与重绑定 · 下一节:7.1 反射驱动的属性暴露 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。