Back to Big Rig Rush
SOURCE / PINNED RELEASE

Made of little things.

Big Rig Rush

Release
ec9b6549f752…
Author-recorded commit
74b729258b1a…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40

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

src/input.ts
export interface Controls {
  up: boolean;
  down: boolean;
  left: boolean;
  right: boolean;
  /** Held trigger: the A-10's Gatling gun. */
  gun: boolean;
  /** The A-10's throttle lever: hold M to push it up, N to pull it back. */
  throttleUp: boolean;
  throttleDown: boolean;
  /** Held pickle button: the A-10 ripples its bombs. */
  bomb: boolean;
}

export type InputAction = 'confirm' | 'pause' | 'horn' | 'mute' | 'shiftUp' | 'shiftDown' | 'tow' | 'hit' | 'fire' | 'bomb' | 'weapon';

const ACTIONS: InputAction[] = ['horn', 'shiftUp', 'shiftDown', 'tow', 'hit', 'fire', 'bomb', 'weapon'];

const KEYMAP: Record<string, keyof Controls> = {
  KeyW: 'up',
  ArrowUp: 'up',
  KeyS: 'down',
  ArrowDown: 'down',
  KeyA: 'left',
  ArrowLeft: 'left',
  KeyD: 'right',
  ArrowRight: 'right',
  KeyG: 'gun',
  KeyM: 'throttleUp',
  KeyN: 'throttleDown',
  KeyB: 'bomb',
};

/** Keys that are held controls and also fire a one-off action when first pressed. */
const PRESS_ACTIONS: Record<string, InputAction> = { KeyM: 'shiftUp', KeyN: 'shiftDown', KeyB: 'bomb' };

const CONTROL_NAMES: (keyof Controls)[] = ['up', 'down', 'left', 'right', 'gun', 'throttleUp', 'throttleDown', 'bomb'];

/** Merges held keys and held on-screen buttons into one set of controls. */
export class Input {
  readonly controls: Controls = { up: false, down: false, left: false, right: false, gun: false, throttleUp: false, throttleDown: false, bomb: false };
  private readonly keys = new Set<keyof Controls>();
  private controlFor(code: string): keyof Controls | undefined {
    return KEYMAP[code];
  }
  private readonly touches = new Map<keyof Controls, Set<number>>();

  constructor(
    private readonly onAction: (action: InputAction) => void,
    private readonly onTouch: () => void,
  ) {
    window.addEventListener('keydown', (event) => this.keyDown(event));
    window.addEventListener('keyup', (event) => {
      const control = this.controlFor(event.code);
      if (control) {
        this.keys.delete(control);
        this.refresh();
      }
    });
  }

  attachButtons(buttons: Iterable<HTMLElement>): void {
    for (const button of buttons) {
      const control = button.dataset.control as keyof Controls | undefined;
      if (!control || !CONTROL_NAMES.includes(control)) continue;
      const held = new Set<number>();
      this.touches.set(control, held);
      const release = (event: PointerEvent) => {
        held.delete(event.pointerId);
        button.classList.toggle('held', held.size > 0);
        this.refresh();
      };
      button.addEventListener('pointerdown', (event) => {
        event.preventDefault();
        if (event.pointerType === 'touch') this.onTouch();
        try {
          button.setPointerCapture(event.pointerId);
        } catch {
          // The pointer may already be gone; the release handlers still clean up.
        }
        held.add(event.pointerId);
        button.classList.add('held');
        this.refresh();
      });
      button.addEventListener('pointerup', release);
      button.addEventListener('pointercancel', release);
      button.addEventListener('lostpointercapture', release);
      button.addEventListener('contextmenu', (event) => event.preventDefault());
    }
  }

  /** Tap buttons that fire a one-off action (gear shifts, horn). */
  attachActionButtons(buttons: Iterable<HTMLElement>): void {
    for (const button of buttons) {
      const action = button.dataset.action as InputAction | undefined;
      if (!action || !ACTIONS.includes(action)) continue;
      button.addEventListener('pointerdown', (event) => {
        event.preventDefault();
        if (event.pointerType === 'touch') this.onTouch();
        button.classList.add('held');
        this.onAction(action);
      });
      const release = () => button.classList.remove('held');
      button.addEventListener('pointerup', release);
      button.addEventListener('pointercancel', release);
      button.addEventListener('pointerleave', release);
      button.addEventListener('contextmenu', (event) => event.preventDefault());
    }
  }

  /** Drop everything held, e.g. when the frame loses focus. */
  reset(): void {
    this.keys.clear();
    for (const held of this.touches.values()) held.clear();
    document.querySelectorAll('.held').forEach((el) => el.classList.remove('held'));
    this.refresh();
  }

  private keyDown(event: KeyboardEvent): void {
    const control = this.controlFor(event.code);
    if (control) {
      event.preventDefault();
      this.keys.add(control);
      this.refresh();
      const action = PRESS_ACTIONS[event.code];
      if (action && !event.repeat) this.onAction(action);
      return;
    }
    if (event.repeat) return;
    if (event.code === 'KeyP' || event.code === 'Escape') {
      event.preventDefault();
      this.onAction('pause');
    } else if (event.code === 'KeyH') {
      event.preventDefault();
      this.onAction('horn');
    } else if (event.code === 'KeyR') {
      event.preventDefault();
      this.onAction('tow');
    } else if (event.code === 'KeyF') {
      event.preventDefault();
      this.onAction('hit');
    } else if (event.code === 'KeyV') {
      event.preventDefault();
      this.onAction('weapon');
    } else if (event.code === 'KeyK') {
      event.preventDefault();
      this.onAction('mute');
    } else if (event.code === 'Space' && !(event.target instanceof HTMLButtonElement)) {
      event.preventDefault();
      this.onAction('fire');
    } else if (event.code === 'Enter' || event.code === 'Space') {
      // Let a focused button handle its own activation.
      if (event.target instanceof HTMLButtonElement) return;
      event.preventDefault();
      this.onAction('confirm');
    }
  }

  private refresh(): void {
    for (const name of CONTROL_NAMES) {
      this.controls[name] = this.keys.has(name) || (this.touches.get(name)?.size ?? 0) > 0;
    }
  }
}