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.
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);
