Back to Mini Samurai
SOURCE / PINNED RELEASE

Made of little things.

Mini Samurai

Release
a7beb798adbc…
Author-recorded commit
4f97be1515b8…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b

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

src/game/input.ts
import { ACTIONS, defaultControls, type ControlSettings, type KeyLayout } from './controls.js';

/** Raw per-player input, before it is turned camera-relative. */
export interface RawInput {
  /** Screen-space stick: x right, y up/forward. */
  x: number;
  y: number;
  run: boolean;
  /** Car handbrake: the run key (left Shift) or right Shift, or the run button on a controller. */
  handbrake: boolean;
  crouch: boolean;
  jump: boolean;
  up: boolean;
  down: boolean;
  light: boolean;
  heavy: boolean;
  block: boolean;
  grab: boolean;
  /** Dodge roll (pressed this frame). */
  roll: boolean;
  /** Camera turn keys / right stick: -1 left .. 1 right. */
  turn: number;
  /** Right stick up/down: tilts the camera like moving the mouse (-1 stick up .. 1 stick down). */
  tilt: number;
  /** Mouse movement since the last frame, in pixels (player 1 only). */
  lookX: number;
  lookY: number;
}

/** Standard-mapping gamepad buttons. */
const PAD = { a: 0, b: 1, start: 9, up: 12, down: 13, left: 14, right: 15 };

export function emptyRaw(): RawInput {
  return { x: 0, y: 0, run: false, handbrake: false, crouch: false, jump: false, up: false, down: false, light: false, heavy: false, block: false, grab: false, roll: false, turn: 0, tilt: 0, lookX: 0, lookY: 0 };
}

/** Touch overlay: a floating stick on the left half, action buttons on the right. */
export class TouchPad {
  readonly el: HTMLDivElement;
  private stickId: number | null = null;
  private origin = { x: 0, y: 0 };
  private vec = { x: 0, y: 0 };
  private readonly held = new Set<string>();
  private readonly pressed = new Set<string>();
  private readonly knob: HTMLDivElement;
  private readonly base: HTMLDivElement;

  constructor(parent: HTMLElement) {
    this.el = document.createElement('div');
    this.el.className = 'touch';
    this.el.innerHTML = `
      <div class="touch-stick"><div class="touch-base"><div class="touch-knob"></div></div></div>
      <div class="touch-buttons">
        <button data-act="block" aria-label="Block">守<small>Block</small></button>
        <button data-act="heavy" aria-label="Heavy attack">重<small>Heavy</small></button>
        <button data-act="light" class="big" aria-label="Attack">斬<small>Attack</small></button>
        <button data-act="crouch" aria-label="Crouch or drop">伏<small>Crouch</small></button>
        <button data-act="grab" aria-label="Pick up">取<small>Grab</small></button>
        <button data-act="jump" class="big" aria-label="Jump or climb">跳<small>Jump</small></button>
        <button data-act="roll" aria-label="Roll">転<small>Roll</small></button>
      </div>`;
    parent.append(this.el);
    this.base = this.el.querySelector('.touch-base')!;
    this.knob = this.el.querySelector('.touch-knob')!;
    const stick = this.el.querySelector<HTMLDivElement>('.touch-stick')!;
    stick.addEventListener('pointerdown', (e) => {
      if (this.stickId !== null) return;
      this.stickId = e.pointerId;
      stick.setPointerCapture(e.pointerId);
      const r = stick.getBoundingClientRect();
      this.origin = { x: e.clientX, y: e.clientY };
      this.base.style.left = `${e.clientX - r.left}px`;
      this.base.style.top = `${e.clientY - r.top}px`;
      this.base.classList.add('on');
      this.moveStick(e);
    });
    stick.addEventListener('pointermove', (e) => {
      if (e.pointerId === this.stickId) this.moveStick(e);
    });
    const end = (e: PointerEvent) => {
      if (e.pointerId !== this.stickId) return;
      this.stickId = null;
      this.vec = { x: 0, y: 0 };
      this.knob.style.transform = '';
      this.base.classList.remove('on');
    };
    stick.addEventListener('pointerup', end);
    stick.addEventListener('pointercancel', end);
    for (const btn of this.el.querySelectorAll<HTMLButtonElement>('button')) {
      const act = btn.dataset.act!;
      btn.addEventListener('pointerdown', (e) => {
        e.preventDefault();
        btn.setPointerCapture(e.pointerId);
        this.held.add(act);
        this.pressed.add(act);
        btn.classList.add('down');
      });
      const up = () => {
        this.held.delete(act);
        btn.classList.remove('down');
      };
      btn.addEventListener('pointerup', up);
      btn.addEventListener('pointercancel', up);
      btn.addEventListener('contextmenu', (e) => e.preventDefault());
    }
  }

  private moveStick(e: PointerEvent): void {
    const max = 46;
    let dx = e.clientX - this.origin.x;
    let dy = e.clientY - this.origin.y;
    const len = Math.hypot(dx, dy);
    if (len > max) {
      dx = (dx / len) * max;
      dy = (dy / len) * max;
    }
    this.knob.style.transform = `translate(${dx}px, ${dy}px)`;
    this.vec = { x: dx / max, y: -dy / max };
  }

  read(into: RawInput): void {
    const len = Math.hypot(this.vec.x, this.vec.y);
    if (len > 0.15) {
      into.x += this.vec.x;
      into.y += this.vec.y;
      if (len > 0.92) into.run = true;
    }
    if (this.held.has('crouch')) into.crouch = true;
    if (this.held.has('block')) into.block = true;
    if (this.pressed.has('jump')) into.jump = into.up = true;
    if (this.pressed.has('crouch')) into.down = true;
    if (this.pressed.has('light')) into.light = true;
    if (this.pressed.has('heavy')) into.heavy = true;
    if (this.pressed.has('grab')) into.grab = true;
    if (this.pressed.has('roll')) into.roll = true;
    this.pressed.clear();
  }

  release(): void {
    this.held.clear();
    this.pressed.clear();
    this.stickId = null;
    this.vec = { x: 0, y: 0 };
  }

  set visible(v: boolean) {
    this.el.hidden = !v;
  }
}


/** Edges for driving menus with any controller. */
export interface MenuPad {
  dx: number;
  dy: number;
  confirm: boolean;
  back: boolean;
  start: boolean;
}

export const MAX_PLAYERS = 4;

export class Input {
  private readonly down = new Set<string>();
  private readonly pressed = new Set<string>();
  private mouseLight = false;
  private mouseHeavy = false;
  private lookX = 0;
  private lookY = 0;
  /** Last frame's buttons per gamepad index (for presses and joining). */
  private readonly padPrev = new Map<number, boolean[]>();
  private readonly menuPrev = new Map<number, { buttons: boolean[]; dx: number; dy: number }>();
  /** Pads that joined this frame: their joining press shouldn't also act. */
  private readonly justJoined = new Set<number>();
  /**
   * Which gamepad plays as each player (by browser gamepad index), or null.
   * Player 1 always has the keyboard and mouse; slot 0 is only used when p1Pad is on.
   */
  readonly slots: (number | null)[] = [null, null, null, null];
  settings: ControlSettings = defaultControls();
  private boundCodes = new Set<string>();
  pausePressed = false;
  touch: TouchPad | null = null;
  /** While false (menus), keys keep their normal browser behaviour. */
  capture = false;
  /** Rebinding: the next key press is handed here instead of the game. */
  keyCapture: ((code: string) => void) | null = null;
  /** Rebinding: the next controller button press is handed here. */
  padCapture: ((button: number) => void) | null = null;
  /** Called on the first key or click so audio can start. */
  onActivity: () => void = () => undefined;
  /** A controller joined as (or left) a player (1-4). */
  onJoin: (player: number) => void = () => undefined;
  onLeave: (player: number) => void = () => undefined;

  constructor(target: HTMLElement) {
    this.setSettings(this.settings);
    window.addEventListener(
      'keydown',
      (e) => {
        this.onActivity();
        if (this.keyCapture) {
          e.preventDefault();
          e.stopPropagation();
          if (!e.repeat) {
            const done = this.keyCapture;
            this.keyCapture = null;
            done(e.code);
          }
          return;
        }
        if (!this.capture) return;
        if (e.code === 'Escape' || e.code === 'KeyP') {
          e.preventDefault();
          if (!e.repeat) this.pausePressed = true;
          return;
        }
        if (!this.boundCodes.has(e.code)) return;
        // Keep the browser from scrolling or focusing; Ctrl alone is a crouch key.
        e.preventDefault();
        if (!e.repeat) this.pressed.add(e.code);
        this.down.add(e.code);
      },
      true,
    );
    window.addEventListener('keyup', (e) => {
      this.down.delete(e.code);
      // Releasing Ctrl/Shift can swallow the other key's keyup on some layouts.
      if (e.key === 'Control') this.down.delete('ControlLeft'), this.down.delete('ControlRight');
      if (e.key === 'Shift') this.down.delete('ShiftLeft'), this.down.delete('ShiftRight');
    });
    target.addEventListener('pointerdown', (e) => {
      this.onActivity();
      if (e.pointerType !== 'mouse') return;
      if (e.button === 0) this.mouseLight = true;
      if (e.button === 2) this.mouseHeavy = true;
    });
    target.addEventListener('contextmenu', (e) => e.preventDefault());
    // Mouse look: works with pointer lock, or with a free cursor moving over the game.
    window.addEventListener('mousemove', (e) => {
      if (!this.capture) return;
      this.lookX += Math.max(-200, Math.min(200, e.movementX));
      this.lookY += Math.max(-200, Math.min(200, e.movementY));
    });
    window.addEventListener('blur', () => this.releaseAll());
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) this.releaseAll();
    });
    // Some browsers only hand controllers to pages that listen for them.
    window.addEventListener('gamepadconnected', () => this.onActivity());
  }

  setSettings(settings: ControlSettings): void {
    const wasP1Pad = this.settings.p1Pad;
    this.settings = settings;
    // Right Shift is always a car handbrake too (left Shift, the run key, is the main one).
    this.boundCodes = new Set([...ACTIONS.flatMap((a) => settings.keys[a]), 'ShiftRight']);
    // Turning "player 1 uses a controller" off hands that controller back to the queue.
    if (wasP1Pad && !settings.p1Pad && this.slots[0] !== null) {
      this.slots[0] = null;
      this.onLeave(1);
    }
    this.releaseAll();
  }

  releaseAll(): void {
    this.down.clear();
    this.pressed.clear();
    this.mouseLight = this.mouseHeavy = false;
    this.lookX = this.lookY = 0;
    this.touch?.release();
  }

  /** Players taking part: player 1 plus every joined controller, packed together. */
  get humans(): number {
    let n = 1;
    for (let i = 1; i < MAX_PLAYERS; i++) if (this.slots[i] !== null) n = i + 1;
    return n;
  }

  /** Close gaps left by controllers that went away (between matches). */
  compactSlots(): void {
    const pads = this.slots.slice(1).filter((s): s is number => s !== null);
    for (let i = 1; i < MAX_PLAYERS; i++) this.slots[i] = pads[i - 1] ?? null;
  }

  /** Let a joined controller go (Controls screen). */
  release(player: number): void {
    const i = player - 1;
    if (this.slots[i] === null) return;
    this.slots[i] = null;
    this.onLeave(player);
  }

  private any(codes: string[], set: Set<string>): boolean {
    for (const c of codes) if (set.has(c)) return true;
    return false;
  }

  private readKeys(map: KeyLayout, into: RawInput): void {
    const d = this.down;
    const p = this.pressed;
    into.x += (this.any(map.right, d) ? 1 : 0) - (this.any(map.left, d) ? 1 : 0);
    into.y += (this.any(map.up, d) ? 1 : 0) - (this.any(map.down, d) ? 1 : 0);
    into.run ||= this.any(map.run, d);
    into.handbrake ||= this.any(map.run, d) || d.has('ShiftRight');
    into.crouch ||= this.any(map.crouch, d) || this.any(map.crouch, p);
    into.block ||= this.any(map.block, d);
    into.jump ||= this.any(map.jump, p);
    into.up ||= this.any(map.up, p) || this.any(map.jump, p);
    into.down ||= this.any(map.down, p);
    into.light ||= this.any(map.light, p);
    into.heavy ||= this.any(map.heavy, p);
    into.grab ||= this.any(map.grab, p);
    into.roll ||= this.any(map.roll, p);
    into.turn += (this.any(map.turnRight, d) ? 1 : 0) - (this.any(map.turnLeft, d) ? 1 : 0);
  }

  /** Read every device once per frame. Returns one RawInput per player slot (4). */
  poll(): RawInput[] {
    const players = Array.from({ length: MAX_PLAYERS }, emptyRaw);
    this.readKeys(this.settings.keys, players[0]!);
    if (this.mouseLight) players[0]!.light = true;
    if (this.mouseHeavy) players[0]!.heavy = true;
    players[0]!.lookX = this.lookX;
    players[0]!.lookY = this.lookY;
    this.lookX = this.lookY = 0;
    this.touch?.read(players[0]!);
    this.pollPads(players);
    for (const p of players) {
      const len = Math.hypot(p.x, p.y);
      if (len > 1) {
        p.x /= len;
        p.y /= len;
      }
    }
    this.pressed.clear();
    this.mouseLight = this.mouseHeavy = false;
    return players;
  }

  /** Why controllers can't be read at all, if they can't (shown in the Controls screen). */
  padError: string | null = null;

  private gamepads(): (Gamepad | null)[] {
    if (typeof navigator.getGamepads !== 'function') {
      this.padError = 'This browser has no controller support.';
      return [];
    }
    try {
      const pads = [...navigator.getGamepads()];
      this.padError = null;
      return pads;
    } catch (e) {
      // Gamepad access can be blocked by policy (e.g. a host frame); keyboard and touch still work.
      this.padError = `The browser blocked controller access: ${e instanceof Error ? e.message : String(e)}`;
      return [];
    }
  }

  /** Stick positions when each controller was first seen (to notice a stick being moved). */
  private readonly restAxes = new Map<number, number[]>();
  /** Controllers the browser has shown us (joined or not). */
  private readonly seen = new Set<number>();
  /** A controller became visible but hasn't joined yet. */
  onSeen: (index: number, id: string) => void = () => undefined;

  /** Live description of what the browser reports, for the Controls screen's Controller check. */
  diagnostics(): { error: string | null; pads: { index: number; id: string; mapping: string; player: number; buttons: number[]; axes: string[] }[] } {
    const pads = this.gamepads()
      .filter((p): p is Gamepad => !!p && p.connected)
      .map((p) => ({
        index: p.index,
        id: p.id,
        mapping: p.mapping || 'non-standard',
        player: this.slots.indexOf(p.index) + 1,
        buttons: p.buttons.map((b, i) => (b.pressed ? i : -1)).filter((i) => i >= 0),
        axes: p.axes.map((a) => a.toFixed(2)),
      }));
    return { error: this.padError, pads };
  }

  /**
   * Notice controllers going away, and let any controller that presses a button
   * join as the next free player.
   */
  private scanPads(pads: (Gamepad | null)[]): void {
    this.justJoined.clear();
    for (let i = 0; i < MAX_PLAYERS; i++) {
      const index = this.slots[i];
      if (index !== null && index !== undefined && !pads[index]?.connected) {
        this.slots[i] = null;
        this.onLeave(i + 1);
      }
    }
    for (const pad of pads) {
      // Any controller counts, not only ones the browser maps to the standard layout.
      if (!pad?.connected) {
        if (pad) {
          this.padPrev.delete(pad.index);
          this.restAxes.delete(pad.index);
          this.seen.delete(pad.index);
        }
        continue;
      }
      const firstSight = !this.seen.has(pad.index);
      if (firstSight) {
        this.seen.add(pad.index);
        // Sticks rest near 0; triggers reported as axes may rest at -1. Remember where they sit.
        this.restAxes.set(pad.index, pad.axes.map((a) => (Math.abs(a) > 0.5 ? a : 0)));
      }
      // Browsers often reveal a controller only once a button is pressed on it, so a
      // button already down when we first see it is that joining press.
      const prev = this.padPrev.get(pad.index) ?? [];
      if (this.slots.includes(pad.index) || this.padCapture) continue;
      const rest = this.restAxes.get(pad.index) ?? [];
      // A button press or a firm push of a stick / D-pad (some report the D-pad as axes).
      const pressed =
        pad.buttons.some((b, i) => (b.pressed || b.value > 0.5) && !prev[i]) || pad.axes.some((a, i) => Math.abs(a - (rest[i] ?? 0)) > 0.6);
      if (!pressed) {
        if (firstSight) this.onSeen(pad.index, pad.id);
        continue;
      }
      const free = this.settings.p1Pad && this.slots[0] === null ? 0 : this.slots.findIndex((s, i) => i > 0 && s === null);
      if (free < 0) continue;
      this.slots[free] = pad.index;
      this.justJoined.add(pad.index);
      this.onJoin(free + 1);
    }
  }

  private pollPads(players: RawInput[]): void {
    // No focus check: inside a host frame the page may not report focus, and the
    // browser already stops sending controller input to background tabs.
    const pads = this.gamepads();
    this.scanPads(pads);
    const map = this.settings.pad;
    this.slots.forEach((index, player) => {
      if (index === null) return;
      const pad = pads[index];
      if (!pad) return;
      const prev = this.padPrev.get(index) ?? [];
      const btn = (i: number) => pad.buttons[i]?.pressed ?? false;
      const held = (list: number[]) => list.some(btn);
      const edge = (list: number[]) => list.some((i) => btn(i) && !prev[i]);
      if (!this.padCapture && !this.justJoined.has(index)) {
        const into = players[player]!;
        let ax = pad.axes[0] ?? 0;
        let ay = -(pad.axes[1] ?? 0);
        if (Math.hypot(ax, ay) < 0.2) ax = ay = 0;
        ax += (btn(PAD.right) ? 1 : 0) - (btn(PAD.left) ? 1 : 0);
        ay += (btn(PAD.up) ? 1 : 0) - (btn(PAD.down) ? 1 : 0);
        into.x += ax;
        into.y += ay;
        into.run ||= held(map.run);
        into.handbrake ||= held(map.run);
        into.crouch ||= held(map.crouch);
        into.block ||= held(map.block);
        into.jump ||= edge(map.jump);
        into.up ||= edge(map.jump) || (btn(PAD.up) && !prev[PAD.up]);
        into.down ||= edge(map.crouch) || (btn(PAD.down) && !prev[PAD.down]);
        into.light ||= edge(map.light);
        into.heavy ||= edge(map.heavy);
        into.grab ||= edge(map.grab);
        into.roll ||= edge(map.roll);
        // Right stick turns and tilts the camera (on unrecognised layouts axes 2-3 may be triggers).
        const rx = pad.mapping === 'standard' ? (pad.axes[2] ?? 0) : 0;
        const ry = pad.mapping === 'standard' ? (pad.axes[3] ?? 0) : 0;
        if (Math.abs(rx) > 0.25) into.turn += rx;
        if (Math.abs(ry) > 0.25) into.tilt += ry;
        if (btn(PAD.start) && !prev[PAD.start]) this.pausePressed = true;
      }
    });
    // Remember every pad's buttons (joined or not) for the next frame's presses.
    for (const pad of pads) if (pad?.connected) this.padPrev.set(pad.index, pad.buttons.map((b) => b.pressed));
  }

  /** Menu navigation from any controller: stick/D-pad steps, A confirm, B back, Start. */
  menuPoll(): MenuPad {
    const out: MenuPad = { dx: 0, dy: 0, confirm: false, back: false, start: false };
    for (const pad of this.gamepads()) {
      if (!pad?.connected) continue;
      const state = this.menuPrev.get(pad.index) ?? { buttons: pad.buttons.map((b) => b.pressed), dx: 0, dy: 0 };
      const btn = (i: number) => pad.buttons[i]?.pressed ?? false;
      const edge = (i: number) => btn(i) && !state.buttons[i];
      if (this.padCapture) {
        // Rebinding a controller button: the first new press on any controller is the answer.
        const pressed = pad.buttons.findIndex((b, i) => b.pressed && !state.buttons[i]);
        if (pressed >= 0 && pressed !== PAD.start) {
          const done = this.padCapture;
          this.padCapture = null;
          done(pressed);
        }
      } else if (!this.justJoined.has(pad.index)) {
        // Stick flicks count once until the stick returns near the middle.
        const sx = Math.abs(pad.axes[0] ?? 0) > 0.6 ? Math.sign(pad.axes[0]!) : 0;
        const sy = Math.abs(pad.axes[1] ?? 0) > 0.6 ? Math.sign(pad.axes[1]!) : 0;
        const dx = (btn(PAD.right) ? 1 : 0) - (btn(PAD.left) ? 1 : 0) || sx;
        const dy = (btn(PAD.down) ? 1 : 0) - (btn(PAD.up) ? 1 : 0) || sy;
        if (dx && dx !== state.dx) out.dx = dx;
        if (dy && dy !== state.dy) out.dy = dy;
        state.dx = dx;
        state.dy = dy;
        out.confirm ||= edge(PAD.a);
        out.back ||= edge(PAD.b);
        out.start ||= edge(PAD.start);
      }
      state.buttons = pad.buttons.map((b) => b.pressed);
      this.menuPrev.set(pad.index, state);
    }
    return out;
  }

  takePause(): boolean {
    const p = this.pausePressed;
    this.pausePressed = false;
    return p;
  }
}