Files
suaveplan/packages/gaming/games/controller/src/gamepad/gamepad.internal.md
2026-05-27 23:47:53 +01:00

4.3 KiB

gamepad.internal — gamepad-adapter validators + global resolver

Purpose

Module-private helpers used by GamepadController to keep the main class file under the repository's 300-line hard cap. Centralising these as standalone functions also makes them easy to fuzz from focused unit tests in gamepad.test.ts without instantiating a full controller.

The exports are not part of the package's public API — they are re-bound by name inside GamepadController and the package barrel does not surface them. Treat the file as the implementation backing store for the gamepad adapter's option-validation and global discovery surface.

Features

  • validateButtonMap(map) — verifies every entry in a GamepadButtonMap targets a key listed in CONTROLLER_INPUT_KEYS. Returns a frozen shallow copy on success; throws ControllerError(INVALID_GAMEPAD_MAP) on the first invalid entry with the offending tuple in context.entry.
  • validateAxisMap(map) — verifies each positive / negative / analog binding inside every axis entry. Each entry is independently frozen, then the outer record is frozen. Errors carry the [rawIndex, role, value] tuple in context.entry.
  • resolveGlobalGamepadSource() — discovers globalThis.navigator.getGamepads lazily, returning a bound GamepadSource when the global is present and a function. Returns undefined otherwise — the adapter only throws GAMEPAD_API_UNAVAILABLE when update() is later called without having injected a source.

Usage: Basic

import {
    validateButtonMap,
    validateAxisMap,
} from "./gamepad.internal.js";

const buttonMap = validateButtonMap({ 0: "a", 1: "b" });
const axisMap = validateAxisMap({
    0: { positive: "right", negative: "left", analog: "lx" },
});

Usage: Advanced

The validators throw at the first invalid entry, so callers that want to collect every problem can wrap each entry in a try/catch:

import {
    validateButtonMap,
    validateAxisMap,
} from "./gamepad.internal.js";

const candidate = { 0: "a", 99: "bogus" } as Record<number, string>;
const errors: unknown[] = [];
try {
    validateButtonMap(candidate as Parameters<typeof validateButtonMap>[0]);
} catch (err) {
    errors.push(err);
}
if (errors.length === 0) {
    // candidate is valid
}

// Hosts without `navigator` (Node, Bun outside a DOM polyfill)
// receive `undefined` and must inject a source explicitly:
import { resolveGlobalGamepadSource } from "./gamepad.internal.js";
const source = resolveGlobalGamepadSource();
if (source === undefined) {
    // fall back to a virtual source
}

API Reference

  • validateButtonMap(map: GamepadButtonMap): GamepadButtonMap — validates + freezes a button map. Throws ControllerError(INVALID_GAMEPAD_MAP) on invalid entries.
  • validateAxisMap(map: GamepadAxisMap): GamepadAxisMap — validates + freezes an axis map, including each per-binding inner object. Throws ControllerError(INVALID_GAMEPAD_MAP) on invalid entries.
  • resolveGlobalGamepadSource(): GamepadSource | undefined — best -effort discovery of the host's navigator.getGamepads. Always returns synchronously and never throws.

Implementation Notes

  • Validators take the input map as a value (not a class field) so the unit tests can exercise them without standing up a full GamepadController. The maps are checked once at construction time; consumers cannot mutate the runtime-bound versions.
  • Object.freeze is applied at two levels for axis maps (each binding and the outer record) so accidental rebinding via axisMap[2] = … surfaces as a TypeError in strict-mode consumers rather than silently mutating the adapter.
  • resolveGlobalGamepadSource binds the discovered function to navigator so callers that later strip the receiver (e.g. destructuring) keep the correct this. Returning () => getGamepads() rather than the raw bound function keeps the signature predictable when the underlying global is replaced after construction (rare, but observed in some test harnesses).
  • The function globalThis as unknown as { navigator?: … } cast is the only place in the gamepad module that touches a host global; centralising it here keeps the cast localised and means the Biome noExplicitAny rule never has to allow exceptions in the main class file.