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,142 @@
# `gamepad` — `GamepadController`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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.