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 输入缓冲与手感 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。