SOURCE / PINNED RELEASE
Made of little things.
super-platformer
- Release
- b61593abc2c6…
- Author-recorded commit
- bbd35ca510f6…
- License
- LICENSE
- Author’s source reference
- nostr://npub16uawymqtmdg4j6zcl6snt5apzfp4ek2g8lpasnqa75tspdpglcqsgpatvc/wss%3A%2F%2Fgit.napplet.soy%2F/n-01288b1ab38
Archive hash verified: d2529eb9ad1961f2…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import './napplet-settings.js';
import { onSettings } from './napplet-settings.js';
import { sfx } from './audio.js';
import {
STORAGE_DOMAIN,
THEME_DOMAIN,
storage,
themeGet,
themeOnChanged,
} from '@napplet/sdk';
import type { Subscription, Theme } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import {
LEVELS,
Platformer,
type CompletionInfo,
type GameStatus,
type HudState,
type InputAction,
} from './game.js';
import './styles.css';
import { ControllerInput, InputSources, type ControllerState } from './controller.js';
type BestTimes = {
levels: Array<number | null>;
total: number | null;
};
const STORAGE_KEY = 'super-platformer/best-v1';
const elements = {
stage: requireElement<HTMLDivElement>('#stage'),
canvas: requireElement<HTMLCanvasElement>('#game'),
overlay: requireElement<HTMLDivElement>('#overlay'),
overlayTitle: requireElement<HTMLParagraphElement>('#overlayTitle'),
overlayText: requireElement<HTMLParagraphElement>('#overlayText'),
overlayHint: requireElement<HTMLParagraphElement>('#overlayHint'),
primaryButton: requireElement<HTMLButtonElement>('#primaryButton'),
secondaryButton: requireElement<HTMLButtonElement>('#secondaryButton'),
hudLevel: requireElement<HTMLSpanElement>('#hudLevel'),
hudCoins: requireElement<HTMLSpanElement>('#hudCoins'),
hudTime: requireElement<HTMLSpanElement>('#hudTime'),
hudBest: requireElement<HTMLSpanElement>('#hudBest'),
hudHearts: requireElement<HTMLSpanElement>('#hudHearts'),
hudPower: requireElement<HTMLSpanElement>('#hudPower'),
hudPowerWrap: requireElement<HTMLSpanElement>('#hudPowerWrap'),
soundButton: requireElement<HTMLButtonElement>('#soundButton'),
pauseButton: requireElement<HTMLButtonElement>('#pauseButton'),
restartButton: requireElement<HTMLButtonElement>('#restartButton'),
touchButtons: Array.from(
document.querySelectorAll<HTMLButtonElement>('#touchControls button[data-action]'),
),
};
const POWER_LABELS: Record<string, string> = {
magnet: 'Magnet',
fire: 'Fire',
shield: 'Shield',
doubleJump: 'Double jump',
speed: 'Speed',
};
const best: BestTimes = {
levels: LEVELS.map(() => null),
total: null,
};
let lastHud: HudState | null = null;
let currentStatus: GameStatus = 'ready';
let themeSubscription: Subscription | null = null;
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;
}
function formatTime(seconds: number): string {
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
const rest = seconds - minutes * 60;
return `${minutes}:${rest.toFixed(1).padStart(4, '0')}`;
}
return seconds.toFixed(1);
}
// ── Best times (optional storage) ─────────────────────────────────────
async function loadBest(): Promise<void> {
if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
try {
const raw = await storage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as Partial<BestTimes>;
if (Array.isArray(parsed.levels)) {
parsed.levels.forEach((value, index) => {
if (index < best.levels.length && typeof value === 'number' && Number.isFinite(value)) {
best.levels[index] = value;
}
});
}
if (typeof parsed.total === 'number' && Number.isFinite(parsed.total)) {
best.total = parsed.total;
}
refreshBestLabel();
} catch {
// Malformed or unavailable storage is a normal optional-domain state.
}
}
async function saveBest(): Promise<void> {
if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
try {
await storage.setItem(STORAGE_KEY, JSON.stringify(best));
} catch {
// Keep in-memory best when persistence is unavailable.
}
}
function refreshBestLabel(index = lastHud?.levelIndex ?? 0): void {
const value = best.levels[index];
elements.hudBest.textContent = value == null ? '--' : formatTime(value);
}
// ── HUD and overlay ───────────────────────────────────────────────────
function renderHud(state: HudState): void {
lastHud = state;
currentStatus = state.status;
elements.hudLevel.textContent = `${state.levelIndex + 1}/${state.levelCount}`;
elements.hudCoins.textContent = `${state.coins}/${state.coinTotal}`;
elements.hudTime.textContent = formatTime(state.time);
refreshBestLabel(state.levelIndex);
document.body.dataset.status = state.status;
const filled = '♥'.repeat(Math.max(0, state.hearts));
const empty = '♡'.repeat(Math.max(0, 3 - state.hearts));
elements.hudHearts.textContent = filled + empty;
elements.hudHearts.classList.toggle('low', state.hearts <= 1);
if (state.power) {
elements.hudPowerWrap.hidden = false;
elements.hudPower.textContent = `${POWER_LABELS[state.power] ?? state.power} ${Math.ceil(state.powerTime)}`;
} else {
elements.hudPowerWrap.hidden = true;
}
const paused = state.status === 'paused';
elements.pauseButton.textContent = paused ? '▶' : '❚❚';
elements.pauseButton.setAttribute('aria-label', paused ? 'Resume' : 'Pause');
elements.pauseButton.title = paused ? 'Resume (P)' : 'Pause (P)';
elements.pauseButton.disabled = state.status !== 'playing' && !paused;
elements.restartButton.disabled = state.status === 'ready';
}
function renderOverlay(status: GameStatus, info: CompletionInfo): void {
const overlay = elements.overlay;
switch (status) {
case 'playing':
overlay.hidden = true;
return;
case 'ready':
overlay.hidden = false;
elements.overlayTitle.textContent = 'Ready';
elements.overlayText.textContent = `${LEVELS.length} short levels. Grab the coins, reach the flag.`;
elements.primaryButton.textContent = 'Play';
elements.secondaryButton.hidden = true;
elements.overlayHint.hidden = false;
break;
case 'paused':
overlay.hidden = false;
elements.overlayTitle.textContent = 'Paused';
elements.overlayText.textContent = `${info.levelName} · ${formatTime(info.levelTime)} · ${info.deaths} falls`;
elements.primaryButton.textContent = 'Resume';
elements.secondaryButton.hidden = false;
elements.secondaryButton.textContent = 'Restart level';
elements.overlayHint.hidden = false;
break;
case 'levelComplete': {
overlay.hidden = false;
const record = best.levels[info.levelIndex];
const isRecord = record != null && Math.abs(record - info.levelTime) < 0.001;
elements.overlayTitle.textContent = 'Level complete';
elements.overlayText.textContent = `${info.levelName} · ${formatTime(info.levelTime)}${isRecord ? ' · new best!' : ''}`;
elements.primaryButton.textContent = 'Next level';
elements.secondaryButton.hidden = false;
elements.secondaryButton.textContent = 'Replay';
elements.overlayHint.hidden = true;
break;
}
case 'allComplete': {
overlay.hidden = false;
const isRecord = best.total != null && Math.abs(best.total - info.totalTime) < 0.001;
elements.overlayTitle.textContent = 'All levels complete';
elements.overlayText.textContent = `Total ${formatTime(info.totalTime)}${isRecord ? ' · new best!' : ''}`;
elements.primaryButton.textContent = 'Play again';
elements.secondaryButton.hidden = true;
elements.overlayHint.hidden = true;
break;
}
case 'gameOver': {
overlay.hidden = false;
elements.overlayTitle.textContent = 'Game over';
elements.overlayText.textContent = `Out of hearts on ${info.levelName}.`;
elements.primaryButton.textContent = 'Try again';
elements.secondaryButton.hidden = true;
elements.overlayHint.hidden = true;
break;
}
default:
break;
}
}
function handleStatus(status: GameStatus, info: CompletionInfo): void {
currentStatus = status;
if (status === 'levelComplete' || status === 'allComplete') {
const previous = best.levels[info.levelIndex];
if (previous == null || info.levelTime < previous) {
best.levels[info.levelIndex] = info.levelTime;
void saveBest();
}
if (status === 'allComplete' && (best.total == null || info.totalTime < best.total)) {
best.total = info.totalTime;
void saveBest();
}
}
renderOverlay(status, info);
}
// ── Game instance ─────────────────────────────────────────────────────
const game = new Platformer(elements.canvas, {
onHud: renderHud,
onStatusChange: (status, info) => handleStatus(status, info),
});
const initialInfo: CompletionInfo = {
levelIndex: 0,
levelCount: LEVELS.length,
levelName: LEVELS[0].name,
levelTime: 0,
totalTime: 0,
isLast: false,
deaths: 0,
};
renderOverlay('ready', initialInfo);
// ── Theme (optional) ──────────────────────────────────────────────────
function applyTheme(theme: Theme): void {
const root = document.documentElement.style;
root.setProperty('--bg', theme.colors.background);
root.setProperty('--fg', theme.colors.text);
root.setProperty('--primary', theme.colors.primary);
game.refreshPalette();
}
function subscribeToTheme(): void {
if (!runtimeHasDomain(THEME_DOMAIN)) return;
try {
themeGet().then(applyTheme).catch(() => undefined);
themeSubscription = themeOnChanged(applyTheme);
} catch {
// Theme is optional; the stylesheet fallback palette stays in place.
}
}
// ── Input ─────────────────────────────────────────────────────────────
const input = new InputSources(game);
const KEY_MAP: Record<string, InputAction> = {
ArrowLeft: 'left',
KeyA: 'left',
ArrowRight: 'right',
KeyD: 'right',
ArrowUp: 'jump',
KeyW: 'jump',
Space: 'jump',
KeyX: 'fire',
KeyJ: 'fire',
};
function focusCanvas(): void {
elements.canvas.focus({ preventScroll: true });
}
window.addEventListener('keydown', (event) => {
if (event.code === 'KeyP' || event.code === 'Escape') {
if (!event.repeat) game.togglePause();
event.preventDefault();
return;
}
if (event.code === 'KeyR') {
if (!event.repeat) game.restartLevel();
event.preventDefault();
return;
}
if (!elements.overlay.hidden && event.code === 'Enter') {
event.preventDefault();
game.primary();
focusCanvas();
return;
}
if (!elements.overlay.hidden && event.code === 'Space' && currentStatus !== 'paused') {
event.preventDefault();
game.primary();
focusCanvas();
return;
}
const action = KEY_MAP[event.code];
if (!action) return;
event.preventDefault();
if (event.repeat) return;
input.set(`key:${event.code}`, [action]);
});
window.addEventListener('keyup', (event) => {
const action = KEY_MAP[event.code];
if (!action) return;
event.preventDefault();
input.set(`key:${event.code}`, []);
});
const controller = new ControllerInput(input, {
playing: () => currentStatus === 'playing',
primary: () => game.primary(),
pause: () => game.togglePause(),
restart: () => game.restartLevel(),
});
const keyboardHint = '← → move · Space jump · X fire · P pause';
const controllerHints: Record<ControllerState, string> = {
waiting: 'Controller: press a button to connect.',
connected: 'Controller: stick / D-pad move · A/× jump / confirm · X/□ fire · Start pause · Y/△ retry in menus',
unsupported: 'Controller mapping unsupported. Use keyboard or touch.',
unavailable: 'Controller unavailable in this browser or host. Use keyboard or touch.',
};
let controllerState: ControllerState | null = null;
let controllerFrame = 0;
let controllerUnavailable = typeof navigator.getGamepads !== 'function';
function pollController(): void {
let state: ControllerState = controllerUnavailable ? 'unavailable' : 'waiting';
if (!controllerUnavailable && !document.hidden && document.hasFocus()) {
try {
state = controller.update(navigator.getGamepads());
} catch {
controller.reset();
controllerUnavailable = true;
state = 'unavailable';
}
}
if (state !== controllerState) {
controllerState = state;
elements.overlayHint.textContent = `${keyboardHint} · ${controllerHints[state]}`;
}
controllerFrame = requestAnimationFrame(pollController);
}
function clearInput(): void {
input.clear();
controller.reset();
game.releaseAll();
for (const button of elements.touchButtons) button.classList.remove('active');
}
window.addEventListener('blur', clearInput);
document.addEventListener('visibilitychange', () => {
if (document.hidden) clearInput();
});
pollController();
for (const button of elements.touchButtons) {
const action = button.dataset.action as InputAction | undefined;
if (!action) continue;
const down = (event: PointerEvent): void => {
event.preventDefault();
button.setPointerCapture(event.pointerId);
button.classList.add('active');
input.set(`touch:${event.pointerId}`, [action]);
};
const up = (event: PointerEvent): void => {
if (button.hasPointerCapture(event.pointerId)) {
button.releasePointerCapture(event.pointerId);
}
button.classList.remove('active');
input.set(`touch:${event.pointerId}`, []);
};
button.addEventListener('pointerdown', down);
button.addEventListener('pointerup', up);
button.addEventListener('pointercancel', up);
button.addEventListener('lostpointercapture', up);
}
elements.stage.addEventListener('pointerdown', () => {
sfx.resume();
focusCanvas();
});
elements.primaryButton.addEventListener('click', () => {
sfx.resume();
game.primary();
focusCanvas();
});
elements.secondaryButton.addEventListener('click', () => {
game.restartLevel();
focusCanvas();
});
elements.pauseButton.addEventListener('click', () => {
game.togglePause();
focusCanvas();
});
elements.restartButton.addEventListener('click', () => {
game.restartLevel();
focusCanvas();
});
function refreshSoundButton(): void {
const on = sfx.enabled;
elements.soundButton.textContent = on ? '♪' : '♪̸';
elements.soundButton.setAttribute('aria-label', on ? 'Mute sound' : 'Unmute sound');
elements.soundButton.title = on ? 'Sound on' : 'Sound off';
}
elements.soundButton.addEventListener('click', () => {
sfx.setEnabled(!sfx.enabled);
refreshSoundButton();
focusCanvas();
});
onSettings((settings) => {
sfx.setEnabled(settings.sound);
refreshSoundButton();
});
// ── Lifecycle ─────────────────────────────────────────────────────────
const resizeObserver = new ResizeObserver(() => game.resize());
resizeObserver.observe(elements.stage);
window.addEventListener('resize', () => game.resize());
window.addEventListener(
'pagehide',
() => {
themeSubscription?.close();
resizeObserver.disconnect();
cancelAnimationFrame(controllerFrame);
clearInput();
game.destroy();
},
{ once: true },
);
void loadBest();
subscribeToTheme();
game.resize();
