Back to Sickman Survival
SOURCE / PINNED RELEASE

Made of little things.

Sickman Survival

Release
7335c9fdf0a8…
Author-recorded commit
cbd2eeafa70d…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-8804151df7f

Archive hash verified: 591bab4addcbe5fc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/main.ts
import { STORAGE_DOMAIN, storage } from '@napplet/sdk';
import { Sound } from './audio.ts';
import { runtimeHasDomain } from './domain-availability.js';
import { Game, NO_CONTROLS, SPECTATE_DELAY, type Controls } from './game.ts';
import { Input } from './input.ts';
import { gameSettings, onSettingsChange } from './napplet-settings.js';
import { PLAYER_COLORS } from './palette.ts';
import { Renderer, type Pane } from './render.ts';
import { layoutPanes, Roster, type Rect, type RosterChange } from './roster.ts';
import './styles.css';

// Colors are app-owned ("dusk before impact"); the optional host theme is not
// applied, so the scene and HUD keep their look in every shell.

type Mode = 'title' | 'playing' | 'paused' | 'dead';

const STEP = 1 / 120;
const DEATH_OVERLAY_DELAY = 1300;
const BEST_KEY = 'best-seconds';
const MUTED_KEY = 'sound-muted';

const el = {
  app: requireElement<HTMLElement>('#app'),
  canvas: requireElement<HTMLCanvasElement>('#scene'),
  clock: requireElement<HTMLOutputElement>('#clock'),
  best: requireElement<HTMLElement>('#best'),
  bestLabel: requireElement<HTMLElement>('#bestLabel'),
  pause: requireElement<HTMLButtonElement>('#pauseButton'),
  sound: requireElement<HTMLButtonElement>('#soundButton'),
  overlay: requireElement<HTMLElement>('#overlay'),
  kicker: requireElement<HTMLElement>('#overlayKicker'),
  headline: requireElement<HTMLElement>('#overlayHeadline'),
  detail: requireElement<HTMLElement>('#overlayDetail'),
  go: requireElement<HTMLButtonElement>('#overlayButton'),
  touchPad: requireElement<HTMLElement>('#touchPad'),
  panes: requireElement<HTMLElement>('#panes'),
};

const renderer = new Renderer(el.canvas);
const input = new Input();
const sound = new Sound();
const roster = new Roster();
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const coarsePointer = window.matchMedia('(pointer: coarse)');

let mode: Mode = 'title';
let game = createGame();
let deathAt = 0;
let deathShown = false;
let best = 0;
let canPersistBest = runtimeHasDomain(STORAGE_DOMAIN);
let saveFailed = false;
let touchSeen = false;
let lastClockText = '';
let frameWidth = 1;
let frameHeight = 1;
let paneKey = '';

function requireElement<T extends HTMLElement>(selector: string): T {
  const element = document.querySelector<T>(selector);
  if (!element) throw new Error(`Missing required element: ${selector}`);
  return element;
}

/** A fresh world with everyone on the roster in it. */
function createGame(): Game {
  const next = new Game((Math.random() * 2 ** 32) >>> 0, roster.players.map((p) => p.slot));
  next.listeners = {
    death: (slot) => {
      sound.death();
      rumble(slot, 1, 600);
    },
    runOver: () => {
      mode = 'dead';
      deathAt = performance.now();
      deathShown = false;
      el.app.dataset.mode = mode;
    },
    explosion: (blast) => sound.explosion(blast),
    blastNear: (slot, proximity, blast) => rumble(slot, proximity ** 1.5, 160 + blast.radius * 6),
    jump: () => sound.jump(),
    land: (impact) => sound.land(impact),
    bounce: () => sound.bounce(),
    footstep: () => sound.footstep(),
    meteor: (m) => sound.meteor(m),
    moleSurface: (m) => sound.moleSurface(m),
    moleDive: (m) => sound.moleDive(m),
    moleArmed: (m) => sound.moleArmed(m),
  };
  return next;
}

export function formatTime(seconds: number): string {
  if (seconds < 60) return `${seconds.toFixed(1)}s`;
  const m = Math.floor(seconds / 60);
  const s = seconds - m * 60;
  return `${m}:${s.toFixed(1).padStart(4, '0')}`;
}

// Modes and overlay ────────────────────────────────────────────────────────

function setMode(next: Mode): void {
  mode = next;
  el.app.dataset.mode = next;
  el.pause.setAttribute('aria-label', next === 'paused' ? 'Resume' : 'Pause');
  input.reset();
  sound.setPaused(next === 'paused');
}

function rumble(slot: number, strength: number, ms: number): void {
  if (gameSettings.rumble) input.rumble(roster.sourceOf(slot) ?? 'solo', strength, ms);
}

function showOverlay(kicker: string, headline: string, detail: string, action: string): void {
  el.kicker.textContent = kicker;
  el.headline.textContent = headline;
  el.detail.textContent = detail;
  el.go.textContent = action;
  el.overlay.hidden = false;
  el.app.dataset.overlay = 'on';
  el.go.focus({ preventScroll: true });
}

function hideOverlay(): void {
  el.overlay.hidden = true;
  el.app.dataset.overlay = 'off';
  el.canvas.focus({ preventScroll: true });
}

function start(): void {
  if (mode === 'dead') {
    game = createGame();
    renderer.bind(game);
  }
  game.meteorsEnabled = true;
  setMode('playing');
  hideOverlay();
  sound.click();
}

function pause(): void {
  if (mode !== 'playing') return;
  sound.click();
  setMode('paused');
  showOverlay('Paused', formatTime(game.survived), 'The storm waits for you.', 'Resume');
}

function resume(): void {
  if (mode !== 'paused') return;
  setMode('playing');
  hideOverlay();
  sound.click();
}

/** Start from the title or game over, or toggle pause: what Start and Enter mean right now. */
function primaryAction(): void {
  if (mode === 'playing') pause();
  else if (mode === 'paused') resume();
  else if (mode === 'title' || deathShown) start();
}

function showDeath(): void {
  deathShown = true;
  const time = game.survived;
  const isBest = time > best;
  if (isBest) {
    best = time;
    void saveBest(time);
  }
  renderBest();
  const lines = game.players.length > 1
    ? ["Everyone's down", 'Squad wiped', 'Nobody made it']
    : game.deathCause === 'mole'
      ? ['Mole-bombed', 'Dug up', 'Ambushed below', 'Too close']
      : ['Flattened', 'Vaporised', 'Sent flying', 'Cratered', 'Gone skyward'];
  const kicker = lines[Math.floor(Math.random() * lines.length)];
  showOverlay(kicker, formatTime(time), deathDetail(isBest), 'Again');
}

function deathDetail(isBest: boolean): string {
  const note = saveFailed
    ? ' Best time could not be saved.'
    : canPersistBest
      ? ''
      : ' Best time lasts for this session only.';
  return (isBest ? 'New best!' : `Best ${formatTime(best)}.`) + note;
}

el.go.addEventListener('click', () => {
  if (mode === 'paused') resume();
  else start();
});

el.pause.addEventListener('click', () => {
  if (mode === 'playing') pause();
  else if (mode === 'paused') resume();
});

window.addEventListener('keydown', (event) => {
  if (event.code === 'KeyM') {
    toggleMute();
    return;
  }
  if (event.code === 'Escape' || event.code === 'KeyP') {
    if (mode === 'playing') pause();
    else if (mode === 'paused') resume();
    return;
  }
  if (event.code === 'Enter' || event.code === 'KeyR') {
    if (event.target === el.go) return; // the button's own click handles Enter
    if (mode === 'title' || (mode === 'dead' && deathShown)) start();
    else if (mode === 'paused' && event.code === 'Enter') resume();
  }
});

document.addEventListener('visibilitychange', () => {
  if (document.hidden) pause();
});

// Sound ────────────────────────────────────────────────────────────────────

// Browsers only allow audio after a user gesture; any key, tap or click unlocks it.
for (const type of ['keydown', 'pointerdown'] as const) {
  window.addEventListener(type, () => sound.unlock(), { capture: true });
}
input.onGamepadPress = () => sound.unlock();

el.sound.addEventListener('click', () => toggleMute());

function toggleMute(): void {
  setMuted(!sound.isMuted);
  if (canPersistBest) void storage.setItem(MUTED_KEY, sound.isMuted ? '1' : '0').catch(() => {});
}

function setMuted(muted: boolean): void {
  sound.setMuted(muted);
  el.sound.setAttribute('aria-pressed', String(muted));
  el.sound.setAttribute('aria-label', muted ? 'Unmute sound' : 'Mute sound');
  el.app.dataset.sound = muted ? 'off' : 'on';
}

async function loadMuted(): Promise<void> {
  if (!canPersistBest) return;
  try {
    if ((await storage.getItem(MUTED_KEY)) === '1') setMuted(true);
  } catch {
    // Unmuted by default.
  }
}

// Best time (optional storage) ─────────────────────────────────────────────

function renderBest(): void {
  el.best.textContent = best > 0 ? formatTime(best) : '–';
  el.bestLabel.textContent = canPersistBest && !saveFailed ? 'Best' : 'Session best';
}

async function loadBest(): Promise<void> {
  if (!canPersistBest) return;
  try {
    const stored = await storage.getItem(BEST_KEY);
    const value = stored ? Number.parseFloat(stored) : 0;
    if (Number.isFinite(value) && value > best) best = value;
  } catch {
    canPersistBest = false;
  }
  renderBest();
}

async function saveBest(seconds: number): Promise<void> {
  if (!canPersistBest) return;
  try {
    await storage.setItem(BEST_KEY, seconds.toFixed(2));
    saveFailed = false;
  } catch {
    saveFailed = true;
    if (mode === 'dead' && deathShown) el.detail.textContent = deathDetail(true);
  }
  renderBest();
}

// Settings, touch pad and sizing ───────────────────────────────────────────

function applySettings(): void {
  const comfort = reducedMotion.matches ? 0.25 : 1;
  renderer.options = {
    shake: gameSettings.screenShake ? comfort : 0,
    flashes: !reducedMotion.matches,
  };
  sound.setVolume(gameSettings.soundVolume);
  const mode = gameSettings.touchControls;
  const showTouch = mode === 'always' || (mode === 'auto' && (coarsePointer.matches || touchSeen));
  el.app.dataset.touch = showTouch ? 'on' : 'off';
}

onSettingsChange(applySettings);
reducedMotion.addEventListener('change', applySettings);
coarsePointer.addEventListener('change', applySettings);
input.onTouchSeen = () => {
  if (touchSeen) return;
  touchSeen = true;
  applySettings();
};

function resize(): void {
  const rect = el.app.getBoundingClientRect();
  frameWidth = Math.max(1, rect.width);
  frameHeight = Math.max(1, rect.height);
  renderer.resize(rect.width, rect.height, Math.min(2, window.devicePixelRatio || 1));
}

// Local players ────────────────────────────────────────────────────────────

/** Plugged-in pads join at once and unplugged ones leave; nobody joins a run that is already over. */
function applyRoster(change: RosterChange): void {
  for (const slot of change.removed) game.removePlayer(slot);
  if (mode !== 'dead') for (const slot of change.added) game.addPlayer(slot);
  el.app.dataset.split = roster.mode === 'split' ? 'on' : 'off';
}

function currentPanes(): { panes: Pane[]; spare: Rect | null } {
  const count = roster.players.length;
  const rects = layoutPanes(count, Math.round(frameWidth), Math.round(frameHeight));
  return {
    panes: roster.players.map((p, i) => ({ slot: p.slot, rect: rects[i] })),
    spare: rects.length > count ? rects[count] : null,
  };
}

/** Per-pane "P2" chips, and a join hint in a spare grid cell; rebuilt only when something changes. */
function renderPaneLabels(panes: readonly Pane[], spare: Rect | null): void {
  const split = panes.length > 1;
  const states = panes.map((pane) => {
    const p = game.player(pane.slot);
    if (!p || p.alive || !game.alive) return '';
    const watching = game.viewTarget(pane.slot);
    return game.clock - p.diedAt >= SPECTATE_DELAY && watching && watching !== p ? `watching P${watching.slot + 1}` : 'down';
  });
  const key = JSON.stringify([split, panes, spare, states]);
  if (key === paneKey) return;
  paneKey = key;
  el.panes.replaceChildren();
  if (!split) return;
  panes.forEach((pane, i) => {
    const label = document.createElement('div');
    label.className = 'pane-label';
    label.style.cssText = `left:${pane.rect.x}px;top:${pane.rect.y}px;width:${pane.rect.width}px;height:${pane.rect.height}px`;
    label.style.setProperty('--player', PLAYER_COLORS[pane.slot % PLAYER_COLORS.length]);
    const chip = document.createElement('span');
    chip.className = 'pane-chip';
    chip.textContent = `P${pane.slot + 1}`;
    label.append(chip);
    if (states[i]) {
      const state = document.createElement('span');
      state.className = 'pane-state';
      state.textContent = states[i];
      label.append(state);
    }
    el.panes.append(label);
  });
  if (spare) {
    const join = document.createElement('div');
    join.className = 'pane-spare';
    join.style.cssText = `left:${spare.x}px;top:${spare.y}px;width:${spare.width}px;height:${spare.height}px`;
    join.textContent = 'Plug in a controller to join';
    el.panes.append(join);
  }
}

new ResizeObserver(resize).observe(el.app);

// Loop ─────────────────────────────────────────────────────────────────────

let last = performance.now();
let accumulator = 0;

function frame(now: number): void {
  const dt = Math.min(0.1, (now - last) / 1000);
  last = now;
  const pads = input.poll();
  const padState = input.padConnected ? 'on' : 'off';
  if (el.app.dataset.pad !== padState) el.app.dataset.pad = padState;
  const change = roster.sync(pads.players.map((c) => c.index), pads.status, pads.disconnected);
  if (change.added.length || change.removed.length) applyRoster(change);
  if (input.takeSelect()) toggleMute();
  if (input.takeStart()) primaryAction();

  if (mode === 'paused') {
    if (input.takeAnyJump()) resume();
  } else {
    accumulator += dt;
    if (accumulator >= STEP) {
      if ((mode === 'title' || (mode === 'dead' && deathShown)) && input.takeAnyJump()) start();
      // Read once per frame so a tap is never lost between fixed steps.
      const controls = new Map<number, Controls>();
      for (const p of roster.players) {
        controls.set(p.slot, mode === 'playing' ? input.controlsFor(p.source) : NO_CONTROLS);
      }
      // Drop presses nobody owns, such as the keyboard during split screen.
      input.takeAnyJump();
      let first = true;
      const lookup = (slot: number): Controls => {
        const c = controls.get(slot) ?? NO_CONTROLS;
        return first || !c.jumpPressed ? c : { ...c, jumpPressed: false };
      };
      while (accumulator >= STEP) {
        game.update(STEP, lookup);
        first = false;
        accumulator -= STEP;
      }
    }
  }

  const { panes, spare } = currentPanes();
  renderer.render(game, mode === 'paused' ? 0 : dt, panes, spare);
  renderPaneLabels(panes, spare);
  sound.update(game, mode === 'paused' ? 0 : dt);

  const clockText = formatTime(game.survived);
  if (clockText !== lastClockText) {
    lastClockText = clockText;
    el.clock.value = clockText;
  }
  if (mode === 'dead' && !deathShown && now - deathAt > DEATH_OVERLAY_DELAY) showDeath();
  requestAnimationFrame(frame);
}

input.attachKeyboard(window);
input.attachTouchPad(el.touchPad);
el.canvas.tabIndex = -1;
resize();
renderer.bind(game);
applySettings();
renderBest();
void loadBest();
void loadMuted();
requestAnimationFrame(frame);