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/main.ts
import { CONFIG_DOMAIN, STORAGE_DOMAIN, storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { startSettings } from './napplet-settings.js';
import { Game, TEAM_NAMES, type MatchOptions } from './game/game.js';
import { Hud } from './hud.js';
import { Input, TouchPad } from './game/input.js';
import { parseControls, type ControlSettings } from './game/controls.js';

/** Chip colours for players 1-4 (their armour colours in co-op). */
const PLAYER_CSS = ['#d8443a', '#dd7a2c', '#d9a92b', '#a83c7c'];
import { ControlsScreen } from './controls-ui.js';
import { ROOMS } from './game/rooms.js';
import { setSoundEnabled, sfxUiMove, sfxUiSelect, unlockAudio } from './game/sound.js';
import type { RoomId } from './game/types.js';
import './styles.css';

// Mini Samurai keeps its own lacquer-and-rice-paper palette; it never follows the host theme.

const params = new URLSearchParams(location.search);
if (import.meta.env.DEV && params.has('poses')) {
  void import('./debug-poses.js').then((m) => m.runPoseViewer(params));
} else {
  boot();
}

function el<T extends HTMLElement>(selector: string): T {
  const found = document.querySelector<T>(selector);
  if (!found) throw new Error(`Missing element ${selector}`);
  return found;
}

function boot(): void {
  const app = el<HTMLElement>('#app');
  const canvas = el<HTMLCanvasElement>('#view');
  const menu = el<HTMLElement>('#menu');
  const pauseScreen = el<HTMLElement>('#pause');
  const results = el<HTMLElement>('#results');
  const tooSmall = el<HTMLElement>('#tooSmall');

  const game = new Game(canvas);
  if (import.meta.env.DEV) (window as unknown as { __game: Game }).__game = game;
  const hud = new Hud(app);
  hud.visible = false;
  const input = new Input(canvas);
  input.onActivity = unlockAudio;
  if (matchMedia('(pointer: coarse)').matches) {
    input.touch = new TouchPad(app);
    input.touch.visible = false;
  }
  const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
  game.cam.shakeEnabled = !reducedMotion;

  type Screen = 'menu' | 'fight' | 'paused' | 'results';
  let screen: Screen = 'menu';
  const options: MatchOptions = { room: 'living', mode: '1v1', players: 1, teams: 'together', fill: 'cpus', difficulty: 'normal' };

  // ---- Menu ------------------------------------------------------------------
  const roomList = el<HTMLDivElement>('.rooms');
  for (const room of ROOMS) {
    const b = document.createElement('button');
    b.type = 'button';
    b.dataset.v = room.id;
    b.className = `room room-${room.id}`;
    b.innerHTML = `<span class="room-name">${room.name}</span><span class="room-blurb">${room.blurb}</span>`;
    roomList.append(b);
  }

  function renderMenu(): void {
    for (const seg of menu.querySelectorAll<HTMLElement>('.seg')) {
      const key = seg.dataset.key as keyof MatchOptions;
      for (const b of seg.querySelectorAll<HTMLButtonElement>('button')) {
        const on = String(options[key]) === b.dataset.v;
        b.classList.toggle('on', on);
        b.setAttribute('role', 'radio');
        b.setAttribute('aria-checked', String(on));
      }
    }
    // Players are whoever has joined: player 1 plus each controller that pressed a button.
    options.players = input.humans;
    renderPlayers();
    // Teams only matters with exactly two players in 2 vs 2 (3-4 players always split up).
    el<HTMLElement>('#teamsOpt').hidden = !(options.mode === '2v2' && options.players === 2);
    // Free for all: CPUs can fill the places nobody has joined (up to four samurai).
    el<HTMLElement>('#fillOpt').hidden = options.mode !== 'ffa';
    // CPU skill only when CPUs fight; Waves ramps the enemies' skill up by itself.
    const cpus =
      options.mode === '1v1'
        ? options.players < 2
        : options.mode === '2v2'
          ? options.players < 4
          : options.mode === 'ffa'
            ? options.players < 2 || (options.fill === 'cpus' && options.players < 4)
            : false;
    el<HTMLElement>('#skillOpt').hidden = !cpus;
    el<HTMLButtonElement>('#startBtn').textContent = options.mode === 'explore' ? 'Explore!' : 'Fight!';
  }

  menu.addEventListener('click', (e) => {
    const b = (e.target as HTMLElement).closest<HTMLButtonElement>('.seg button');
    if (!b) return;
    const key = b.closest<HTMLElement>('.seg')!.dataset.key as keyof MatchOptions;
    if (b.disabled) return;
    const changed = String(options[key]) !== b.dataset.v;
    (options as unknown as Record<string, string>)[key] = b.dataset.v!;
    unlockAudio();
    sfxUiMove();
    renderMenu();
    saveMenu();
    if (key === 'room' && changed) startAttract();
  });

  function startAttract(): void {
    game.start({ ...options, attract: true });
    updateMenuFraming();
  }

  /** On wide frames the menu card sits left, so frame the sparring samurai to its right. */
  function updateMenuFraming(): void {
    game.cam.offsetX = screen === 'menu' && app.clientWidth > 760 ? 0.2 : 0;
  }

  function showMenu(): void {
    releasePointer();
    screen = 'menu';
    game.paused = false;
    input.capture = false;
    hud.visible = false;
    if (input.touch) input.touch.visible = false;
    pauseScreen.hidden = true;
    results.hidden = true;
    menu.hidden = false;
    startAttract();
    el<HTMLButtonElement>('#startBtn').focus({ preventScroll: true });
  }

  function startMatch(): void {
    unlockAudio();
    sfxUiSelect();
    screen = 'fight';
    menu.hidden = true;
    pauseScreen.hidden = true;
    results.hidden = true;
    input.capture = true;
    input.releaseAll();
    game.paused = false;
    // Close any gaps left by controllers that went away, so players are 1, 2, 3...
    input.compactSlots();
    options.players = input.humans;
    game.start({ ...options });
    updateMenuFraming();
    hud.setup(game.fighters, options.mode === 'explore' || options.mode === 'waves' ? options.mode : 'battle');
    hud.visible = true;
    if (input.touch) input.touch.visible = true;
    canvas.focus({ preventScroll: true });
  }

  // Pointer lock keeps the cursor from leaving the game while mouse-looking.
  // Hosts may not allow it (sandboxed frames); plain mouse movement still turns the camera.
  let pointerLocked = false;
  canvas.addEventListener('pointerdown', (e) => {
    if (screen !== 'fight' || e.pointerType !== 'mouse' || document.pointerLockElement) return;
    try {
      const result = canvas.requestPointerLock() as unknown;
      if (result instanceof Promise) result.catch(() => undefined);
    } catch {
      /* not permitted here */
    }
  });
  document.addEventListener('pointerlockchange', () => {
    const locked = document.pointerLockElement === canvas;
    // Esc releases the lock before the game sees the key, so treat that as pause.
    if (pointerLocked && !locked && screen === 'fight') setPaused(true);
    pointerLocked = locked;
  });
  function releasePointer(): void {
    if (document.pointerLockElement) document.exitPointerLock();
  }

  function setPaused(p: boolean): void {
    if (screen !== 'fight' && screen !== 'paused') return;
    if (p) releasePointer();
    screen = p ? 'paused' : 'fight';
    game.paused = p;
    input.capture = !p;
    input.releaseAll();
    pauseScreen.hidden = !p;
    if (p) pauseScreen.querySelector<HTMLButtonElement>('[data-act="resume"]')!.focus({ preventScroll: true });
    else canvas.focus({ preventScroll: true });
  }

  el<HTMLButtonElement>('#startBtn').addEventListener('click', startMatch);
  for (const overlay of [pauseScreen, results]) {
    overlay.addEventListener('click', (e) => {
      const act = (e.target as HTMLElement).closest<HTMLButtonElement>('button')?.dataset.act;
      if (act === 'resume') setPaused(false);
      else if (act === 'restart') startMatch();
      else if (act === 'menu') showMenu();
      else if (act === 'controls') openControls();
    });
  }
  window.addEventListener('keydown', (e) => {
    if (screen === 'paused' && !controlsScreen.isOpen && !e.repeat && (e.code === 'Escape' || e.code === 'KeyP')) {
      e.preventDefault();
      setPaused(false);
    }
  });

  game.onMatchEnd = (team) => {
    releasePointer();
    screen = 'results';
    input.capture = false;
    if (input.touch) input.touch.visible = false;
    const humanWon = game.fighters.some((f) => f.team === team && f.human);
    const seal = results.querySelector<HTMLElement>('.seal')!;
    if (options.mode === 'waves') {
      // Waves always ends in defeat; the score is how far you got.
      seal.textContent = '終';
      el<HTMLElement>('#resultText').textContent = `Fell on wave ${game.wave} · ${game.kills} KO${game.kills === 1 ? '' : 's'}`;
    } else {
      seal.textContent = '勝';
      el<HTMLElement>('#resultText').textContent =
        options.mode === 'ffa'
          ? `${game.winnerName(team)}${humanWon || game.humanCount > 1 ? '' : '. Try again?'}`
          : humanWon || game.humanCount > 1
            ? game.winnerName(team)
            : `${TEAM_NAMES[team]} team wins. Try again?`;
    }
    results.hidden = false;
    results.querySelector<HTMLButtonElement>('[data-act="restart"]')!.focus({ preventScroll: true });
  };

  // ---- Full screen ------------------------------------------------------------------
  // One button in the menu and one on the pause screen toggle between full screen and windowed.
  // Hosts that don't allow full screen (sandboxed frames) simply don't show the button.
  const fullscreenButtons = [...document.querySelectorAll<HTMLButtonElement>('.fullscreen-btn')];
  function syncFullscreen(): void {
    const on = document.fullscreenElement !== null;
    for (const b of fullscreenButtons) {
      const label = on ? 'Windowed' : 'Full screen';
      // The menu's corner button is icon-only; its label lives in the tooltip and for screen readers.
      if (b.classList.contains('title-fs')) {
        b.title = label;
        b.setAttribute('aria-label', label);
        b.classList.toggle('on', on);
      } else b.textContent = label;
      b.setAttribute('aria-pressed', String(on));
      b.hidden = !document.fullscreenEnabled;
    }
  }
  async function toggleFullscreen(): Promise<void> {
    try {
      if (document.fullscreenElement) await document.exitFullscreen();
      else await document.documentElement.requestFullscreen();
    } catch {
      // Refused by the browser or host: stay as we are.
    }
    syncFullscreen();
  }
  for (const b of fullscreenButtons) b.addEventListener('click', () => void toggleFullscreen());
  document.addEventListener('fullscreenchange', syncFullscreen);
  syncFullscreen();

  // ---- Controls screen -------------------------------------------------------------
  const controlsScreen = new ControlsScreen(app, input);
  let controlsReturn: HTMLElement | null = null;
  function openControls(): void {
    unlockAudio();
    sfxUiSelect();
    controlsReturn = document.activeElement instanceof HTMLElement ? document.activeElement : null;
    controlsScreen.open();
  }
  controlsScreen.onClose = () => {
    sfxUiMove();
    controlsReturn?.focus({ preventScroll: true });
  };
  controlsScreen.onChange = (settings: ControlSettings) => {
    if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
    Promise.resolve()
      .then(() => storage.setItem('controls', JSON.stringify(settings)))
      .catch(() => undefined);
  };
  el<HTMLButtonElement>('#controlsBtn').addEventListener('click', openControls);
  if (runtimeHasDomain(STORAGE_DOMAIN)) {
    Promise.resolve()
      .then(() => storage.getItem('controls'))
      .then((saved) => {
        if (saved) input.setSettings(parseControls(saved));
      })
      .catch(() => undefined);
  }

  // ---- Controllers -------------------------------------------------------------------
  const toast = document.createElement('div');
  toast.className = 'toast';
  toast.setAttribute('aria-live', 'polite');
  toast.hidden = true;
  app.append(toast);
  let toastTimer = 0;
  function showToast(text: string): void {
    toast.textContent = text;
    toast.hidden = false;
    window.clearTimeout(toastTimer);
    toastTimer = window.setTimeout(() => (toast.hidden = true), 2400);
  }
  input.onJoin = (player) => {
    unlockAudio();
    sfxUiSelect();
    // Joining mid-match: their samurai appears from the next match.
    const playing = screen === 'fight' || screen === 'paused';
    showToast(playing && !game.humanFighter(player - 1) ? `Player ${player} connected — joins next match` : `Player ${player} connected`);
    controlsScreen.refresh();
    if (screen === 'menu') renderMenu();
  };
  input.onSeen = () => {
    if (screen !== 'fight') showToast('Controller found — press any button to join');
    controlsScreen.refresh();
  };
  input.onLeave = (player) => {
    showToast(`Player ${player} disconnected`);
    controlsScreen.refresh();
    if (screen === 'menu') {
      input.compactSlots();
      renderMenu();
    }
    // A controller dropping out mid-fight pauses so nobody is left standing still.
    if (screen === 'fight' && game.humanFighter(player - 1)) setPaused(true);
  };

  /** The Players row in the menu: who has joined, and an invitation for the empty places. */
  function renderPlayers(): void {
    const row = el<HTMLElement>('#playersRow');
    const chips: string[] = [];
    for (let i = 0; i < 4; i++) {
      if (i === 0) chips.push(`<span class="pchip on" style="--pc:${PLAYER_CSS[0]}"><b>P1</b>${input.slots[0] !== null ? 'Keys + pad' : 'Keyboard'}</span>`);
      else if (input.slots[i] !== null) chips.push(`<span class="pchip on" style="--pc:${PLAYER_CSS[i]}"><b>P${i + 1}</b>Controller</span>`);
      else chips.push(`<span class="pchip"><b>P${i + 1}</b>Press a button</span>`);
    }
    row.innerHTML = chips.join('');
    row.setAttribute('aria-label', `${input.humans} player${input.humans > 1 ? 's' : ''}`);
  }

  /** The screen that menus navigation applies to right now. */
  function activeScreen(): HTMLElement | null {
    if (controlsScreen.isOpen) return controlsScreen.el;
    if (screen === 'paused') return pauseScreen;
    if (screen === 'results') return results;
    if (screen === 'menu') return menu;
    return null;
  }

  /** Move focus to the nearest control in a direction (controller and arrow keys). */
  function moveFocus(dx: number, dy: number): void {
    const root = activeScreen();
    if (!root) return;
    const items = [...root.querySelectorAll<HTMLElement>('button, input[type="range"]')].filter(
      (e) => !(e as HTMLButtonElement).disabled && e.offsetParent !== null && !e.closest('[hidden]'),
    );
    if (!items.length) return;
    const current = document.activeElement as HTMLElement | null;
    if (!current || !items.includes(current)) {
      items[0]!.focus();
      return;
    }
    // Sliders take left/right themselves.
    if (dx && current instanceof HTMLInputElement && current.type === 'range') {
      if (dx > 0) current.stepUp(5);
      else current.stepDown(5);
      current.dispatchEvent(new Event('input', { bubbles: true }));
      return;
    }
    // Nearest control in that direction, measuring gaps between edges so a wide button
    // (Fight!) still steps to the row right next to it.
    const from = current.getBoundingClientRect();
    let best: HTMLElement | null = null;
    let bestScore = Infinity;
    for (const item of items) {
      if (item === current) continue;
      const r = item.getBoundingClientRect();
      const along = dx > 0 ? r.left - from.right : dx < 0 ? from.left - r.right : dy > 0 ? r.top - from.bottom : from.top - r.bottom;
      if (along < -4) continue;
      const across = dx
        ? Math.max(0, r.top - from.bottom, from.top - r.bottom)
        : Math.max(0, r.left - from.right, from.left - r.right);
      // Prefer the one most nearly in line when several sit in the same row.
      const centre = dx
        ? Math.abs(r.top + r.height / 2 - (from.top + from.height / 2))
        : Math.abs(r.left + r.width / 2 - (from.left + from.width / 2));
      const score = Math.max(0, along) + across * 3 + centre * 0.05;
      if (score < bestScore) {
        bestScore = score;
        best = item;
      }
    }
    if (best) {
      best.focus();
      best.scrollIntoView({ block: 'nearest' });
      sfxUiMove();
    }
  }

  /** Controller in menus: stick/D-pad moves, A selects, B backs out, Start starts or resumes. */
  function menuPads(): void {
    const m = input.menuPoll();
    if (screen === 'fight' && !controlsScreen.isOpen) return;
    if (m.dx || m.dy) moveFocus(m.dx, m.dy);
    if (m.confirm) (document.activeElement as HTMLElement | null)?.click();
    if (m.back) {
      if (controlsScreen.isOpen) controlsScreen.back();
      else if (screen === 'paused') setPaused(false);
      else if (screen === 'results') showMenu();
    }
    if (m.start && !controlsScreen.isOpen && screen === 'menu') startMatch();
  }

  // Arrow keys step between menu buttons too (Enter/Space press them as usual).
  window.addEventListener('keydown', (e) => {
    if (input.keyCapture || screen === 'fight') return;
    const dirs: Record<string, [number, number]> = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] };
    const d = dirs[e.code];
    if (!d || document.activeElement instanceof HTMLInputElement) return;
    e.preventDefault();
    moveFocus(d[0], d[1]);
  });

  // When a lone player opens the menu, keep the chips current.
  renderPlayers();

  // ---- Mouse speed ------------------------------------------------------------------
  const speedInputs = [...document.querySelectorAll<HTMLInputElement>('.mouse-speed')];
  function setMouseSpeed(value: number, save: boolean): void {
    const speed = Math.min(3, Math.max(0.25, Number.isFinite(value) ? value : 1));
    game.cam.sensitivity = speed;
    for (const input of speedInputs) {
      input.value = String(speed);
      input.parentElement!.querySelector('output')!.textContent = `${speed.toFixed(speed < 1 ? 2 : 1)}×`;
    }
    if (save && runtimeHasDomain(STORAGE_DOMAIN)) {
      Promise.resolve()
        .then(() => storage.setItem('mouseSpeed', String(speed)))
        .catch(() => undefined);
    }
  }
  for (const input of speedInputs) input.addEventListener('input', () => setMouseSpeed(Number(input.value), true));
  if (runtimeHasDomain(STORAGE_DOMAIN)) {
    Promise.resolve()
      .then(() => storage.getItem('mouseSpeed'))
      .then((saved) => {
        if (saved) setMouseSpeed(Number(saved), false);
      })
      .catch(() => undefined);
  }

  // ---- Optional host features ------------------------------------------------------
  // Remembering the menu is a nicety: any storage failure just keeps the defaults.
  function saveMenu(): void {
    if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
    Promise.resolve()
      .then(() => storage.setItem('menu', JSON.stringify(options)))
      .catch(() => undefined);
  }
  if (runtimeHasDomain(STORAGE_DOMAIN)) {
    Promise.resolve()
      .then(() => storage.getItem('menu'))
      .then((saved) => {
        if (!saved || screen !== 'menu') return;
        const parsed = JSON.parse(saved) as Partial<MatchOptions>;
        const rooms = ROOMS.map((r) => r.id as RoomId);
        if (parsed.room && rooms.includes(parsed.room)) options.room = parsed.room;
        if (parsed.mode === '1v1' || parsed.mode === '2v2' || parsed.mode === 'ffa' || parsed.mode === 'waves' || parsed.mode === 'explore') options.mode = parsed.mode;
        if (parsed.fill === 'cpus' || parsed.fill === 'empty') options.fill = parsed.fill;
        if (parsed.teams === 'together' || parsed.teams === 'split') options.teams = parsed.teams;
        if (parsed.difficulty === 'easy' || parsed.difficulty === 'normal' || parsed.difficulty === 'hard') options.difficulty = parsed.difficulty;
        renderMenu();
        startAttract();
      })
      .catch(() => undefined);
  }
  if (runtimeHasDomain(CONFIG_DOMAIN)) {
    startSettings((values) => {
      setSoundEnabled(values.sound !== false);
      game.cam.shakeEnabled = values.screenShake !== false && !reducedMotion;
    });
  }

  // ---- Layout -------------------------------------------------------------------------
  function fit(): void {
    const w = app.clientWidth;
    const h = app.clientHeight;
    game.resize(w, h);
    const small = w < 300 || h < 220;
    tooSmall.hidden = !small;
    app.classList.toggle('compact', w < 760 || h < 600);
    updateMenuFraming();
    if (small && screen === 'fight') setPaused(true);
  }
  new ResizeObserver(fit).observe(app);
  fit();

  // ---- Loop ---------------------------------------------------------------------------
  renderMenu();
  showMenu();
  let last = performance.now();
  function frame(now: number): void {
    const dt = Math.min(0.1, (now - last) / 1000);
    last = now;
    const raw = input.poll();
    menuPads();
    if (input.takePause() && !controlsScreen.isOpen) {
      if (screen === 'fight') setPaused(true);
      else if (screen === 'paused') setPaused(false);
    }
    game.update(dt, screen === 'fight' ? raw : []);
    if (screen !== 'menu') hud.update(game, dt);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
  document.addEventListener('visibilitychange', () => {
    if (document.hidden && screen === 'fight') setPaused(true);
  });
}