Back to Chase'n Those Narly Waves
SOURCE / PINNED RELEASE

Made of little things.

Chase'n Those Narly Waves

Release
0c7dde9d4921…
Author-recorded commit
88e58516be17…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-836af5a08b9

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

src/main.ts
// Starts the game: sets up the screen, menus, controls and saving your best score.

import { KEYS_DOMAIN, STORAGE_DOMAIN, keys, storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { createGame, popup, update, type Game } from './game.js';
import { Input, attachKeyboard, attachTouchButtons } from './input.js';
import { drawHud } from './render/hud.js';
import { World } from './render/scene.js';
import { BASIC_TRICKS, TRICKS, comboText } from './tricks.js';
import './styles.css';

const BEST_KEY = 'best-score';

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

const canvas = $<HTMLCanvasElement>('#scene');
const hudCanvas = $<HTMLCanvasElement>('#hud');
const hud = hudCanvas.getContext('2d')!;
const world = createWorld();

/** The 3D world needs WebGL. If this device can't do it, say so instead of crashing. */
function createWorld(): World | null {
  try {
    return new World(canvas);
  } catch {
    $('#noWebgl').hidden = false;
    return null;
  }
}
const screens = {
  title: $<HTMLElement>('#titleScreen'),
  pause: $<HTMLElement>('#pauseScreen'),
  over: $<HTMLElement>('#overScreen'),
};
const touch = $<HTMLElement>('#touch');
const pauseButton = $<HTMLButtonElement>('#pauseButton');
const isTouch = window.matchMedia('(pointer: coarse)').matches;

const input = new Input();
let game: Game = createGame(0);

// ---------- Screens ----------

function showScreen(): void {
  screens.title.hidden = game.phase !== 'title';
  screens.pause.hidden = game.phase !== 'paused';
  screens.over.hidden = game.phase !== 'over';
  touch.hidden = !(isTouch && game.phase === 'playing');
  pauseButton.hidden = game.phase !== 'playing';

  const focusTarget: Record<Game['phase'], HTMLElement> = {
    title: $('#startButton'),
    paused: $('#resumeButton'),
    over: $('#againButton'),
    playing: canvas,
  };
  focusTarget[game.phase].focus({ preventScroll: true });
}

function startGame(): void {
  game = createGame(game.best);
  game.phase = 'playing';
  input.clear();
  popup(game, 'SURF\'S UP, BRO!', 'Tap W when a wave hits the green zone', '#2de1c2');
  showScreen();
}

function togglePause(): void {
  if (game.phase === 'playing') game.phase = 'paused';
  else if (game.phase === 'paused') game.phase = 'playing';
  else return;
  input.clear();
  showScreen();
}

function restart(): void {
  if (game.phase === 'title') return;
  startGame();
}

function gameOver(): void {
  $('#finalScore').textContent = `${game.score} points`;
  const isNewBest = game.score > 0 && game.score >= game.best && game.score > savedBest;
  $('#finalBest').textContent = isNewBest ? 'NEW BEST SCORE! Totally rad!' : `Best: ${game.best}`;
  if (isNewBest) saveBest(game.score);
  showScreen();
}

$('#startButton').addEventListener('click', startGame);
$('#againButton').addEventListener('click', startGame);
$('#resumeButton').addEventListener('click', togglePause);
$('#restartButton').addEventListener('click', startGame);
pauseButton.addEventListener('click', togglePause);

$('#secretTricks').textContent =
  `...plus ${TRICKS.length - BASIC_TRICKS.length} secret tricks with 3 and 4 keys. Can you find them all?`;

$('#trickList').replaceChildren(
  ...BASIC_TRICKS.map((trick) => {
    const item = document.createElement('li');
    const name = document.createElement('span');
    name.textContent = trick.name;
    const combo = document.createElement('span');
    combo.className = 'combo';
    combo.textContent = comboText(trick.combo);
    item.append(name, combo);
    return item;
  }),
);

// ---------- Controls ----------

attachKeyboard(input, () => game.phase === 'playing');
attachTouchButtons(touch, input);

// Pause and restart are "actions" the host app (shell) can show and let
// players re-map (NAP-KEYS). If the host doesn't support that, we listen ourselves.
let shellHandlesKeys = false;
if (runtimeHasDomain(KEYS_DOMAIN)) {
  Promise.all([
    keys.register({ id: 'game.pause', label: 'Pause / resume', defaultKey: 'P' }, togglePause),
    keys.register({ id: 'game.restart', label: 'Start over', defaultKey: 'R' }, restart),
  ])
    .then((bindings) => {
      shellHandlesKeys = true;
      window.addEventListener('pagehide', () => bindings.forEach((b) => b.close()), { once: true });
    })
    .catch(() => undefined);
}

window.addEventListener('keydown', (event) => {
  if (event.code === 'Escape') togglePause();
  if (shellHandlesKeys) return;
  if (event.code === 'KeyP') togglePause();
  if (event.code === 'KeyR' && game.phase !== 'playing') restart();
});

// Pause automatically if you click away or switch tabs.
window.addEventListener('blur', () => {
  if (game.phase === 'playing') togglePause();
});
document.addEventListener('visibilitychange', () => {
  if (document.hidden && game.phase === 'playing') togglePause();
});

// ---------- Best score (saved through the host, if it allows it) ----------

let savedBest = 0;

function saveBest(score: number): void {
  savedBest = score;
  if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
  storage.setItem(BEST_KEY, String(score)).catch(() => undefined);
}

function showTitleBest(): void {
  $('#titleBest').textContent = game.best > 0 ? `Your best: ${game.best}` : '';
}

if (runtimeHasDomain(STORAGE_DOMAIN)) {
  storage
    .getItem(BEST_KEY)
    .then((value) => {
      const best = Number(value);
      if (Number.isFinite(best) && best > game.best) {
        game.best = best;
        savedBest = best;
        showTitleBest();
      }
    })
    .catch(() => undefined);
}

// ---------- Screen size ----------

let width = 0;
let height = 0;

function resize(): void {
  const rect = canvas.getBoundingClientRect();
  const dpr = Math.min(2, window.devicePixelRatio || 1);
  width = Math.max(1, rect.width);
  height = Math.max(1, rect.height);
  world?.resize(width, height);
  hudCanvas.width = Math.round(width * dpr);
  hudCanvas.height = Math.round(height * dpr);
  hud.setTransform(dpr, 0, 0, dpr, 0, 0);
}

new ResizeObserver(resize).observe(canvas);
resize();

// ---------- Game loop ----------

let last = performance.now();

function frame(now: number): void {
  const dt = Math.min(0.05, (now - last) / 1000);
  last = now;

  const wasPlaying = game.phase === 'playing';
  if (game.phase === 'title') game.scroll += 6 * dt; // gentle ocean on the title screen
  update(game, input, dt);
  if (wasPlaying && game.phase === 'over') gameOver();

  const bottomSpace = isTouch && game.phase === 'playing' ? touch.offsetHeight : 0;
  world?.render(game);
  drawHud(hud, game, {
    width,
    height,
    bottomSpace,
    sharkWarnings: world?.sharkWarnings(game) ?? [],
  });
  requestAnimationFrame(frame);
}

showTitleBest();
showScreen();
requestAnimationFrame(frame);