5.3 KiB
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
padIndexso 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 withconnected: 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 throwControllerError(INVALID_GAMEPAD_MAP)with the offending tuple incontext.entry. - Inherits the full
BaseControllersurface —onInput,onAnyInput,setThreshold,getDirectionalVector, telemetry instrumentation.
Usage: Basic
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:
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 BaseControllerconstructor(options?: GamepadControllerOptions)update(): void— polls the source, diffs against last frame, pushes updates throughsetState(). ThrowsControllerError(GAMEPAD_API_UNAVAILABLE)when no source is available. No-op afterdestroy().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— addssource,buttonMap,axisMap,deadzone,padIndex.interface GamepadLike— structural subset ofGamepad(buttons[i].pressed,buttons[i].value,axes[i],connected).type GamepadSource = () => readonly (GamepadLike | null | undefined)[].interface GamepadAxisBinding—positive,negative,analogfields, all optional.
Implementation Notes
- Button values are taken from
buttons[i].valuewhen present so analog triggers report the full[0, 255]range. Pads that omitvalue(older drivers, software emulators) fall back to a binarypressed ? 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 above255. - The disconnect zero-out is one-shot — guarded by a
wasConnectedflag — so a permanently absent pad does not flood downstream consumers with redundantvalue=0events every poll. - The Zod options schema is
.strict()so unknown options are rejected at the boundary asControllerError(INVALID_OPTIONS); field-level rejections (e.g.deadzone: -1) flow through the same code with the Zod issues incontext.issues. super.update()re-applies thepollRategate, so callers may invokeupdate()more often than the configured rate without paying repeated traversal cost — the source function is only invoked once per gated tick.