Files
skills/docs/gaming/games/controller/src/keyboard/keyboard.md
2026-05-29 08:20:56 +01:00

5.3 KiB
Raw Blame History

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 keyMap covering arrow keys, WASD, ZXCV face buttons, QERT triggers, and Digit1–Digit4 function keys out of the box.
  • Validates every key-map entry against CONTROLLER_INPUT_KEYS at construction; invalid entries throw ControllerError(INVALID_KEY_MAP) with the offending tuple in context.entry.
  • attach() / detach() are idempotent and survive destroy() cleanly (no listeners leak, attach() after destroy is a no-op).
  • Repeats and double-presses are suppressed (event.repeat === true and already-pressed keys are ignored).
  • Optional preventDefault: true to suppress default browser behaviour (e.g. spacebar scrolling) without leaking that decision into the input pipeline.
  • Inherits the full BaseController surface — 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 BaseController
    • constructor(options?: KeyboardControllerOptions)
    • attach(): void — bind keydown/keyup to the resolved target. Throws ControllerError(KEYBOARD_TARGET_MISSING) when no target is available.
    • detach(): void — idempotent removal of listeners.
    • getPressedKeys(): readonly string[] — snapshot of physical keys currently held down (by code or key identifier).
    • getKeyMap(): KeyboardKeyMap — immutable key → input map.
    • destroy(): void — detaches listeners and clears state.
  • DEFAULT_KEYBOARD_KEY_MAP: KeyboardKeyMap — frozen default map used when options.keyMap is omitted.
  • interface KeyboardControllerOptions extends BaseControllerOptions — adds target, keyMap, autoAttach, preventDefault.
  • interface EventTargetLike — structural subset of EventTarget.
  • interface KeyboardEventLike — structural subset of KeyboardEvent (code, key, repeat, preventDefault).
  • type KeyboardKeyMap = Readonly<Record<string, keyof ControllerInput>>.

Implementation Notes

  • The adapter writes through BaseController.setState({ [input]: value }) rather than reaching into pendingStateUpdates. This keeps the OTel counters (controller_inputs_total, controller_state_changes_total) and the InputEventEmitter firing on the same code path as direct programmatic updates.
  • Key identification consults event.code first, then event.key. Both must be strings; the adapter rejects events whose identifier is undefined. This matches the upstream behaviour and aligns with W3C UI Events.
  • Repeat suppression is double-guarded: the adapter ignores event.repeat === true and 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 in context.issues. The schema is .strict() so unknown keys are rejected at the boundary — typos surface as construction errors rather than silent ignores.
  • setState accepts a partial ControllerInput, so the adapter computes the field per event rather than buffering. This keeps the per-event latency below the pollRate gate that BaseController applies to update().