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/controls-ui.ts
import {
  ACTIONS,
  ACTION_LABELS,
  PAD_ACTIONS,
  RESERVED_KEYS,
  defaultControls,
  keyName,
  padButtonName,
  type Action,
  type ControlSettings,
  type PadAction,
} from './game/controls.js';
import type { Input } from './game/input.js';

type Tab = 'players' | 'keys' | 'pad';
const TABS: [Tab, string][] = [
  ['players', 'Players'],
  ['keys', 'Keyboard'],
  ['pad', 'Controller'],
];

/** The Controls screen: who uses which device, and every key and button binding. */
export class ControlsScreen {
  readonly el: HTMLElement;
  private readonly body: HTMLElement;
  private readonly status: HTMLElement;
  private tab: Tab = 'players';
  /** Binding currently waiting for a key / button press. */
  private waiting: { tab: Tab; action: string } | null = null;
  private timer = 0;
  /** Called after any change, with settings ready to save. */
  onChange: (settings: ControlSettings) => void = () => undefined;
  onClose: () => void = () => undefined;

  constructor(
    parent: HTMLElement,
    private readonly input: Input,
  ) {
    this.el = document.createElement('section');
    this.el.className = 'screen overlay controls-screen';
    this.el.hidden = true;
    this.el.setAttribute('aria-label', 'Controls');
    this.el.innerHTML = `
      <div class="card controls-card">
        <header class="controls-head">
          <h2>Controls</h2>
          <button type="button" class="start ctl-done" data-ctl="close">Done</button>
        </header>
        <div class="ctl-tabs" role="tablist">
          ${TABS.map(([id, label]) => `<button type="button" role="tab" data-tab="${id}">${label}</button>`).join('')}
        </div>
        <div class="ctl-body"></div>
        <footer class="ctl-foot">
          <output class="ctl-status" aria-live="polite"></output>
          <button type="button" data-ctl="reset">Reset all to defaults</button>
        </footer>
      </div>`;
    parent.append(this.el);
    this.body = this.el.querySelector('.ctl-body')!;
    this.status = this.el.querySelector('.ctl-status')!;

    this.el.addEventListener('click', (e) => {
      const b = (e.target as HTMLElement).closest<HTMLButtonElement>('button');
      if (!b) return;
      if (b.dataset.tab) {
        this.cancelCapture();
        this.tab = b.dataset.tab as Tab;
        this.render();
        this.el.querySelector<HTMLButtonElement>(`[data-tab="${this.tab}"]`)?.focus();
      } else if (b.dataset.ctl === 'close') this.close();
      else if (b.dataset.ctl === 'reset') {
        this.cancelCapture();
        this.apply(defaultControls());
        this.say('Everything is back to the defaults.');
      } else if (b.dataset.ctl === 'p1pad') {
        const next = this.clone();
        next.p1Pad = !next.p1Pad;
        this.apply(next);
        this.say(next.p1Pad ? 'The next controller to join plays as player 1.' : 'Player 1 uses the keyboard and mouse only.');
      } else if (b.dataset.release) {
        this.input.release(Number(b.dataset.release));
        this.render();
      }
      else if (b.dataset.action) this.startCapture(b.dataset.action);
    });
    // Escape closes the screen (or cancels a rebinding) instead of reaching the game.
    window.addEventListener(
      'keydown',
      (e) => {
        if (this.el.hidden || e.code !== 'Escape' || this.input.keyCapture) return;
        e.preventDefault();
        e.stopImmediatePropagation();
        if (this.waiting) this.cancelCapture();
        else this.close();
      },
      true,
    );
  }

  get isOpen(): boolean {
    return !this.el.hidden;
  }

  /** Live readout of what the browser reports about controllers (Players tab). */
  private diagTimer = 0;
  private updateDiag(): void {
    const box = this.body.querySelector<HTMLElement>('.ctl-diag-body');
    if (!box) return;
    const d = this.input.diagnostics();
    const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
    let html: string;
    if (d.error) html = `<span class="bad">${esc(d.error)}</span>`;
    else if (!d.pads.length)
      html = 'Controller support works, but no controller is visible yet. With this window in front, press a button on your controller (browsers only show a controller after a button press). If nothing appears, check the controller works in another game or is paired/plugged in.';
    else
      html = d.pads
        .map((p) => {
          const who = p.player ? `<span class="good">Player ${p.player}</span>` : '<span class="bad">not joined — press a button</span>';
          const live = p.buttons.length ? `buttons ${p.buttons.join(', ')}` : 'no buttons held';
          const axes = p.axes.slice(0, 4).join(' ');
          return `<div>#${p.index + 1} ${esc(p.id.slice(0, 60))} <i>(${esc(p.mapping)})</i> → ${who}<br><small>${live} · sticks ${axes}</small></div>`;
        })
        .join('');
    if (box.innerHTML !== html) box.innerHTML = html;
  }

  open(): void {
    this.el.hidden = false;
    window.clearInterval(this.diagTimer);
    this.diagTimer = window.setInterval(() => this.updateDiag(), 200);
    this.render();
    this.el.querySelector<HTMLButtonElement>(`[data-tab="${this.tab}"]`)?.focus({ preventScroll: true });
  }

  close(): void {
    this.cancelCapture();
    window.clearInterval(this.diagTimer);
    this.el.hidden = true;
    this.onClose();
  }

  /** Controller B: cancel a pending rebind, otherwise leave the screen. */
  back(): void {
    if (this.waiting) this.cancelCapture();
    else this.close();
  }

  /** Re-render when controllers come and go. */
  refresh(): void {
    if (this.isOpen && !this.waiting) this.render();
  }

  private get settings(): ControlSettings {
    return this.input.settings;
  }

  private clone(): ControlSettings {
    return JSON.parse(JSON.stringify(this.settings)) as ControlSettings;
  }

  private apply(next: ControlSettings): void {
    this.input.setSettings(next);
    this.onChange(next);
    this.render();
  }

  private say(text: string): void {
    this.status.textContent = text;
  }

  private render(): void {
    for (const b of this.el.querySelectorAll<HTMLButtonElement>('[data-tab]')) {
      const on = b.dataset.tab === this.tab;
      b.classList.toggle('on', on);
      b.setAttribute('aria-selected', String(on));
    }
    const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
    const refocus = focused && this.body.contains(focused) ? (focused.dataset.action ?? focused.dataset.player) : null;
    if (this.tab === 'players') this.body.innerHTML = this.playersHtml();
    else if (this.tab === 'pad') this.body.innerHTML = this.padHtml();
    else this.body.innerHTML = this.keysHtml();
    this.updateDiag();
    if (refocus) this.body.querySelector<HTMLElement>(`[data-action="${refocus}"], [data-player="${refocus}"]`)?.focus({ preventScroll: true });
    else if (focused && !document.body.contains(focused)) this.el.querySelector<HTMLButtonElement>(`[data-tab="${this.tab}"]`)?.focus({ preventScroll: true });
  }

  private playersHtml(): string {
    const slots = this.input.slots;
    const p1Pad = this.settings.p1Pad;
    const p1 = `<div class="ctl-row">
        <span class="ctl-label"><b>Player 1</b><small>Keyboard &amp; mouse${p1Pad ? (slots[0] !== null ? ' + controller' : ' + the next controller to join') : ''}</small></span>
        <button type="button" class="ctl-bind" data-ctl="p1pad" aria-pressed="${p1Pad}">${p1Pad ? 'Controller too: On' : 'Controller too: Off'}</button>
      </div>`;
    const others = [1, 2, 3]
      .map((i) => {
        const joined = slots[i] !== null;
        return `<div class="ctl-row${joined ? ' joined' : ''}">
          <span class="ctl-label"><b>Player ${i + 1}</b><small>${joined ? 'Controller connected' : 'Press any button on a controller to join'}</small></span>
          ${joined ? `<button type="button" class="ctl-bind" data-release="${i + 1}">Remove</button>` : '<span class="ctl-empty">—</span>'}
        </div>`;
      })
      .join('');
    return `${p1}${others}
      <div class="ctl-diag" aria-label="Controller check"><b>Controller check</b><div class="ctl-diag-body">Checking…</div></div>
      <p class="ctl-note">Player 1 plays with the keyboard and mouse. Every other player joins by pressing any
      button on their controller: the first becomes player 2, then 3 and 4. Turn on "Controller too" to play
      player 1 with a controller instead.</p>`;
  }

  private keysHtml(): string {
    const keys = this.settings.keys;
    const rows = ACTIONS.map((a) => {
      const waiting = this.waiting?.tab === this.tab && this.waiting.action === a;
      const label = waiting ? 'Press a key…' : keys[a].map(keyName).join(' / ') || '—';
      return `<div class="ctl-row${waiting ? ' waiting' : ''}">
        <span class="ctl-label">${ACTION_LABELS[a]}</span>
        <button type="button" class="ctl-bind" data-action="${a}" aria-label="${ACTION_LABELS[a]}: ${label}. Press to change.">${label}</button>
      </div>`;
    }).join('');
    return `${rows}<p class="ctl-note">The keyboard is player 1's. The mouse is too: left click attacks, right click is a
      heavy attack, moving it turns the camera. Esc and P always pause. On a ledge, forward or jump climbs and back drops.
      In a toy car: W/S drive and brake, A/D steer, Shift (the run key) is the handbrake for drifting, jump gets out.</p>`;
  }

  private padHtml(): string {
    const pad = this.settings.pad;
    const rows = PAD_ACTIONS.map((a) => {
      const waiting = this.waiting?.tab === 'pad' && this.waiting.action === a;
      const label = waiting ? 'Press a button…' : pad[a].map(padButtonName).join(' / ') || '—';
      return `<div class="ctl-row${waiting ? ' waiting' : ''}">
        <span class="ctl-label">${ACTION_LABELS[a]}</span>
        <button type="button" class="ctl-bind" data-action="${a}" aria-label="${ACTION_LABELS[a]}: ${label}. Press to change.">${label}</button>
      </div>`;
    }).join('');
    return `${rows}<p class="ctl-note">Left stick or D-pad moves, right stick turns and tilts the camera, Menu pauses.
      These buttons are shared by every controller. In menus: stick/D-pad to choose, A to select, B to go back.
      In a toy car: the left stick drives and steers, the run button is the handbrake, jump gets out.</p>`;
  }

  private startCapture(action: string): void {
    this.cancelCapture();
    if (this.tab === 'players') return;
    const tab = this.tab;
    this.waiting = { tab, action };
    this.render();
    window.clearTimeout(this.timer);
    this.timer = window.setTimeout(() => {
      if (this.waiting) {
        this.cancelCapture();
        this.say('Nothing pressed, binding unchanged.');
      }
    }, 8000);
    if (tab === 'pad') {
      this.say('Press a button on any controller (Esc to cancel).');
      this.input.padCapture = (button) => this.assignPad(action as PadAction, button);
      // Escape on the keyboard still cancels; other keys are ignored while waiting.
      const escOnly = (code: string): void => {
        if (code === 'Escape') this.cancelCapture();
        else this.input.keyCapture = escOnly;
      };
      this.input.keyCapture = escOnly;
    } else {
      this.say('Press the new key (Esc to cancel).');
      this.input.keyCapture = (code) => this.assignKey(action as Action, code);
    }
  }

  private cancelCapture(): void {
    window.clearTimeout(this.timer);
    this.input.keyCapture = null;
    this.input.padCapture = null;
    if (this.waiting) {
      this.waiting = null;
      this.render();
    }
  }

  private assignKey(action: Action, code: string): void {
    if (code === 'Escape') {
      this.cancelCapture();
      this.say('Binding unchanged.');
      return;
    }
    if (RESERVED_KEYS.has(code)) {
      this.say(`${keyName(code)} is the pause key and can't be used.`);
      this.waiting = null;
      this.cancelCapture();
      return;
    }
    const next = this.clone();
    // One key does one thing: take it away from wherever else it was bound.
    for (const a of ACTIONS) next.keys[a] = next.keys[a].filter((c) => c !== code);
    next.keys[action] = [code];
    this.waiting = null;
    window.clearTimeout(this.timer);
    this.apply(next);
    this.say(`${ACTION_LABELS[action]} is now ${keyName(code)}.`);
  }

  private assignPad(action: PadAction, button: number): void {
    const next = this.clone();
    for (const a of PAD_ACTIONS) next.pad[a] = next.pad[a].filter((b) => b !== button);
    next.pad[action] = [button];
    this.waiting = null;
    this.input.keyCapture = null;
    window.clearTimeout(this.timer);
    this.apply(next);
    this.say(`${ACTION_LABELS[action]} is now ${padButtonName(button)}.`);
  }
}