Back to Browser Side Quest
SOURCE / PINNED RELEASE

Made of little things.

Browser Side Quest

Release
b9d42f5768e4…
Author-recorded commit
c267690145fa…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest

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

src/input/input.ts
/**
 * One input layer for keyboard, gamepad and the on-screen touch pad. Pointer
 * click/tap-to-move (the original control scheme) is wired in app.ts; this adds
 * direct movement and an action button on top, all routed through the same
 * Game methods as clicks.
 */
import { createGamepadInput, type GamepadBindings } from './gamepad.js';

export interface InputTargets {
  /** The running game, or null on menus / while dead. */
  playing(): {
    stepPlayer(dx: number, dy: number): void;
    interact(): void;
    stopAttackers(): void;
  } | null;
  isTyping(): boolean;
  toggleChat(): void;
  hideWindows(): boolean;
  toggleAchievements(): void;
  toggleMute(): void;
  /** Gamepad A / Enter on menus: play, respawn, rejoin. Returns true if handled. */
  confirm(): boolean;
}

type Dir = 'up' | 'down' | 'left' | 'right';

const KEY_DIRS: Record<string, Dir> = {
  ArrowUp: 'up',
  KeyW: 'up',
  ArrowDown: 'down',
  KeyS: 'down',
  ArrowLeft: 'left',
  KeyA: 'left',
  ArrowRight: 'right',
  KeyD: 'right',
};

const DELTA: Record<Dir, [number, number]> = {
  up: [0, -1],
  down: [0, 1],
  left: [-1, 0],
  right: [1, 0],
};

const bindings: GamepadBindings = {
  moveX: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
  moveY: [{ axis: 1 }, { button: 12, scale: -1 }, { button: 13 }],
  action: [{ button: 0 }, { button: 7 }],
  cancel: [{ button: 1 }],
  achievements: [{ button: 3 }],
  chat: [{ button: 2 }],
  mute: [{ button: 9 }],
};

export class InputLayer {
  private readonly keysHeld: Dir[] = [];
  private readonly padHeld = new Set<Dir>();
  private gamepadDir: Dir | null = null;
  private readonly gamepad = createGamepadInput(bindings, { deadZone: 0.25 });
  private raf = 0;
  private disposed = false;
  private readonly cleanups: (() => void)[] = [];

  constructor(private readonly targets: InputTargets) {
    const onKeyDown = (e: KeyboardEvent): void => this.keyDown(e);
    const onKeyUp = (e: KeyboardEvent): void => this.keyUp(e);
    const onBlur = (): void => {
      this.keysHeld.length = 0;
      this.padHeld.clear();
      this.gamepadDir = null;
    };
    window.addEventListener('keydown', onKeyDown);
    window.addEventListener('keyup', onKeyUp);
    window.addEventListener('blur', onBlur);
    this.cleanups.push(() => {
      window.removeEventListener('keydown', onKeyDown);
      window.removeEventListener('keyup', onKeyUp);
      window.removeEventListener('blur', onBlur);
    });
    this.raf = requestAnimationFrame(this.frame);
  }

  /** Bind the on-screen touch pad buttons. */
  bindTouchPad(root: HTMLElement): void {
    for (const button of root.querySelectorAll<HTMLButtonElement>('.pad')) {
      const dir = button.dataset.dir as Dir | 'action';
      const release = (): void => {
        button.classList.remove('held');
        if (dir !== 'action') this.padHeld.delete(dir);
      };
      button.addEventListener('pointerdown', (e) => {
        e.preventDefault();
        button.setPointerCapture(e.pointerId);
        button.classList.add('held');
        if (dir === 'action') this.targets.playing()?.interact();
        else this.padHeld.add(dir);
      });
      button.addEventListener('pointerup', release);
      button.addEventListener('pointercancel', release);
      button.addEventListener('lostpointercapture', release);
      button.addEventListener('contextmenu', (e) => e.preventDefault());
    }
  }

  private keyDown(e: KeyboardEvent): void {
    if (e.isComposing) return;
    const typing = this.targets.isTyping();

    if (e.key === 'Enter') {
      if (!typing && this.targets.confirm()) {
        e.preventDefault();
        return;
      }
      // The chat input handles its own Enter (send / close).
      if (!typing || (e.target as HTMLElement)?.id === 'chatinput') return;
    }
    if (typing) return;

    const dir = KEY_DIRS[e.code];
    if (dir) {
      if (this.targets.playing()) e.preventDefault();
      if (!this.keysHeld.includes(dir)) this.keysHeld.push(dir);
      return;
    }

    switch (e.code) {
      case 'Space':
        if (this.targets.playing()) {
          e.preventDefault();
          if (!e.repeat) this.targets.playing()?.interact();
        }
        break;
      case 'Escape':
        this.targets.hideWindows();
        this.targets.playing()?.stopAttackers();
        break;
    }
  }

  private keyUp(e: KeyboardEvent): void {
    const dir = KEY_DIRS[e.code];
    if (!dir) return;
    const i = this.keysHeld.indexOf(dir);
    if (i >= 0) this.keysHeld.splice(i, 1);
  }

  private frame = (): void => {
    if (this.disposed) return;
    this.pollGamepad();

    const dir =
      this.keysHeld[this.keysHeld.length - 1] ??
      this.gamepadDir ??
      ([...this.padHeld][this.padHeld.size - 1] as Dir | undefined) ??
      null;
    const game = this.targets.playing();
    if (dir && game && !this.targets.isTyping()) {
      const [dx, dy] = DELTA[dir];
      game.stepPlayer(dx, dy);
    }
    this.raf = requestAnimationFrame(this.frame);
  };

  private pollGamepad(): void {
    const frame = this.gamepad.poll();
    if (frame.status !== 'ready') {
      this.gamepadDir = null;
      return;
    }
    let dir: Dir | null = null;
    for (const pad of frame.players) {
      const { moveX, moveY, action, cancel, achievements, chat, mute } = pad.actions;
      const ax = moveX.value;
      const ay = moveY.value;
      if (Math.max(Math.abs(ax), Math.abs(ay)) >= 0.5) {
        dir = Math.abs(ax) > Math.abs(ay) ? (ax < 0 ? 'left' : 'right') : ay < 0 ? 'up' : 'down';
      }
      if (action.pressed && !this.targets.confirm()) this.targets.playing()?.interact();
      if (cancel.pressed && !this.targets.hideWindows()) this.targets.playing()?.stopAttackers();
      if (achievements.pressed) this.targets.toggleAchievements();
      if (chat.pressed) this.targets.toggleChat();
      if (mute.pressed) this.targets.toggleMute();
    }
    this.gamepadDir = dir;
  }

  dispose(): void {
    this.disposed = true;
    cancelAnimationFrame(this.raf);
    this.gamepad.dispose();
    for (const cleanup of this.cleanups) cleanup();
  }
}