first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
# `keyboard` — `KeyboardController`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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:
```ts
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()`.