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 { 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);
});
}
