# `gamepad` — `GamepadController` _(no submodules)_ ## Purpose Browser-tier device adapter that polls the Web Gamepad API and normalises button + axis state into the canonical 37-field `ControllerInput` shape exposed by `BaseController`. The adapter covers the W3C "standard mapping" layout (face buttons, bumpers, triggers, sticks, D-pad, system buttons) out of the box and exposes the button + axis maps so non-standard pads or custom layouts can be described per-instance. The class never reads from `navigator` at module-load time. The `GamepadSource` is either supplied via `options.source` or resolved lazily from `globalThis.navigator.getGamepads()` when `update()` runs. Tests inject a deterministic source returning fixture pads and drive the adapter directly — no jsdom or `navigator` polyfill required. ## Features - Standard W3C button mapping for all 17 indices, including analog triggers (`lt`/`rt`) and D-pad-to-digital mapping. - Standard axis mapping covering both digital halves (`right`/`left`/`up`/`down`) and centred analog representations (`lx`/`ly`/`rx`/`ry`). - Per-axis deadzone (defaults to `0.1`) applied before the magnitude is rescaled to the `[0, 255]` field range. - Per-instance `padIndex` so two adapters on the same source can drive split-screen co-op without further routing. - Connection diff — when the source returns `null`, `undefined`, or a pad with `connected: false`, the adapter zeroes the mapped fields exactly once and stops emitting until the pad reappears. - Validates every map entry against `CONTROLLER_INPUT_KEYS`; invalid entries throw `ControllerError(INVALID_GAMEPAD_MAP)` with the offending tuple in `context.entry`. - Inherits the full `BaseController` surface — `onInput`, `onAnyInput`, `setThreshold`, `getDirectionalVector`, telemetry instrumentation. ## Usage: Basic ```ts import { GamepadController } from "@suaveplan/controller/gamepad"; const gp = new GamepadController(); gp.onInput("a", (_input, value) => { if (value === 255) console.log("A pressed"); }); // pump every frame function tick() { gp.update(); requestAnimationFrame(tick); } tick(); ``` ## Usage: Advanced Drive the adapter from a synthetic source with deterministic timing — no browser globals required: ```ts import { GamepadController } from "@suaveplan/controller/gamepad"; import type { GamepadLike } from "@suaveplan/controller/gamepad"; let pad: GamepadLike | null = null; const gp = new GamepadController({ source: () => [pad], deadzone: 0.2, padIndex: 0, axisMap: { 0: { positive: "right", negative: "left", analog: "lx" }, 1: { positive: "down", negative: "up", analog: "ly" }, }, buttonMap: { 0: "a", 1: "b" }, clock: () => performance.now(), pollRate: 120, }); pad = { buttons: [ { pressed: true, value: 1 }, { pressed: false, value: 0 }, ], axes: [0.75, -0.5], }; gp.update(); console.log(gp.getValue("a")); // 255 console.log(gp.getValue("right")); // 191 ``` ## API Reference - `class GamepadController extends BaseController` - `constructor(options?: GamepadControllerOptions)` - `update(): void` — polls the source, diffs against last frame, pushes updates through `setState()`. Throws `ControllerError(GAMEPAD_API_UNAVAILABLE)` when no source is available. No-op after `destroy()`. - `getDeadzone(): number` — resolved deadzone. - `getPadIndex(): number` — bound pad index. - `getButtonMap(): GamepadButtonMap` — immutable button map. - `getAxisMap(): GamepadAxisMap` — immutable axis map. - `DEFAULT_GAMEPAD_BUTTON_MAP: GamepadButtonMap` — W3C standard mapping. - `DEFAULT_GAMEPAD_AXIS_MAP: GamepadAxisMap` — W3C standard axis layout (left + right sticks). - `interface GamepadControllerOptions extends BaseControllerOptions` — adds `source`, `buttonMap`, `axisMap`, `deadzone`, `padIndex`. - `interface GamepadLike` — structural subset of `Gamepad` (`buttons[i].pressed`, `buttons[i].value`, `axes[i]`, `connected`). - `type GamepadSource = () => readonly (GamepadLike | null | undefined)[]`. - `interface GamepadAxisBinding` — `positive`, `negative`, `analog` fields, all optional. ## Implementation Notes - Button values are taken from `buttons[i].value` when present so analog triggers report the full `[0, 255]` range. Pads that omit `value` (older drivers, software emulators) fall back to a binary `pressed ? 255 : 0`. - Axis values are clamped to `[-1, 1]` before the deadzone test so drivers that report slightly over-range readings (common on dead or worn sticks) cannot push the field above `255`. - The disconnect zero-out is one-shot — guarded by a `wasConnected` flag — so a permanently absent pad does not flood downstream consumers with redundant `value=0` events every poll. - The Zod options schema is `.strict()` so unknown options are rejected at the boundary as `ControllerError(INVALID_OPTIONS)`; field-level rejections (e.g. `deadzone: -1`) flow through the same code with the Zod issues in `context.issues`. - `super.update()` re-applies the `pollRate` gate, so callers may invoke `update()` more often than the configured rate without paying repeated traversal cost — the source function is only invoked once per gated tick.