Back to Supersonic RC Revive
SOURCE / PINNED RELEASE

Made of little things.

Supersonic RC Revive

Release
1ba42f1ca1d6…
Author-recorded commit
baecad10b1cd…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-143146b0d6f

Archive hash verified: 90d22b206672eba4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/input.ts
// Director keyPressed() emulation. The Lingo uses Mac key codes for the
// arrows (123 left, 124 right, 125 down, 126 up) and Escape (53), and
// characters for the rest (" ", "s", "1").
import { createGamepadInput, type GamepadBindings, type Controller, type ControllerFrame } from '../../docs/examples/gamepad.js';

export type DirectorKey = 123 | 124 | 125 | 126 | 53 | ' ' | 's' | '1';

/** A controller press outside a race: focus moves, A (select), B (back), Menu (start). */
export type MenuIntent = 'up' | 'down' | 'left' | 'right' | 'select' | 'back' | 'start';

/**
 * Standard-mapping controller layout (Xbox lettering): A accelerates, X brakes
 * or reverses, B is the handbrake, the D-pad steers like the arrow keys, the
 * left stick steers by how far it is pushed (steerAxis) and Menu quits the
 * race. The buttons are on/off like keys.
 */
const PAD_BINDINGS: GamepadBindings = {
  left: [{ button: 14 }],
  right: [{ button: 15 }],
  up: [{ button: 12 }],
  down: [{ button: 13 }],
  // One signed axis each: the helper's `down` ignores the sign, so the
  // directions are read from `value`.
  stickX: [{ axis: 0 }],
  stickY: [{ axis: 1 }],
  accelerate: [{ button: 0 }],
  brake: [{ button: 2 }],
  handbrake: [{ button: 1 }],
  menu: [{ button: 9 }],
};
const PAD_KEYS: Record<string, DirectorKey> = { left: 123, right: 124, accelerate: 126, brake: 125, handbrake: ' ', menu: 53 };
const PAD_MENU: Record<string, MenuIntent> = { up: 'up', down: 'down', left: 'left', right: 'right', accelerate: 'select', handbrake: 'back', menu: 'start' };
const DIRECTIONS = ['up', 'down', 'left', 'right'] as const;
/** How far the stick must lean to move the menu highlight. */
const STICK_MENU = 0.5;
/** Held-direction repeat in menus. */
const REPEAT_DELAY_MS = 400;
const REPEAT_MS = 150;

export interface PadPoll {
  intents: MenuIntent[];
  frame?: ControllerFrame;
  /** A controller is connected but the frame is not focused, so its input is withheld. */
  waiting: boolean;
}

const CODE_TO_KEY: Record<string, DirectorKey> = {
  ArrowLeft: 123,
  ArrowRight: 124,
  ArrowDown: 125,
  ArrowUp: 126,
  KeyA: 123,
  KeyD: 124,
  KeyS: 125,
  KeyW: 126,
  Escape: 53,
  Space: ' ',
  KeyM: 's', // sound toggle; "s" is taken by WASD steering
  Digit1: '1',
};

export class Input {
  private readonly held = new Set<DirectorKey>();
  /** Virtual (touch/gamepad) sources merged in each poll. */
  private readonly virtual = new Set<DirectorKey>();
  /** Short pulses from shell-triggered NAP-KEYS actions. */
  private readonly actions = new Set<DirectorKey>();
  private readonly actionTimers = new Map<DirectorKey, ReturnType<typeof setTimeout>>();
  /** Keys held on a controller during a race, rebuilt each poll. */
  private readonly pad = new Set<DirectorKey>();
  private gamepad: ReturnType<typeof createGamepadInput> | null | undefined;
  /** When each held menu direction next repeats. */
  private readonly repeatAt = new Map<MenuIntent, number>();
  /** Menu directions held at the last poll (the stick's edges are made here). */
  private dirsDown = new Set<string>();
  /** After clear(): directions already held give no press. */
  private dirsSuppressed = true;
  /** The left stick's lean during a race, -1 (full left) to 1 (full right), past the dead zone. */
  private stick = 0;

  /** Analog steering from a controller stick: -1 full left … 1 full right; 0 without one. */
  steerAxis(): number {
    return this.stick;
  }

  keyPressed(key: DirectorKey): boolean {
    return this.held.has(key) || this.virtual.has(key) || this.actions.has(key) || this.pad.has(key);
  }

  /**
   * Read the controllers once per animation frame. In a race their buttons
   * become held keys; elsewhere their presses come back as menu intents.
   */
  pollGamepad(racing: boolean, now = performance.now(), separate = false): PadPoll {
    this.pad.clear();
    this.stick = 0;
    if (this.gamepad === undefined) {
      this.gamepad = typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function'
        ? createGamepadInput(PAD_BINDINGS, { deadZone: 0.25, buttonThreshold: 0.5 })
        : null;
    }
    if (!this.gamepad) return { intents: [], waiting: false, frame: { status: 'unavailable', players: [], connected: [], disconnected: [] } };
    const frame = this.gamepad.poll();
    for (const index of frame.connected) {
      const controller = frame.players.find((c) => c.index === index);
      console.log('[controllers] connected', { index, id: controller?.id, mapping: controller?.mapping, mapped: controller?.mapped });
    }
    for (const index of frame.disconnected) console.log('[controllers] disconnected', { index, status: frame.status });
    const intents: MenuIntent[] = [];
    if (frame.status === 'inactive') {
      this.repeatAt.clear();
      this.dirsSuppressed = true;
      return { intents, waiting: frame.players.length > 0, frame };
    }
    if (separate) return { intents, waiting: false, frame };
    const down = new Set<string>();
    const pressed = new Set<string>();
    let stick = 0;
    for (const player of frame.players) {
      for (const [name, action] of Object.entries(player.actions)) {
        if (name === 'stickX' || name === 'stickY') continue;
        if (action.down) down.add(name);
        if (action.pressed) pressed.add(name);
      }
      const { stickX, stickY } = player.actions;
      // The strongest lean of any controller steers.
      if (Math.abs(stickX.value) > Math.abs(stick)) stick = stickX.value;
      if (stickX.value <= -STICK_MENU) down.add('left');
      if (stickX.value >= STICK_MENU) down.add('right');
      if (stickY.value <= -STICK_MENU) down.add('up');
      if (stickY.value >= STICK_MENU) down.add('down');
    }
    // Direction edges, from the D-pad and the stick alike.
    for (const dir of DIRECTIONS) {
      if (down.has(dir) && !this.dirsDown.has(dir) && !this.dirsSuppressed) pressed.add(dir);
      else pressed.delete(dir);
    }
    this.dirsDown = new Set(DIRECTIONS.filter((d) => down.has(d)));
    this.dirsSuppressed = false;
    if (racing) {
      this.stick = stick;
      // Buttons only: the D-pad steers like the arrow keys, and the stick's
      // lean goes to steerAxis rather than to 123/124.
      for (const player of frame.players) {
        for (const [name, action] of Object.entries(player.actions)) {
          const key = PAD_KEYS[name];
          if (action.down && key !== undefined) this.pad.add(key);
        }
      }
      this.repeatAt.clear();
      return { intents, waiting: false, frame };
    }
    for (const name of pressed) {
      const intent = PAD_MENU[name];
      if (intent) intents.push(intent);
    }
    for (const dir of DIRECTIONS) {
      if (!down.has(dir)) this.repeatAt.delete(dir);
      else if (pressed.has(dir)) this.repeatAt.set(dir, now + REPEAT_DELAY_MS);
      else {
        const at = this.repeatAt.get(dir);
        if (at === undefined) this.repeatAt.set(dir, now + REPEAT_DELAY_MS);
        else if (now >= at) {
          intents.push(dir);
          this.repeatAt.set(dir, now + REPEAT_MS);
        }
      }
    }
    return { intents, waiting: false, frame };
  }

  /** A local player's snapshot; this never polls the browser a second time. */
  applyController(controller: Controller): void {
    this.pad.clear();
    this.stick = controller.actions.stickX?.value ?? 0;
    for (const [name, action] of Object.entries(controller.actions)) {
      const key = PAD_KEYS[name];
      if (name !== 'menu' && action.down && key !== undefined) this.pad.add(key);
    }
  }

  setVirtual(key: DirectorKey, down: boolean): void {
    if (down) this.virtual.add(key);
    else this.virtual.delete(key);
  }

  /** Turn a shell-triggered action into a brief control pulse. Key repeat extends it. */
  pulseAction(key: DirectorKey, durationMs = 160): void {
    const previous = this.actionTimers.get(key);
    if (previous !== undefined) clearTimeout(previous);
    this.actions.add(key);
    this.actionTimers.set(
      key,
      setTimeout(() => {
        this.actions.delete(key);
        this.actionTimers.delete(key);
      }, durationMs),
    );
  }

  clear(): void {
    this.held.clear();
    this.virtual.clear();
    this.actions.clear();
    this.pad.clear();
    this.stick = 0;
    this.repeatAt.clear();
    this.dirsSuppressed = true;
    this.gamepad?.reset();
    for (const timer of this.actionTimers.values()) clearTimeout(timer);
    this.actionTimers.clear();
  }

  attach(target: Window): () => void {
    const down = (e: KeyboardEvent) => {
      // Typing in a text field (the pack link) is not driving.
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
      const key = CODE_TO_KEY[e.code];
      if (key === undefined) return;
      this.held.add(key);
      if (e.code.startsWith('Arrow') || e.code === 'Space') e.preventDefault();
    };
    const up = (e: KeyboardEvent) => {
      const key = CODE_TO_KEY[e.code];
      if (key !== undefined) this.held.delete(key);
    };
    const blur = () => this.clear();
    target.addEventListener('keydown', down);
    target.addEventListener('keyup', up);
    target.addEventListener('blur', blur);
    return () => {
      target.removeEventListener('keydown', down);
      target.removeEventListener('keyup', up);
      target.removeEventListener('blur', blur);
    };
  }
}