6.1 设备抽象与事件队列
前五章把资源、动画、物理、音频依次接进了内核。从这一章开始处理输入——玩家意图进入引擎的唯一入口。输入看似简单:按下就跳,松开就停。可一旦要同时支持键盘、鼠标、手柄和触摸,麻烦就来了:四种设备给的原始事件形状完全不同,游戏逻辑若逐个适配,就会被设备细节污染。
这一节先解决最底层的问题:把四种设备拍平成同一种事件,再按帧切分交给游戏。动作映射(6.2)与手感缓冲(6.3)都建立在这一层之上。
本章与站内既有内容的分工
站内已经有不少讲输入设计的文章,本卷不重推这些原理,只做「从零实现 + 每节真跑」。
| 主题 | 站内既有内容 | 本章这一节做什么 |
|---|---|---|
| 输入系统整体 | Defold 输入系统:动作绑定、触控键盘手柄与手势识别 | 不重推引擎用法,把设备归一化与队列写成可跑的 TS |
| 客户端输入设计 | 游戏客户端输入系统设计:手感从按下那一刻开始 | 把设计原则落到 helio 的具体数据结构与真值用例 |
| 输入缓冲与手感 | 动作游戏客户端输入缓冲 、《Cocos 游戏开发入门》5.3 输入缓冲与手感 | 抽成纯时间逻辑,用 vitest 逐帧真跑 |
| 帧与时间预算 | 2.1 帧时间预算 | 说明输入采样落在帧的哪一相位、由谁消费 |
四种设备,一种事件
先看四种设备各自的原生事件形状。键盘给你 KeyboardEvent.code(一个物理键位字符串)加 down/up;鼠标给你 button 编号、坐标与 down/up/move;触摸给的是 TouchList,每个触点带 identifier 与坐标;手柄最特殊——它没有事件,只有一份可轮询的 Gamepad 快照(buttons[i].pressed 与 axes[i])。
如果让游戏逻辑直接吃这四种东西,if (e.code === 'Space' || gp.buttons[0].pressed || ...) 会散落各处。helio 的做法是在采集层把设备抹平:所有设备都产出同一种 InputEvent。
下面 src/subsys/input/input-event.ts 层级 L1,完整可编译。它定义了归一化事件、四个转换函数,以及帧内事件队列。
// file: src/subsys/input/input-event.ts
// tier: L1
// verify: npx vitest run src/subsys/input/input-event.test.ts
//
// helio 输入子系统:设备归一化事件与帧内事件队列。
// 键盘 / 鼠标 / 手柄 / 触摸在采集层被归一化成同一种 InputEvent,
// 游戏侧只认 control 名,不再区分设备。
export type DeviceKind = 'keyboard' | 'mouse' | 'gamepad' | 'touch';
export type EventPhase = 'down' | 'up' | 'move';
/** 归一化输入事件。设备差异在采集层被抹平。 */
export interface InputEvent {
readonly frame: number;
readonly device: DeviceKind;
readonly control: string;
readonly phase: EventPhase;
readonly value: number;
readonly x: number;
readonly y: number;
}
/** 键盘:用 DOM KeyboardEvent.code(物理键位,不受键盘布局影响)。 */
export function fromKey(frame: number, code: string, phase: EventPhase): InputEvent {
return { frame, device: 'keyboard', control: code, phase, value: phase === 'up' ? 0 : 1, x: 0, y: 0 };
}
/** 指针(鼠标 / 触摸):按钮与位置归一化到同一形状。 */
export function fromPointer(
frame: number, device: 'mouse' | 'touch', control: string,
phase: EventPhase, x: number, y: number, value = 1,
): InputEvent {
return { frame, device, control, phase, value, x, y };
}
/** W3C Gamepad 标准按钮位序(前 16 个)。 */
export const GAMEPAD_BUTTONS = [
'ButtonA', 'ButtonB', 'ButtonX', 'ButtonY', 'ShoulderL', 'ShoulderR',
'TriggerL', 'TriggerR', 'Select', 'Start', 'StickL', 'StickR',
'DPadUp', 'DPadDown', 'DPadLeft', 'DPadRight',
] as const;
/** 手柄是轮询式设备:只在按钮状态相对上一帧翻转时产生边沿事件。 */
export function diffGamepadButtons(
frame: number, padIndex: number, prev: readonly boolean[], now: readonly boolean[],
): InputEvent[] {
const out: InputEvent[] = [];
const n = Math.min(prev.length, now.length);
for (let i = 0; i < n; i++) {
if (prev[i] === now[i]) continue;
const name = GAMEPAD_BUTTONS[i] ?? `Button${i}`;
out.push({
frame, device: 'gamepad', control: `Pad${padIndex}:${name}`,
phase: now[i] ? 'down' : 'up', value: now[i] ? 1 : 0, x: 0, y: 0,
});
}
return out;
}
/**
* 帧内事件队列。采集层随时 collect;帧开始时 beginFrame 把上一帧采集到的事件
* 翻成本帧可读事件并更新电平表;边沿只在本帧可见,电平跨帧保持。
*/
export class InputEventQueue {
#frame = 0;
#pending: InputEvent[] = [];
#current: InputEvent[] = [];
readonly #down = new Set<string>();
readonly #axis = new Map<string, number>();
get frame(): number { return this.#frame; }
/** 采集层调用(DOM 回调里随时可调)。 */
collect(ev: InputEvent): void { this.#pending.push(ev); }
beginFrame(frame: number): void {
this.#frame = frame;
this.#current = this.#pending;
this.#pending = [];
for (const ev of this.#current) {
if (ev.phase === 'down') { this.#down.add(ev.control); this.#axis.set(ev.control, ev.value); }
else if (ev.phase === 'up') { this.#down.delete(ev.control); this.#axis.set(ev.control, 0); }
else { this.#axis.set(ev.control, ev.value); }
}
}
events(): readonly InputEvent[] { return this.#current; }
ofDevice(device: DeviceKind): InputEvent[] { return this.#current.filter((e) => e.device === device); }
/** 电平:此刻是否按住(跨帧保持)。 */
isDown(control: string): boolean { return this.#down.has(control); }
/** 轴值:摇杆 / 扳机。 */
axis(control: string): number { return this.#axis.get(control) ?? 0; }
/** 边沿:本帧是否按下。 */
pressed(control: string): boolean {
return this.#current.some((e) => e.control === control && e.phase === 'down');
}
/** 边沿:本帧是否松开。 */
released(control: string): boolean {
return this.#current.some((e) => e.control === control && e.phase === 'up');
}
}
control 是归一化后的「控制名」,四种设备各有一套命名约定:键盘直接用 KeyboardEvent.code(如 KeyW、Space——用物理键位而非 key,这样 AZERTY 键盘上「WASD」仍是同一物理区域);鼠标用 Pointer<button>;触摸用 Pointer<id>;手柄用 Pad<索引>:ButtonX 与 Pad<索引>:Axis0。游戏侧只认这些名字,完全不知道它们来自哪种设备。
用 code 而不是 key 是有代价的:code 表达的是物理位置,所以中文输入法激活时它依然稳定。这也是绝大多数引擎的选择。
帧内事件队列:边沿与电平
DOM 事件在任意时刻到达,可能一帧内来好几个,也可能两帧之间一个都没有。而游戏逻辑是按帧跑的,它需要一个稳定的每帧快照。InputEventQueue 用一个双缓冲解决:采集层(DOM 回调)随时 collect 进 #pending;帧开始时 beginFrame 把 #pending 整体换给 #current,游戏这一帧只读 #current。
这里最关键的一对概念是边沿与电平:
- 边沿(edge):
pressed/released,只在事件发生的那一帧为真。跳跃、开火、切枪这类「一次触发」的操作用边沿。 - 电平(level):
isDown/axis,描述「此刻是否按住」,跨帧保持。移动、瞄准这类「持续状态」用电平。
为什么两者都要?只用电平会漏掉「同一帧内按下又松开」——电平是 0,但玩家确实点了一下;只用边沿则无法表达「一直按住前进」。#down 表在 beginFrame 里按事件顺序更新,所以「同帧 down 再 up」会被正确收敛成电平 0,而两个边沿仍在本帧可见。
手柄是唯一需要特殊处理的:它没有 DOM 事件,必须在主循环里主动轮询,再用 diffGamepadButtons 和上一帧快照做差,把「翻转」补成边沿。这就是 prev 参数的用途。
真实设备采集:L4,命令行不可复现
上面那层完全是纯逻辑,但把它接到真实设备需要浏览器的 DOM。attachDomInput 把 keydown / keyup / pointer* 接进队列,并用一个定时器轮询 navigator.getGamepads()。
下面 src/subsys/input/dom-input-source.ts 层级 L4——它依赖 window、navigator、Gamepad 等浏览器全局,本节需在浏览器中手动验证,命令行不可复现。该文件只保证通过 tsc 类型检查,不在 Node 里运行。
// file: src/subsys/input/dom-input-source.ts
// tier: L4
//
// 真实设备采集:把 DOM 的 KeyboardEvent / PointerEvent 与轮询式 Gamepad
// 接进 InputEventQueue。依赖 window / navigator.getGamepads,命令行不可复现,
// 需在浏览器中手动验证(本文件仅通过 tsc 类型检查)。
import { InputEventQueue, diffGamepadButtons, fromKey, fromPointer } from './input-event.ts';
/** 每帧轮询手柄:与上一帧按钮状态做 diff,轴值作为 move 事件。 */
export class GamepadPoller {
#prev: boolean[][] = [];
poll(frame: number, queue: InputEventQueue, pads: readonly (Gamepad | null)[]): void {
for (let i = 0; i < pads.length; i++) {
const pad = pads[i];
if (!pad) continue;
const now = pad.buttons.map((b) => b.pressed);
const prev = this.#prev[i] ?? new Array(now.length).fill(false);
for (const ev of diffGamepadButtons(frame, i, prev, now)) queue.collect(ev);
for (let a = 0; a < pad.axes.length; a++) {
queue.collect({ frame, device: 'gamepad', control: `Pad${i}:Axis${a}`, phase: 'move', value: pad.axes[a] ?? 0, x: 0, y: 0 });
}
this.#prev[i] = now;
}
}
}
/** 把 DOM 事件源接到队列上,返回解绑函数。 */
export function attachDomInput(target: Window, queue: InputEventQueue, poller: GamepadPoller): () => void {
const key = (phase: 'down' | 'up') => (e: KeyboardEvent): void => {
queue.collect(fromKey(queue.frame, e.code, phase));
};
const pointer = (phase: 'down' | 'up' | 'move') => (e: PointerEvent): void => {
const device = e.pointerType === 'touch' ? 'touch' : 'mouse';
queue.collect(fromPointer(queue.frame, device, `Pointer${e.button}`, phase, e.clientX, e.clientY, phase === 'up' ? 0 : 1));
};
const onKeyDown = key('down');
const onKeyUp = key('up');
const onPointerDown = pointer('down');
const onPointerUp = pointer('up');
const onPointerMove = pointer('move');
target.addEventListener('keydown', onKeyDown);
target.addEventListener('keyup', onKeyUp);
target.addEventListener('pointerdown', onPointerDown);
target.addEventListener('pointerup', onPointerUp);
target.addEventListener('pointermove', onPointerMove);
const handle = target.setInterval(() => poller.poll(queue.frame, queue, navigator.getGamepads()), 16);
return (): void => {
target.removeEventListener('keydown', onKeyDown);
target.removeEventListener('keyup', onKeyUp);
target.removeEventListener('pointerdown', onPointerDown);
target.removeEventListener('pointerup', onPointerUp);
target.removeEventListener('pointermove', onPointerMove);
target.clearInterval(handle);
};
}
注意 queue.collect 里用的是 queue.frame——采集时事件先落在 pending 缓冲,帧号只是标记,真正的切换发生在下一次 beginFrame。这样即便 DOM 回调在帧中途触发,也不会破坏这一帧的快照。触摸用 PointerEvent 而不是 TouchEvent,是因为 pointerType 已经把鼠标与触摸统一进同一套 API,采集层少一个分支。
可运行产物:归一化与边沿/电平的真跑
事件队列与归一化都是纯逻辑,可以在 Node 里真跑。下面 src/subsys/input/input-event.test.ts 层级 L1,命令 npx vitest run src/subsys/input/input-event.test.ts。
// file: src/subsys/input/input-event.test.ts
// tier: L1
// verify: npx vitest run src/subsys/input/input-event.test.ts
import { describe, expect, it } from 'vitest';
import { InputEventQueue, diffGamepadButtons, fromKey, fromPointer } from './input-event.ts';
describe('边沿与电平', () => {
it('边沿只在本帧可见,电平跨帧保持', () => {
const q = new InputEventQueue();
q.collect(fromKey(0, 'Space', 'down'));
q.beginFrame(1);
expect(q.pressed('Space')).toBe(true);
expect(q.isDown('Space')).toBe(true);
q.beginFrame(2);
expect(q.pressed('Space')).toBe(false); // 本帧没有新事件
expect(q.isDown('Space')).toBe(true); // 仍按住
});
it('同一帧内按下再松开:两个边沿都在,电平归零', () => {
const q = new InputEventQueue();
q.collect(fromKey(0, 'Space', 'down'));
q.collect(fromKey(0, 'Space', 'up'));
q.beginFrame(1);
expect(q.pressed('Space')).toBe(true);
expect(q.released('Space')).toBe(true);
expect(q.isDown('Space')).toBe(false);
});
it('未 beginFrame 前不消费事件', () => {
const q = new InputEventQueue();
q.collect(fromKey(0, 'KeyW', 'down'));
expect(q.pressed('KeyW')).toBe(false);
q.beginFrame(1);
expect(q.pressed('KeyW')).toBe(true);
});
});
describe('设备归一化', () => {
it('键盘 code 直接作为 control', () => {
expect(fromKey(3, 'KeyW', 'down')).toMatchObject({ device: 'keyboard', control: 'KeyW', value: 1 });
expect(fromKey(3, 'KeyW', 'up').value).toBe(0);
});
it('鼠标与触摸归一到同一形状,仅 device 不同', () => {
const m = fromPointer(1, 'mouse', 'Pointer0', 'down', 12, 34);
const t = fromPointer(1, 'touch', 'Pointer0', 'down', 12, 34);
expect(m.device).toBe('mouse');
expect(t.device).toBe('touch');
expect(m.x).toBe(12);
expect(t.y).toBe(34);
});
it('手柄按钮 diff:只在翻转时产生事件', () => {
const down = diffGamepadButtons(5, 0, [false, false], [true, false]);
expect(down).toHaveLength(1);
expect(down[0]).toMatchObject({ control: 'Pad0:ButtonA', phase: 'down', frame: 5 });
const up = diffGamepadButtons(9, 1, [true], [false]);
expect(up[0]).toMatchObject({ control: 'Pad1:ButtonA', phase: 'up' });
});
it('ofDevice 只挑出指定设备的事件', () => {
const q = new InputEventQueue();
q.collect(fromKey(0, 'Space', 'down'));
q.collect(fromPointer(0, 'mouse', 'Pointer0', 'down', 1, 2));
q.beginFrame(1);
expect(q.ofDevice('keyboard')).toHaveLength(1);
expect(q.ofDevice('gamepad')).toHaveLength(0);
});
});
本机实跑:Test Files 1 passed、Tests 7 passed;tsc --noEmit 零错误。三条「边沿与电平」用例把最容易写错的语义钉死了:边沿不可跨帧、同帧 down+up 两个边沿都在、未 beginFrame 不消费。
小结
- 四种设备的原始事件形状各异,helio 在采集层把它们归一化成同一种
InputEvent,游戏侧只认control名。 - 键盘用
code(物理键位),鼠标/触摸/手柄各有一套control命名约定;手柄没有事件,靠轮询 + diff 补边沿。 - 帧内事件队列用双缓冲,把「任意时刻到达」的事件切成「按帧可读」的快照;边沿只在本帧可见,电平跨帧保持。
- 真实设备采集(DOM 胶水)是 L4,需在浏览器中手动验证,命令行不可复现。
下一节把这一层的事件接成「动作」:游戏逻辑不该认识 Space,只该认识 jump。
阅读导航:上一节:回到目录 · 下一节:6.2 动作映射与重绑定 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。