《Cocos 游戏开发入门》5.3 输入缓冲与手感

本节解释为什么同样的判定逻辑有的游戏跟手、有的发飘:在输入与游戏循环之间加一层时间缓冲。用 TimedBuffer 实现跳跃缓冲、土狼时间与连击窗口,把「手感」变成三个可调秒数,并用假时钟单测锁住落地前抢跳、离地后补跳等边界场景,最后对照 Cocos 3.8 的键盘与触摸输入。

5.3 输入缓冲与手感

到这里,《方块冲刺》已经能收到点击、能识别手势了。但把它交到玩家手里,大概率会被吐槽一句「跳起来不跟手」。奇怪的是,代码里 pointerdown 一响就起跳,逻辑上零延迟,为什么还发飘?

本节把《方块冲刺》推进到「跟手」这一步:在输入与游戏循环之间加一层时间缓冲,用 TimedBuffer 实现跳跃缓冲、土狼时间与连击窗口。三个「手感参数」全部抽成可配置的秒数,并用假时钟单测把落地前抢跳、离地后补跳这些边界场景钉死。

5.3.1 手感不是延迟,是「预期」

玩家说「不跟手」,通常不是真的嫌延迟高——60 帧下一次输入到画面响应只有 16ms,人几乎察觉不到。真正的抱怨来自被拒绝的输入:

  • 玩家在离地还有 30ms 时按下跳,游戏说「你还没落地」→ 这次跳白按了;
  • 玩家在刚跳离地面 40ms 时按下跳,游戏说「你已经在空中」→ 又白按了;
  • 玩家在连击窗口结束后 20ms 按下第二下,游戏说「连击断了」。

这些「差一点点」的拒绝,累积起来就是手感差。解决办法不是降低延迟,而是给输入一段宽容期——承认玩家的操作意图,哪怕它早了一点或晚了一点。业界把这套机制叫「输入缓冲」(input buffering),其中最有名的两个是 jump buffer(跳跃缓冲)与 coyote time(土狼时间,源自卡通角色跑出悬崖后还能在空中蹬一下腿)。

5.3.2 一个可复用的时间缓冲

三种宽容期共享同一个模式:记下一个时间戳,在一段时间窗口内允许被「消费」。先抽出一个最小构件:

// src/input/timed-buffer.ts
export class TimedBuffer {
  private readonly window: number;
  private stamp = Number.NEGATIVE_INFINITY;

  constructor(window: number) {
    this.window = window;
  }

  /** 记录一次输入发生(单位:秒) */
  press(now: number): void {
    this.stamp = now;
  }

  /** 在窗口内消费:成功返回 true 并清空,过期返回 false */
  consume(now: number): boolean {
    if (now - this.stamp <= this.window) {
      this.stamp = Number.NEGATIVE_INFINITY;
      return true;
    }
    return false;
  }

  get pending(): boolean {
    return this.stamp !== Number.NEGATIVE_INFINITY;
  }
}

用 Number.NEGATIVE_INFINITY 当「空」的哨兵值,比用 -1 更稳:游戏时间从 0 开始时,-1 会恰好落进窗口。consume 成功即清空,保证一次按键只触发一次动作——这是防止「按一下跳两下」的关键。

5.3.3 把三个参数集中成一张表

手感参数最忌讳散落各处。集中成一个配置对象,调参时只改这一处:

// src/input/feel-config.ts
export interface FeelConfig {
  /** 跳跃缓冲窗口:落地前多久按下仍算数(秒) */
  jumpBuffer: number;
  /** 土狼时间:离地后多久内仍可跳(秒) */
  coyoteTime: number;
  /** 连击窗口:多久内再次攻击算连击(秒) */
  comboWindow: number;
}

export const DEFAULT_FEEL: FeelConfig = {
  jumpBuffer: 0.12,
  coyoteTime: 0.10,
  comboWindow: 0.35,
};

这三个数字都有据可依:jumpBuffer 取 120ms,略短于一次轻点(约 100–200ms),既容得下「提前按」又不至于让玩家觉得「我明明没按它自己跳了」;coyoteTime 取 100ms,约为 6 帧,足够覆盖玩家对「已经离地」的视觉延迟;comboWindow 取 350ms,是连击节奏的常见上限。它们全是可调参数,不是魔法数。

5.3.4 跳跃缓冲 + 土狼时间

把 TimedBuffer 与土狼计时器组合成一个跳跃控制器。它每帧被调用一次,返回「本帧是否真的起跳」:

// src/game/jump-controller.ts
import { TimedBuffer } from "../input/timed-buffer";
import { FeelConfig } from "../input/feel-config";

export class JumpController {
  private readonly cfg: FeelConfig;
  private readonly buffer: TimedBuffer;
  private lastGroundedAt = Number.NEGATIVE_INFINITY;

  constructor(cfg: FeelConfig) {
    this.cfg = cfg;
    this.buffer = new TimedBuffer(cfg.jumpBuffer);
  }

  /** 玩家按下跳跃键时调用 */
  onJumpPressed(now: number): void {
    this.buffer.press(now);
  }

  /** 每帧调用;grounded 表示角色当前是否踩在地面 */
  update(now: number, grounded: boolean): boolean {
    if (grounded) this.lastGroundedAt = now;
    const withinCoyote = now - this.lastGroundedAt <= this.cfg.coyoteTime;
    if ((grounded || withinCoyote) && this.buffer.consume(now)) {
      // 起跳后立刻作废土狼时间,防止刚跳就二段跳
      this.lastGroundedAt = Number.NEGATIVE_INFINITY;
      return true;
    }
    return false;
  }
}

这段逻辑有两处值得琢磨。第一,&& 的短路顺序:只有「在地面或土狼窗口内」时才会去 consume 缓冲,否则缓冲会保留到落地——这正是「落地前抢跳」能生效的原因。第二,起跳后把 lastGroundedAt 清空:不清的话,起跳后的头 100ms 里 withinCoyote 仍为真,玩家连按两下就会二段跳。

5.3.5 连击窗口

连击窗口是同一模式的另一种用法:不是「允许迟到的输入」,而是「允许早到的输入」。两次攻击之间隔多久还算同一套连招,由 comboWindow 决定:

// src/game/combo-tracker.ts
export class ComboTracker {
  private readonly window: number;
  private lastHitAt = Number.NEGATIVE_INFINITY;
  private count = 0;

  constructor(window: number) {
    this.window = window;
  }

  /** 每次攻击命中调用,返回当前连击数 */
  hit(now: number): number {
    if (now - this.lastHitAt <= this.window) {
      this.count += 1;
    } else {
      this.count = 1;
    }
    this.lastHitAt = now;
    return this.count;
  }

  get current(): number {
    return this.count;
  }
}

和第 5.2 节的双击判定一样,这里也有一处顺序陷阱:必须先判断是否在窗口内、再更新 lastHitAt。反过来写,窗口判定永远成立,连击数会无限涨。

5.3.6 用假时钟测手感

手感看似玄学,其实是纯时间函数,完全可以用假时钟断言。now 是我们自己传进去的秒数,测试里想跳多久跳多久:

// test/feel.test.ts
import { describe, it, expect } from "vitest";
import { JumpController } from "../src/game/jump-controller";
import { ComboTracker } from "../src/game/combo-tracker";
import { DEFAULT_FEEL } from "../src/input/feel-config";

describe("跳跃缓冲与土狼时间", () => {
  it("落地前 80ms 按下,落地瞬间自动起跳", () => {
    const c = new JumpController(DEFAULT_FEEL);
    c.onJumpPressed(0.90);
    expect(c.update(0.95, false)).toBe(false);  // 仍在空中
    expect(c.update(1.00, true)).toBe(true);    // 落地即消费缓冲
  });

  it("刚离地 80ms 内仍可跳(土狼时间)", () => {
    const c = new JumpController(DEFAULT_FEEL);
    c.update(0.00, true);
    c.update(0.05, false);
    c.onJumpPressed(0.08);
    expect(c.update(0.08, false)).toBe(true);
  });

  it("离地超过土狼时间就跳不了", () => {
    const c = new JumpController(DEFAULT_FEEL);
    c.update(0.00, true);
    c.update(0.30, false);
    c.onJumpPressed(0.30);
    expect(c.update(0.30, false)).toBe(false);
  });

  it("缓冲过期后不再起跳", () => {
    const c = new JumpController(DEFAULT_FEEL);
    c.onJumpPressed(0.00);
    expect(c.update(0.50, true)).toBe(false);   // 0.5s 远超 0.12s 窗口
  });
});

describe("连击窗口", () => {
  it("窗口内连击累加,超时后归零重计", () => {
    const combo = new ComboTracker(DEFAULT_FEEL.comboWindow);
    expect(combo.hit(0.00)).toBe(1);
    expect(combo.hit(0.30)).toBe(2);
    expect(combo.hit(0.60)).toBe(3);
    expect(combo.hit(1.20)).toBe(1);   // 距上次 0.6s,超窗口
  });
});

这些测试的价值在于把「手感」从主观判断变成可回归的断言。哪天你为了别的原因改了 update 的顺序,测试会立刻告诉你「落地前抢跳」失效了。

5.3.7 Cocos 对照:键盘与触摸输入

Cocos 里同样没有内置输入缓冲,input 只负责把原始事件送到你手上。对照表:

要做的事mini2dCocos 3.8
监听键盘按下keydown 监听input.on(Input.EventType.KEY_DOWN, cb, this)
判断按了哪个键event.code === "Space"event.keyCode === KeyCode.SPACE
监听触摸按下pointerdowninput.on(Input.EventType.TOUCH_START, cb, this)
事件绑到某节点手动判断命中node.on(Node.EventType.TOUCH_START, cb, this)
import {
  _decorator, Component, Node, input, Input, EventKeyboard, KeyCode,
} from 'cc';

const { ccclass } = _decorator;

@ccclass('JumpInputBridge')
export class JumpInputBridge extends Component {
  private readonly buffer = { stamp: Number.NEGATIVE_INFINITY, window: 0.12 };

  onEnable(): void {
    input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    this.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this);
  }
  onDisable(): void {
    input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    this.node.off(Node.EventType.TOUCH_START, this.onTouchStart, this);
  }

  private onKeyDown(event: EventKeyboard): void {
    if (event.keyCode === KeyCode.SPACE || event.keyCode === KeyCode.KEY_W) {
      this.buffer.stamp = performance.now() / 1000;
    }
  }
  private onTouchStart(): void {
    this.buffer.stamp = performance.now() / 1000;
  }
}

这里要强调一个设计选择:input 是全局输入源,node.on 是节点局部输入源。跳跃这种「任何时候都该响应」的操作适合挂在全局 input 上;而「点击某个按钮」应该用 node.on,让引擎帮你做命中测试。EventKeyboard.keyCode 用的是 KeyCode 枚举值——注意它是 enum,在 Cocos 的运行时里真实存在,与我们 mini2d 侧禁用 enum 的约定不同,因为那边跑的是引擎自己的编译产物。

下一章我们离开输入,进入一个更抽象的话题:游戏对象该用继承还是组合来组织。你会发现,本章这层「输入缓冲」如果写成一个可复用的组件,会比写在角色类里干净得多。

阅读导航:上一节:5.2 触摸手势与多点触控 · 下一节:6.1 组件模型:从继承到组合 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画