5.3 KiB
keyboard — KeyboardController
(no submodules)
Purpose
Browser-tier device adapter that normalises DOM keydown/keyup
events into the canonical 37-field ControllerInput shape exposed by
BaseController. The adapter exists so games, UIs, and headless test
harnesses can consume the same controller surface whether the user is
pressing physical keys, dispatching synthetic events from a test
double, or driving the adapter from a non-DOM emitter such as a
custom virtual keyboard.
The class never touches a global at module load time. The target is
either supplied via options.target or resolved lazily from
globalThis.window when attach() runs. Tests inject an
EventTargetLike double and drive the adapter directly — no jsdom or
browser environment is required.
Features
- Configurable
keyMapcovering arrow keys, WASD, ZXCV face buttons, QERT triggers, andDigit1–Digit4function keys out of the box. - Validates every key-map entry against
CONTROLLER_INPUT_KEYSat construction; invalid entries throwControllerError(INVALID_KEY_MAP)with the offending tuple incontext.entry. attach()/detach()are idempotent and survivedestroy()cleanly (no listeners leak,attach()after destroy is a no-op).- Repeats and double-presses are suppressed (
event.repeat === trueand already-pressed keys are ignored). - Optional
preventDefault: trueto suppress default browser behaviour (e.g. spacebar scrolling) without leaking that decision into the input pipeline. - Inherits the full
BaseControllersurface —onInput,onAnyInput,onCombo,setThreshold,getDirectionalVector,update(), telemetry instrumentation.
Usage: Basic
import { KeyboardController } from "@suaveplan/controller/keyboard";
const kb = new KeyboardController();
kb.onInput("up", (_input, value) => {
console.log(value === 255 ? "pressed up" : "released up");
});
// later
kb.destroy();
Usage: Advanced
Drive the adapter from a synthetic emitter — no DOM required:
import { KeyboardController } from "@suaveplan/controller/keyboard";
import type { EventTargetLike } from "@suaveplan/controller/keyboard";
const listeners = new Map<string, Set<(e: unknown) => void>>();
const target: EventTargetLike = {
addEventListener(type, listener) {
const bucket = listeners.get(type) ?? new Set();
bucket.add(listener as never);
listeners.set(type, bucket);
},
removeEventListener(type, listener) {
listeners.get(type)?.delete(listener as never);
},
};
const kb = new KeyboardController({
target,
keyMap: { Space: "a", KeyJ: "b" },
preventDefault: true,
pollRate: 120,
});
const dispatch = (type: string, event: unknown) => {
for (const cb of listeners.get(type) ?? []) cb(event as never);
};
dispatch("keydown", { code: "Space" });
console.log(kb.getValue("a")); // 255
dispatch("keyup", { code: "Space" });
kb.destroy();
API Reference
class KeyboardController extends BaseControllerconstructor(options?: KeyboardControllerOptions)attach(): void— bindkeydown/keyupto the resolved target. ThrowsControllerError(KEYBOARD_TARGET_MISSING)when no target is available.detach(): void— idempotent removal of listeners.getPressedKeys(): readonly string[]— snapshot of physical keys currently held down (bycodeorkeyidentifier).getKeyMap(): KeyboardKeyMap— immutable key → input map.destroy(): void— detaches listeners and clears state.
DEFAULT_KEYBOARD_KEY_MAP: KeyboardKeyMap— frozen default map used whenoptions.keyMapis omitted.interface KeyboardControllerOptions extends BaseControllerOptions— addstarget,keyMap,autoAttach,preventDefault.interface EventTargetLike— structural subset ofEventTarget.interface KeyboardEventLike— structural subset ofKeyboardEvent(code,key,repeat,preventDefault).type KeyboardKeyMap = Readonly<Record<string, keyof ControllerInput>>.
Implementation Notes
- The adapter writes through
BaseController.setState({ [input]: value })rather than reaching intopendingStateUpdates. This keeps the OTel counters (controller_inputs_total,controller_state_changes_total) and theInputEventEmitterfiring on the same code path as direct programmatic updates. - Key identification consults
event.codefirst, thenevent.key. Both must be strings; the adapter rejects events whose identifier isundefined. This matches the upstream behaviour and aligns with W3C UI Events. - Repeat suppression is double-guarded: the adapter ignores
event.repeat === trueand also short-circuits when the key is already tracked as pressed. - The Zod options schema covers every public option; rejections are
surfaced as
ControllerError(INVALID_OPTIONS)with the Zod issues incontext.issues. The schema is.strict()so unknown keys are rejected at the boundary — typos surface as construction errors rather than silent ignores. setStateaccepts a partialControllerInput, so the adapter computes the field per event rather than buffering. This keeps the per-event latency below thepollRategate thatBaseControllerapplies toupdate().