SOURCE / PINNED RELEASE
Made of little things.
Rail Game
- Release
- ccdd5b913413…
- Author-recorded commit
- ddf0122b6acc…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-91ec90c6fdf
Archive hash verified: dad7b678e25f96a3…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { storage, themeGet, themeOnChanged, type Theme, type Subscription } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { connectSettings } from './napplet-settings.js';
import { RailGame, STAGES, type GameEvent } from './game.js';
import { Renderer } from './renderer.js';
import { parseProgress, mergeProgress, recordRun } from './progress.js';
import './styles.css';
const el = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
const app = el('app'), canvas = el<HTMLCanvasElement>('game');
const primary = el<HTMLButtonElement>('primaryButton'), secondary = el<HTMLButtonElement>('secondaryButton');
const pauseButton = el<HTMLButtonElement>('pauseButton'), soundButton = el<HTMLButtonElement>('soundButton');
const game = new RailGame();
const renderer = new Renderer(canvas);
const lifetime = new AbortController();
const listenerOptions = { signal: lifetime.signal };
let progress = parseProgress(null);
let heldKeys = new Set<string>();
const heldPointers = new Map<number, number>();
let dragId: number | undefined, dragOrigin = 0, dragSquadOrigin = 0, targetX: number | undefined;
let frameId = 0, previousTime = 0, accumulator = 0, lastHud = 0, toastUntil = 0;
let renderedPhase = '', finishedRun = false, soundEnabled = false;
let audio: AudioContext | undefined, lastShotSound = 0;
let configReducedMotion = false;
const reducedPreference = matchMedia('(prefers-reduced-motion: reduce)');
let themeSubscription: Subscription | undefined;
let disposed = false;
let saveQueue = Promise.resolve();
const storageAvailable = runtimeHasDomain('storage');
async function timed<T>(promise: Promise<T>): Promise<T> {
let timer = 0;
try {
return await Promise.race([promise, new Promise<never>((_, reject) => { timer = window.setTimeout(() => reject(new Error('Timed out')), 5000); })]);
} finally { clearTimeout(timer); }
}
function setSaveStatus(text: string): void { el('saveStatus').textContent = text; }
const loadedProgress = storageAvailable ? timed(Promise.resolve().then(() => storage.getItem('rail-progress-v1'))).then(raw => {
progress = mergeProgress(progress, parseProgress(raw));
setSaveStatus('Progress saved on this device'); renderPicker();
}).catch(() => setSaveStatus('Save unavailable · records stay in this session')) : Promise.resolve();
function persist(): void {
if (!storageAvailable) return;
setSaveStatus('Saving progress…');
saveQueue = saveQueue.then(async () => {
await loadedProgress;
await timed(storage.setItem('rail-progress-v1', JSON.stringify(progress)));
setSaveStatus('Progress saved on this device');
}).catch(() => setSaveStatus('Could not save · records stay in this session'));
}
function applyTheme(theme: Theme): void {
if (disposed) return;
const { background, text, primary: accent } = theme.colors;
const root = document.documentElement;
root.style.setProperty('--bg', background); root.style.setProperty('--fg', text); root.style.setProperty('--primary', accent);
// Resolve arbitrary CSS colors once, then choose legible text on the action button.
const swatch = document.createElement('canvas').getContext('2d')!;
swatch.fillStyle = accent; swatch.fillRect(0, 0, 1, 1);
const pixel = swatch.getImageData(0, 0, 1, 1).data;
root.style.setProperty('--button-fg', pixel[0] * .299 + pixel[1] * .587 + pixel[2] * .114 > 150 ? '#102a24' : '#ffffff');
swatch.fillStyle = background; swatch.fillRect(0, 0, 1, 1);
const bgPixel = swatch.getImageData(0, 0, 1, 1).data;
root.style.colorScheme = bgPixel[0] * .299 + bgPixel[1] * .587 + bgPixel[2] * .114 > 150 ? 'light' : 'dark';
renderer.setTheme(background, text, accent);
}
if (runtimeHasDomain('theme')) {
try { themeSubscription = themeOnChanged(applyTheme); void timed(themeGet()).then(applyTheme).catch(() => {}); } catch { /* Local palette remains usable. */ }
}
function updateSoundButton(): void {
soundButton.setAttribute('aria-label', soundEnabled ? 'Mute sound' : 'Enable sound');
soundButton.title = soundEnabled ? 'Mute sound' : 'Enable sound';
soundButton.setAttribute('aria-pressed', String(soundEnabled)); el('soundOff').hidden = soundEnabled;
}
function activateAudio(): void {
if (!soundEnabled) return;
try { audio ??= new AudioContext(); void audio.resume().catch(() => {}); } catch { soundEnabled = false; updateSoundButton(); }
}
function playSound(kind: GameEvent['sound']): void {
if (!soundEnabled || !audio || audio.state !== 'running') return;
const now = audio.currentTime;
if (kind === 'shot' && now - lastShotSound < .13) return;
if (kind === 'shot') lastShotSound = now;
const osc = audio.createOscillator(), gain = audio.createGain();
const frequencies = { shot: 160, pickup: 620, hit: 95, kill: 230, win: 780 };
const duration = kind === 'shot' ? .045 : kind === 'win' ? .5 : .15;
osc.type = kind === 'shot' || kind === 'hit' ? 'triangle' : 'sine';
osc.frequency.setValueAtTime(frequencies[kind], now);
osc.frequency.exponentialRampToValueAtTime(kind === 'pickup' || kind === 'win' ? frequencies[kind] * 1.5 : 50, now + duration);
gain.gain.setValueAtTime(kind === 'shot' ? .018 : .075, now); gain.gain.exponentialRampToValueAtTime(.001, now + duration);
osc.connect(gain); gain.connect(audio.destination); osc.start(); osc.stop(now + duration);
osc.onended = () => { osc.disconnect(); gain.disconnect(); };
}
function applyMotion(): void { game.reducedMotion = reducedPreference.matches || configReducedMotion; }
const closeSettings = connectSettings(settings => {
soundEnabled = settings.soundEnabled; configReducedMotion = settings.reducedMotion; updateSoundButton(); applyMotion();
});
reducedPreference.addEventListener('change', applyMotion, listenerOptions); applyMotion();
function renderPicker(): void {
el('stagePicker').replaceChildren(...STAGES.map((stage, i) => {
const button = document.createElement('button'); button.type = 'button';
button.textContent = String(i + 1).padStart(2, '0');
button.disabled = i > progress.unlocked;
button.title = `${stage.name}${progress.best[i] ? ` · Best ${progress.best[i]}` : ''}`;
button.setAttribute('aria-label', `Stage ${i + 1}: ${stage.name}${i > progress.unlocked ? ' (locked)' : ''}`);
button.setAttribute('aria-pressed', String(game.stage === i));
button.addEventListener('click', () => prepare(i), listenerOptions); return button;
}));
}
function resetInput(): void {
heldKeys.clear(); heldPointers.clear(); targetX = undefined; dragId = undefined;
el('leftButton').dataset.held = 'false'; el('rightButton').dataset.held = 'false';
}
function prepare(stage: number): void {
resetInput(); game.prepare(stage); finishedRun = false; renderedPhase = ''; el('toast').dataset.visible = 'false'; toastUntil = 0;
renderPicker(); renderHud(); renderOverlay();
}
function begin(): void {
if (innerWidth < 240 || innerHeight < 280) return;
resetInput(); activateAudio(); game.start(); accumulator = 0; previousTime = performance.now();
canvas.focus({ preventScroll: true }); renderOverlay();
}
function pause(): void { resetInput(); game.pause(); renderOverlay(); }
function renderOverlay(): void {
if (renderedPhase === game.phase) return;
renderedPhase = game.phase; app.dataset.phase = game.phase;
const active = game.phase === 'playing'; el('overlay').hidden = active;
pauseButton.disabled = !active && game.phase !== 'paused'; pauseButton.textContent = game.phase === 'paused' ? '▷' : 'Ⅱ';
pauseButton.setAttribute('aria-label', game.phase === 'paused' ? 'Resume game' : 'Pause game');
el<HTMLButtonElement>('leftButton').disabled = !active; el<HTMLButtonElement>('rightButton').disabled = !active;
if (active) return;
el('legend').hidden = game.phase !== 'ready';
el('stagePicker').hidden = game.phase === 'paused';
el('resultStats').hidden = game.phase === 'ready' || game.phase === 'paused';
secondary.hidden = game.phase !== 'paused';
el('panelLabel').textContent = `STAGE ${String(game.stage + 1).padStart(2, '0')} · ${game.definition.length} METERS`;
const content = game.phase === 'ready' ? ['Build your squad.', game.stage === 0 ? 'Collect soldiers. Hit the multipliers. Keep moving and take down the warden.' : game.definition.subtitle, 'Start run']
: game.phase === 'paused' ? ['Take a breather.', 'Your squad is right where you left it.', 'Resume run']
: game.phase === 'lost' ? ['Squad down.', 'Steer around striped barriers. Grow your squad before facing the warden.', 'Try again']
: game.stage === STAGES.length - 1 ? ['All lines cleared.', `${STAGES.length} wardens down. Take another run and beat your record.`, 'Play again']
: ['Warden down.', `${STAGES[game.stage + 1].name} is unlocked. A fresh squad awaits.`, 'Next stage'];
el('panelTitle').textContent = content[0]; el('panelText').textContent = content[1];
primary.replaceChildren(document.createTextNode(content[2])); const arrow = document.createElement('span'); arrow.textContent = '↗'; arrow.setAttribute('aria-hidden', 'true'); primary.append(arrow);
if (game.phase === 'won' || game.phase === 'lost') {
el('resultStats').replaceChildren(...[['SCORE', game.score], ['BEST SQUAD', game.peakSquad], ['ELIMINATED', game.kills]].map(([label, value]) => {
const item = document.createElement('span'), strong = document.createElement('strong'); item.textContent = String(label); strong.textContent = String(value); item.append(strong); return item;
}));
if (!finishedRun) {
finishedRun = true; resetInput(); progress = recordRun(progress, game.stage, game.score, game.phase === 'won'); persist(); renderPicker();
}
}
}
function renderHud(): void {
el('stageNumber').textContent = `${String(game.stage + 1).padStart(2, '0')} / ${String(STAGES.length).padStart(2, '0')}`;
el('stageName').textContent = game.definition.name;
el('squad').textContent = String(game.squad).padStart(2, '0'); el('damage').textContent = `×${game.damage}`; el('score').textContent = String(game.score).padStart(4, '0');
el('progress').style.width = `${game.progress * 100}%`;
el('routeLabel').textContent = game.progress >= 1 ? 'TAKE DOWN THE WARDEN' : `${Math.ceil(game.definition.length - game.distance)} m TO WARDEN`;
el('powerStatus').textContent = [game.rapid > 0 ? `RAPID ${Math.ceil(game.rapid)}s` : '', game.shield ? '◇ SHIELD' : ''].filter(Boolean).join(' / ');
const boss = game.boss; el('bossHud').hidden = !boss || game.progress < 1;
if (boss) el('bossHealth').style.width = `${Math.max(0, boss.hp / boss.maxHp * 100)}%`;
canvas.setAttribute('aria-label', `Rail track. Squad ${game.squad}. Position ${game.x < -.8 ? 'left' : game.x > .8 ? 'right' : 'center'}. Use A/D, arrows or drag to move. Shooting is automatic.`);
}
primary.addEventListener('click', () => {
if (game.phase === 'won') { prepare(game.stage < STAGES.length - 1 ? game.stage + 1 : 0); }
else if (game.phase === 'lost') prepare(game.stage);
begin();
}, listenerOptions);
secondary.addEventListener('click', () => { prepare(game.stage); begin(); }, listenerOptions);
pauseButton.addEventListener('click', () => game.phase === 'paused' ? begin() : pause(), listenerOptions);
soundButton.addEventListener('click', () => { soundEnabled = !soundEnabled; updateSoundButton(); activateAudio(); if (soundEnabled) playSound('pickup'); }, listenerOptions);
// Keyboard input stays inside this applet; the host retains its own shortcuts.
app.addEventListener('keydown', event => {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const key = event.key.toLowerCase();
if (['arrowleft', 'arrowright', 'a', 'd'].includes(key) && game.phase === 'playing') { event.preventDefault(); heldKeys.add(key); targetX = undefined; }
if (['p', 'escape'].includes(key) && !event.repeat) { event.preventDefault(); if (game.phase === 'playing') pause(); else if (game.phase === 'paused') begin(); }
if (key === ' ' && event.target === canvas && !event.repeat) { event.preventDefault(); if (game.phase === 'playing') pause(); else if (game.phase === 'paused' || game.phase === 'ready') begin(); }
}, listenerOptions);
app.addEventListener('keyup', event => { heldKeys.delete(event.key.toLowerCase()); }, listenerOptions);
for (const [id, direction] of [['leftButton', -1], ['rightButton', 1]] as const) {
const button = el<HTMLButtonElement>(id);
button.addEventListener('pointerdown', event => {
if (game.phase !== 'playing') return;
event.preventDefault(); button.setPointerCapture(event.pointerId); heldPointers.set(event.pointerId, direction); targetX = undefined;
button.dataset.held = 'true'; canvas.focus({ preventScroll: true }); activateAudio();
}, listenerOptions);
const release = (event: PointerEvent) => { heldPointers.delete(event.pointerId); button.dataset.held = String([...heldPointers.values()].includes(direction)); };
button.addEventListener('pointerup', release, listenerOptions); button.addEventListener('pointercancel', release, listenerOptions); button.addEventListener('lostpointercapture', release, listenerOptions);
}
canvas.addEventListener('pointerdown', event => {
if (game.phase !== 'playing' || dragId !== undefined) return;
event.preventDefault(); canvas.focus({ preventScroll: true }); canvas.setPointerCapture(event.pointerId);
dragId = event.pointerId; dragOrigin = renderer.worldX(event.clientX); dragSquadOrigin = game.x; targetX = game.x; activateAudio();
}, listenerOptions);
canvas.addEventListener('pointermove', event => {
if (event.pointerId === dragId) targetX = dragSquadOrigin + renderer.worldX(event.clientX) - dragOrigin;
}, listenerOptions);
const releaseDrag = (event: PointerEvent) => { if (event.pointerId === dragId) { dragId = undefined; targetX = undefined; } };
canvas.addEventListener('pointerup', releaseDrag, listenerOptions); canvas.addEventListener('pointercancel', releaseDrag, listenerOptions); canvas.addEventListener('lostpointercapture', releaseDrag, listenerOptions);
window.addEventListener('blur', pause, listenerOptions);
document.addEventListener('visibilitychange', () => { if (document.hidden) pause(); }, listenerOptions);
const resize = new ResizeObserver(() => { renderer.resize(); resetInput(); if (innerWidth < 240 || innerHeight < 280) pause(); }); resize.observe(app);
function frame(now: number): void {
const delta = previousTime ? Math.min((now - previousTime) / 1000, .1) : 0; previousTime = now;
if (!document.hidden && renderer.width > 0 && renderer.height > 0) {
if (game.phase === 'playing') {
accumulator += delta;
while (accumulator >= 1 / 60) {
const left = heldKeys.has('a') || heldKeys.has('arrowleft') || [...heldPointers.values()].includes(-1);
const right = heldKeys.has('d') || heldKeys.has('arrowright') || [...heldPointers.values()].includes(1);
game.update(1 / 60, Number(right) - Number(left), targetX); accumulator -= 1 / 60;
}
} else accumulator = 0;
for (const event of game.events.splice(0)) {
playSound(event.sound);
if (event.text) { el('toast').textContent = event.text; el('toast').dataset.tone = event.tone; el('toast').dataset.visible = 'true'; toastUntil = now + 1700; }
}
if (toastUntil && now > toastUntil) el('toast').dataset.visible = 'false';
renderer.draw(game, game.elapsed);
if (now - lastHud > 80) { renderHud(); lastHud = now; }
renderOverlay();
}
frameId = requestAnimationFrame(frame);
}
window.addEventListener('pagehide', () => {
disposed = true; game.pause(); resetInput(); cancelAnimationFrame(frameId); resize.disconnect(); lifetime.abort();
themeSubscription?.close(); closeSettings(); void audio?.close().catch(() => {});
}, { once: true });
renderer.resize(); renderPicker(); renderHud(); renderOverlay(); updateSoundButton(); frameId = requestAnimationFrame(frame);
