《游戏引擎实现原理》6.3 输入缓冲与手感

跳跃输入总被『吃掉』?问题多半不在玩家手速,而在手感逻辑。本节实现跳跃缓冲、土狼时间与输入延迟补偿三件套,三者全部是纯时间逻辑,可在 Node 里逐帧喂入真跑,并说明真正的『手感』数值调参仍需在浏览器中手动验证、命令行不可复现,只有逻辑部分可自动化测试。

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 反射驱动的属性暴露 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径