Back to super-platformer
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.

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