《游戏引擎实现原理》6.2 动作映射与重绑定

游戏逻辑不该认识 Space 或 Pad0:ButtonA。本节把动作名与物理控制解耦成一张数据绑定表,支持多设备并行绑定、运行时重绑定、同键冲突检测与版本化持久化,并用 vitest 在本机真跑重绑定、冲突检测与按键配置往返序列化的完整用例。

6.2 动作映射与重绑定

6.1 把设备拍平成了统一的 InputEvent,但游戏逻辑仍会读到 KeyW、Pad0:ButtonA 这种物理控制名。这一节再往上抽一层:让游戏只认识动作(jump、moveX),把「动作到物理控制」的对应关系变成一张数据表。有了这层解耦,重绑定、多设备、按键配置存档都变成对表的操作,而不是改代码。

游戏逻辑不该认识 Space

先看反面写法。如果玩家脚本里直接写:

// snippet: 反面写法,本节不采用
if (queue.pressed('Space')) jump();
if (queue.isDown('KeyD')) moveX += 1;

那么「用键盘还是手柄」这个决定就硬编码进了玩法逻辑。要做「同时支持键盘与手柄」,得写两遍;要做「允许玩家改键」,得改代码;要存档按键配置,更无从下手。

正解是间接一层:玩法只问「jump 这个动作现在按下了吗」,jump 背后绑了哪些物理控制由数据决定。helio 把这张表叫 ActionMapSpec——动作名到一组绑定的映射。

绑定表:动作 → 多设备控制

下面 src/subsys/input/action-map.ts 层级 L1,完整可编译。它包含绑定类型、动作解析、冲突检测、重绑定与序列化。

// file: src/subsys/input/action-map.ts
// tier: L1
// verify: npx vitest run src/subsys/input/action-map.test.ts
//
// helio 输入子系统:动作映射与重绑定。
// 游戏逻辑只引用「动作」(如 'jump');动作到物理控制的对应关系存在数据里,
// 可多设备、可重绑定、可持久化,并在加载时做冲突检测。

import type { DeviceKind, InputEventQueue } from './input-event.ts';

/** 一条绑定:把某设备上的某个 control 接到一个动作上。 */
export interface Binding {
  readonly device: DeviceKind;
  readonly control: string;
  /** 轴绑定方向;按键绑定省略。 */
  readonly axis?: 'positive' | 'negative';
  readonly scale?: number;
}

/** 动作名 → 该动作的所有绑定。 */
export type ActionMapSpec = Readonly<Record<string, readonly Binding[]>>;

/** 按键类动作:任一绑定被按住即为真。 */
export function actionDown(spec: ActionMapSpec, queue: InputEventQueue, action: string): boolean {
  const bindings = spec[action];
  if (bindings === undefined) return false;
  for (const b of bindings) {
    if (b.axis !== undefined || b.device === 'gamepad') {
      const a = queue.axis(b.control) * (b.scale ?? 1);
      if (b.axis === 'negative' ? a < -0.5 : a > 0.5) return true;
    } else if (queue.isDown(b.control)) {
      return true;
    }
  }
  return false;
}

/** 轴类动作:所有绑定带符号求和并钳到 [-1, 1]。 */
export function actionAxis(spec: ActionMapSpec, queue: InputEventQueue, action: string): number {
  const bindings = spec[action];
  if (bindings === undefined) return 0;
  let v = 0;
  for (const b of bindings) {
    const isAxis = b.axis !== undefined || b.device === 'gamepad';
    const raw = isAxis ? queue.axis(b.control) : queue.isDown(b.control) ? 1 : 0;
    v += (b.axis === 'negative' ? -raw : raw) * (b.scale ?? 1);
  }
  return Math.max(-1, Math.min(1, v));
}

export interface Conflict {
  readonly device: DeviceKind;
  readonly control: string;
  readonly actions: readonly string[];
}

/** 冲突检测:同一个 control 被绑到了多个动作上。 */
export function findConflicts(spec: ActionMapSpec): Conflict[] {
  const byControl = new Map<string, { device: DeviceKind; control: string; actions: Set<string> }>();
  for (const [action, bindings] of Object.entries(spec)) {
    for (const b of bindings) {
      const key = `${b.device}:${b.control}`;
      const e = byControl.get(key) ?? { device: b.device, control: b.control, actions: new Set<string>() };
      e.actions.add(action);
      byControl.set(key, e);
    }
  }
  const out: Conflict[] = [];
  for (const e of byControl.values()) {
    if (e.actions.size > 1) out.push({ device: e.device, control: e.control, actions: [...e.actions].sort() });
  }
  return out;
}

/** 重绑定:只替换动作的一个槽位,返回新表(原表不动)。 */
export function rebind(spec: ActionMapSpec, action: string, index: number, binding: Binding): ActionMapSpec {
  const cur = spec[action];
  if (cur === undefined) throw new Error(`unknown action: ${action}`);
  if (index < 0 || index >= cur.length) throw new Error(`binding index out of range: ${index}`);
  const next = cur.slice();
  next[index] = binding;
  return { ...spec, [action]: next };
}

export interface ActionMapFile {
  readonly version: number;
  readonly bindings: ActionMapSpec;
}

const ACTION_MAP_VERSION = 1;

export function serializeActionMap(spec: ActionMapSpec): string {
  const file: ActionMapFile = { version: ACTION_MAP_VERSION, bindings: spec };
  return JSON.stringify(file, null, 2);
}

/**
 * 反序列化:只接受默认表里已有的动作,丢弃未知动作;
 * 版本不符或 JSON 损坏时整体回落默认表。
 */
export function deserializeActionMap(json: string, defaults: ActionMapSpec): ActionMapSpec {
  let parsed: unknown;
  try {
    parsed = JSON.parse(json);
  } catch {
    return defaults;
  }
  const file = parsed as Partial<ActionMapFile>;
  if (file.version !== ACTION_MAP_VERSION || typeof file.bindings !== 'object' || file.bindings === null) {
    return defaults;
  }
  const src = file.bindings as Record<string, unknown>;
  const out: Record<string, readonly Binding[]> = {};
  for (const action of Object.keys(defaults)) {
    const b = src[action];
    out[action] = Array.isArray(b) ? (b as Binding[]).slice() : [...(defaults[action] ?? [])];
  }
  return out;
}

一条绑定 = 「设备 + 控制名」,再加可选的 axis 方向与 scale 缩放。axis 让一个物理摇杆轴能表达正负两个方向:moveX 绑 KeyD(正)与 KeyA(axis: 'negative'),两者相加就是一个键盘驱动的 [-1, 1] 轴;同时再绑 Pad0:Axis0,摇杆也能推同一个轴。多设备不是「二选一」,而是并存——actionAxis 把同名的所有绑定求和,谁动就算谁。

冲突检测与运行时重绑定

findConflicts 扫全表,把「同一个 (device, control) 被绑到两个以上动作」的情况挑出来。这在加载玩家按键配置时必须做:玩家可能把「跳跃」和「冲刺」都绑到 Space,游戏要么报错、要么按优先级处理,绝不能悄悄让一个动作失效。

rebind 走不可变更新:只替换指定动作的指定槽位,返回新表,原表不动。这看起来是多此一举,但它换来两个好处:一是撤销/重做按键配置只需保留旧表引用;二是避免「改表时正在读表」的竞态。表很小,复制的代价可以忽略。

持久化:版本化的按键配置

玩家改过的键必须能存档。serializeActionMap 写出 { version, bindings } 的 JSON;deserializeActionMap 读回时做两件事:版本校验(不认识的版本整体回落默认表)与未知动作过滤(只保留默认表里存在的动作)。第二点很重要——如果新版本删掉了 dash 动作,旧存档里残留的 dash 绑定不该被带回来,否则会留下永远不会触发的幽灵条目。

JSON 解析失败也回落默认表,而不是抛异常。按键配置坏了顶多回到默认键位,不该让游戏起不来。

可运行产物:重绑定、冲突与往返序列化

下面 src/subsys/input/action-map.test.ts 层级 L1,命令 npx vitest run src/subsys/input/action-map.test.ts。

// file: src/subsys/input/action-map.test.ts
// tier: L1
// verify: npx vitest run src/subsys/input/action-map.test.ts
import { describe, expect, it } from 'vitest';
import { InputEventQueue, fromKey } from './input-event.ts';
import {
  actionAxis, actionDown, deserializeActionMap, findConflicts, rebind, serializeActionMap,
  type ActionMapSpec,
} from './action-map.ts';

const SPEC: ActionMapSpec = {
  jump: [{ device: 'keyboard', control: 'Space' }, { device: 'gamepad', control: 'Pad0:ButtonA' }],
  moveX: [
    { device: 'keyboard', control: 'KeyD' },
    { device: 'keyboard', control: 'KeyA', axis: 'negative' },
    { device: 'gamepad', control: 'Pad0:Axis0' },
  ],
};

describe('动作解析', () => {
  it('多设备:同一动作键盘或手柄任一触发', () => {
    const q = new InputEventQueue();
    q.collect(fromKey(0, 'Space', 'down'));
    q.beginFrame(1);
    expect(actionDown(SPEC, q, 'jump')).toBe(true);
  });

  it('未绑定动作恒为 0 / false', () => {
    const q = new InputEventQueue();
    q.beginFrame(1);
    expect(actionDown(SPEC, q, 'dash')).toBe(false);
    expect(actionAxis(SPEC, q, 'dash')).toBe(0);
  });

  it('轴动作:键盘正负键合成一个轴', () => {
    const q = new InputEventQueue();
    q.collect(fromKey(0, 'KeyD', 'down'));
    q.beginFrame(1);
    expect(actionAxis(SPEC, q, 'moveX')).toBe(1);
    q.collect(fromKey(1, 'KeyA', 'down'));
    q.beginFrame(2);
    expect(actionAxis(SPEC, q, 'moveX')).toBe(0); // +1 与 -1 抵消
  });

  it('手柄摇杆驱动轴动作', () => {
    const q = new InputEventQueue();
    q.collect({ frame: 0, device: 'gamepad', control: 'Pad0:Axis0', phase: 'move', value: 0.8, x: 0, y: 0 });
    q.beginFrame(1);
    expect(actionAxis(SPEC, q, 'moveX')).toBeCloseTo(0.8, 5);
  });
});

describe('冲突检测与重绑定', () => {
  it('同一 control 绑到两个动作即冲突', () => {
    const c = findConflicts({
      jump: [{ device: 'keyboard', control: 'Space' }],
      dash: [{ device: 'keyboard', control: 'Space' }],
    });
    expect(c).toHaveLength(1);
    expect(c[0]).toMatchObject({ device: 'keyboard', control: 'Space', actions: ['dash', 'jump'] });
    expect(findConflicts(SPEC)).toEqual([]);
  });

  it('重绑定只改一个槽位,原表不动', () => {
    const next = rebind(SPEC, 'jump', 0, { device: 'keyboard', control: 'KeyK' });
    expect(next['jump']![0]!.control).toBe('KeyK');
    expect(next['jump']![1]!.control).toBe('Pad0:ButtonA'); // 其余槽位保留
    expect(SPEC['jump']![0]!.control).toBe('Space'); // 原表未被修改
  });

  it('重绑定越界 / 未知动作抛错', () => {
    expect(() => rebind(SPEC, 'jump', 9, { device: 'keyboard', control: 'KeyK' })).toThrow(/out of range/);
    expect(() => rebind(SPEC, 'nope', 0, { device: 'keyboard', control: 'KeyK' })).toThrow(/unknown action/);
  });
});

describe('持久化', () => {
  it('序列化往返一致,未知动作被丢弃', () => {
    expect(deserializeActionMap(serializeActionMap(SPEC), SPEC)).toEqual(SPEC);
    const withUnknown = JSON.stringify({ version: 1, bindings: { ...SPEC, teleport: [{ device: 'keyboard', control: 'KeyT' }] } });
    const back = deserializeActionMap(withUnknown, SPEC);
    expect('teleport' in back).toBe(false);
    expect(Object.keys(back).sort()).toEqual(['jump', 'moveX']);
  });

  it('损坏 JSON 与版本不符均回落默认表', () => {
    expect(deserializeActionMap('{ not json', SPEC)).toEqual(SPEC);
    expect(deserializeActionMap(JSON.stringify({ version: 99, bindings: {} }), SPEC)).toEqual(SPEC);
  });
});

本机实跑:Test Files 1 passed、Tests 9 passed;tsc --noEmit 零错误。轴动作那条用例最值得注意:键盘 KeyD(+1)与 KeyA(-1)同时按住时 actionAxis 返回 0——这不是 bug,而是「带符号求和」的正确结果,多数游戏正是靠这个让对向键互相抵消。

小结

  • 玩法只该认识动作名,动作到物理控制的对应关系放在 ActionMapSpec 这张数据表里。
  • 一个动作可以绑多个设备的多个控制,actionDown / actionAxis 把所有绑定求和,天然支持「键鼠 + 手柄」并存。
  • findConflicts 在加载配置时检测同键冲突;rebind 以不可变方式改表,方便撤销与避免竞态。
  • 持久化写 { version, bindings },读回时校验版本、过滤未知动作、解析失败回落默认表。

下一节给动作加上「时间」:跳跃缓冲、土狼时间与输入延迟补偿——玩家按下跳跃的那一帧,往往不是它应该生效的那一帧。

阅读导航:上一节:6.1 设备抽象与事件队列 · 下一节:6.3 输入缓冲与手感 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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