《游戏引擎实现原理》6.1 设备抽象与事件队列

键盘、鼠标、手柄、触摸四种设备产生的原始事件形状完全不同,游戏逻辑不该为每种设备各写一遍分支。本节把四种设备归一化成统一的 InputEvent,实现帧内事件队列,并区分边沿与电平两种语义,最后用一组真值用例把归一化与队列逻辑在本机真跑一遍。

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 动作映射与重绑定 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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