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 只负责把原始事件送到你手上。对照表:
| 要做的事 | mini2d | Cocos 3.8 |
|---|---|---|
| 监听键盘按下 | keydown 监听 | input.on(Input.EventType.KEY_DOWN, cb, this) |
| 判断按了哪个键 | event.code === "Space" | event.keyCode === KeyCode.SPACE |
| 监听触摸按下 | pointerdown | input.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 组件模型:从继承到组合 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。