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.
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 & 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)}.`);
}
}
