Back to blockfall
SOURCE / PINNED RELEASE

Made of little things.

blockfall

Release
a6bb863994d9…
Author-recorded commit
e6ab5899ddac…
License
LICENSE
Author’s source reference
nostr://npub1dmmh0futyszl4445mv4xrygkgsgj8vpu79yx3qyy9qxmz4pemtzqlqmc87/wss%3A%2F%2Fgit.napplet.soy%2F/n-afafae55ef1

Archive hash verified: ba8de5cbee33e11e…. 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 { runtimeHasDomain } from './domain-availability.js';
import { Game, type GameBanner, type GameStats } from './game.js';
import { onGameSettings } from './napplet-settings.js';
import './styles.css';

const BEST_KEY = 'blockfall.best-score';
const ROCKET_PIPS = 8;

const elements = {
  canvas: requireElement<HTMLCanvasElement>('#scene'),
  score: requireElement<HTMLElement>('#score'),
  level: requireElement<HTMLElement>('#level'),
  best: requireElement<HTMLElement>('#best'),
  fuel: requireElement<HTMLElement>('#fuelFill'),
  ammo: requireElement<HTMLElement>('#ammo'),
  ammoCount: requireElement<HTMLElement>('#ammoCount'),
  hint: requireElement<HTMLElement>('#hint'),
  thrustButton: requireElement<HTMLButtonElement>('#thrustBtn'),
  thrustLabel: requireElement<HTMLElement>('#thrustLabel'),
  leftButton: requireElement<HTMLButtonElement>('#leftBtn'),
  rightButton: requireElement<HTMLButtonElement>('#rightBtn'),
  restartButton: requireElement<HTMLButtonElement>('#restartBtn'),
  banner: requireElement<HTMLElement>('#banner'),
  bannerTitle: requireElement<HTMLElement>('#bannerTitle'),
  bannerSub: requireElement<HTMLElement>('#bannerSub'),
};

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 pad(value: number, digits: number): string {
  return String(Math.max(0, Math.floor(value))).padStart(digits, '0');
}

const hints: Record<GameStats['phase'], string> = {
  ready: 'Aim the rail · launch when locked',
  flying: 'Aim for supports · collapse the towers',
  reloading: 'Loading next rocket',
  clear: 'Sector cleared · standby',
  over: 'Range closed · reopen to retry',
};

const pips: HTMLElement[] = [];
for (let index = 0; index < ROCKET_PIPS; index += 1) {
  const pip = document.createElement('span');
  pip.className = 'pip';
  elements.ammo.append(pip);
  pips.push(pip);
}

let best = 0;
let savedBest = 0;
let lastPhase: GameStats['phase'] | null = null;
let keyLeft = false;
let keyRight = false;
let padLeft = false;
let padRight = false;
let keyThrust = false;
let padThrust = false;
let bannerTimer = 0;

const hasStorage = runtimeHasDomain(STORAGE_DOMAIN);

function renderBest(): void {
  elements.best.textContent = pad(best, 6);
}

function renderAmmo(rockets: number): void {
  for (const [index, pip] of pips.entries()) {
    pip.classList.toggle('spent', index >= rockets);
  }
  elements.ammoCount.textContent = `×${rockets}`;
  elements.ammo.setAttribute('aria-label', `${rockets} rockets remaining`);
}

function renderStats(stats: GameStats): void {
  elements.score.textContent = pad(stats.score, 6);
  elements.level.textContent = pad(stats.level, 2);
  elements.fuel.style.width = `${stats.fuel}%`;
  elements.fuel.dataset.low = stats.fuel < 25 ? 'true' : 'false';
  elements.hint.textContent = hints[stats.phase];
  elements.thrustLabel.textContent = stats.phase === 'flying' ? 'Thrust' : 'Launch';
  renderAmmo(stats.rockets);

  const interactive = stats.phase === 'ready' || stats.phase === 'flying';
  elements.leftButton.disabled = !interactive;
  elements.rightButton.disabled = !interactive;
  elements.thrustButton.disabled = !interactive;
  elements.restartButton.hidden = stats.phase !== 'over';

  if (stats.score > best) {
    best = stats.score;
    renderBest();
  }

  if (stats.phase !== lastPhase) {
    if (stats.phase === 'over' || stats.phase === 'clear') persistBest();
    lastPhase = stats.phase;
  }
}

function showBanner(banner: GameBanner): void {
  elements.banner.hidden = false;
  elements.banner.dataset.tone = banner.tone;
  elements.bannerTitle.textContent = banner.title;
  elements.bannerSub.textContent = banner.sub;
  elements.banner.classList.remove('is-in');
  void elements.banner.offsetWidth;
  elements.banner.classList.add('is-in');
  window.clearTimeout(bannerTimer);
  if (banner.tone !== 'lose') {
    bannerTimer = window.setTimeout(() => {
      elements.banner.hidden = true;
    }, 2500);
  }
}

async function loadBest(): Promise<void> {
  if (!hasStorage) return;
  try {
    const raw = await storage.getItem(BEST_KEY);
    const value = Number(raw);
    if (Number.isFinite(value) && value > 0) {
      best = Math.floor(value);
      savedBest = best;
      renderBest();
    }
  } catch {
    // Storage is optional; an unavailable best score must not block play.
  }
}

function persistBest(): void {
  if (!hasStorage || best <= savedBest) return;
  savedBest = best;
  try {
    void storage.setItem(BEST_KEY, String(best)).catch(() => undefined);
  } catch {
    // Optional persistence only.
  }
}

const game = new Game(elements.canvas, {
  onStats: renderStats,
  onBanner: showBanner,
});

onGameSettings((settings) => {
  game.setOptions({ effects: settings.effects, guide: settings.trajectoryGuide });
});

function syncSteer(): void {
  game.setSteer(keyLeft || padLeft, keyRight || padRight);
}

function syncThrust(): void {
  if (keyThrust || padThrust) game.pressPrimary();
  else game.releasePrimary();
}

function clearInputs(): void {
  keyLeft = false;
  keyRight = false;
  keyThrust = false;
  padLeft = false;
  padRight = false;
  padThrust = false;
  syncSteer();
  syncThrust();
}

window.addEventListener('keydown', (event) => {
  const target = event.target;
  if (
    target instanceof HTMLElement &&
    (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')
  ) {
    return;
  }

  switch (event.code) {
    case 'ArrowLeft':
    case 'KeyA':
      event.preventDefault();
      keyLeft = true;
      syncSteer();
      break;
    case 'ArrowRight':
    case 'KeyD':
      event.preventDefault();
      keyRight = true;
      syncSteer();
      break;
    case 'ArrowUp':
    case 'KeyW':
    case 'Space':
      event.preventDefault();
      if (event.repeat) break;
      if (lastPhase === 'over') {
        game.restart();
      } else {
        keyThrust = true;
        syncThrust();
      }
      break;
    case 'KeyR':
      if (lastPhase === 'over') game.restart();
      break;
    default:
      break;
  }
});

window.addEventListener('keyup', (event) => {
  switch (event.code) {
    case 'ArrowLeft':
    case 'KeyA':
      keyLeft = false;
      syncSteer();
      break;
    case 'ArrowRight':
    case 'KeyD':
      keyRight = false;
      syncSteer();
      break;
    case 'ArrowUp':
    case 'KeyW':
    case 'Space':
      keyThrust = false;
      syncThrust();
      break;
    default:
      break;
  }
});

window.addEventListener('blur', clearInputs);
elements.canvas.addEventListener('contextmenu', (event) => event.preventDefault());

function bindHold(
  button: HTMLButtonElement,
  onPress: () => void,
  onRelease: () => void,
): void {
  button.addEventListener('pointerdown', (event) => {
    event.preventDefault();
    button.classList.add('is-held');
    try {
      button.setPointerCapture(event.pointerId);
    } catch {
      // Pointer capture is best effort.
    }
    onPress();
  });
  const release = (event: PointerEvent): void => {
    button.classList.remove('is-held');
    if (button.hasPointerCapture(event.pointerId)) {
      button.releasePointerCapture(event.pointerId);
    }
    onRelease();
  };
  button.addEventListener('pointerup', release);
  button.addEventListener('pointercancel', release);
  button.addEventListener('lostpointercapture', () => {
    button.classList.remove('is-held');
    onRelease();
  });
}

bindHold(
  elements.leftButton,
  () => {
    padLeft = true;
    syncSteer();
  },
  () => {
    padLeft = false;
    syncSteer();
  },
);

bindHold(
  elements.rightButton,
  () => {
    padRight = true;
    syncSteer();
  },
  () => {
    padRight = false;
    syncSteer();
  },
);

bindHold(
  elements.thrustButton,
  () => {
    padThrust = true;
    syncThrust();
  },
  () => {
    padThrust = false;
    syncThrust();
  },
);

elements.restartButton.addEventListener('click', () => {
  clearInputs();
  game.restart();
});

function releasePadInputs(): void {
  if (!padLeft && !padRight && !padThrust) return;
  padLeft = false;
  padRight = false;
  padThrust = false;
  syncSteer();
  syncThrust();
}

window.addEventListener('pointerup', releasePadInputs);
window.addEventListener('pointercancel', releasePadInputs);

renderBest();
renderAmmo(ROCKET_PIPS);
void loadBest();

window.addEventListener(
  'pagehide',
  () => {
    persistBest();
    game.dispose();
  },
  { once: true },
);