Back to Rail Game
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.

src/main.ts
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);