Back to Stick Runner
SOURCE / PINNED RELEASE

Made of little things.

Stick Runner

Release
50f4e4cd86f6…
Author-recorded commit
2960bafd6cd9…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-938fb0aa7b7

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

src/main.ts
import { CONFIG_DOMAIN } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { startSettings } from './napplet-settings.js';
import { Game, VIEW_H, VIEW_W } from './game/game.js';
import './styles.css';

// Stick Runner keeps its own sunset colours; it never follows the host theme.

const app = requireElement<HTMLElement>('#app');
const stage = requireElement<HTMLDivElement>('#stage');
const screen = requireElement<HTMLDivElement>('#screen');
const canvas = requireElement<HTMLCanvasElement>('#game');
const menu = requireElement<HTMLDivElement>('#menu');
const menuResult = requireElement<HTMLDivElement>('#menuResult');
const menuButton = requireElement<HTMLButtonElement>('#menuButton');
const playButton = requireElement<HTMLButtonElement>('#playButton');
const restartButton = requireElement<HTMLButtonElement>('#restartButton');
const touch = requireElement<HTMLDivElement>('#touch');
const tooSmall = requireElement<HTMLDivElement>('#tooSmall');

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;
}

const game = new Game(canvas);

// ---------- touch buttons ----------

const touchScreen = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches;
let alwaysShowTouch = false;

function updateTouchMode(): void {
  const show = touchScreen || alwaysShowTouch;
  touch.hidden = !show;
  app.classList.toggle('touch-mode', show);
  fitScreen();
}

function holdButton(selector: string, control: 'left' | 'right' | 'jump'): void {
  const button = requireElement<HTMLButtonElement>(selector);
  const release = () => {
    game.controls[control] = false;
    button.classList.remove('held');
  };
  button.addEventListener('pointerdown', (event) => {
    event.preventDefault();
    button.setPointerCapture(event.pointerId);
    game.controls[control] = true;
    button.classList.add('held');
  });
  button.addEventListener('pointerup', release);
  button.addEventListener('pointercancel', release);
  button.addEventListener('lostpointercapture', release);
  button.addEventListener('contextmenu', (event) => event.preventDefault());
}

holdButton('#touchLeft', 'left');
holdButton('#touchRight', 'right');
holdButton('#touchJump', 'jump');

// ---------- fitting the game to the frame ----------

/** Keep the game's wide shape and make it as big as the frame allows. */
function fitScreen(): void {
  const width = stage.clientWidth;
  const height = stage.clientHeight - (touch.hidden ? 0 : touch.offsetHeight + 8);
  const scale = Math.max(0.1, Math.min(width / VIEW_W, height / VIEW_H));
  const cssW = Math.floor(VIEW_W * scale);
  const cssH = Math.floor(VIEW_H * scale);
  screen.style.width = `${cssW}px`;
  screen.style.height = `${cssH}px`;
  game.resize(cssW, cssH);
  tooSmall.hidden = stage.clientWidth >= 240 && stage.clientHeight >= 170;
}

new ResizeObserver(fitScreen).observe(stage);

// ---------- the menu ----------

type MenuMode = 'start' | 'pause' | 'won';
let menuMode: MenuMode = 'start';

function openMenu(mode: MenuMode, result = ''): void {
  menuMode = mode;
  game.paused = true;
  releaseAll();
  menu.hidden = false;
  menuButton.hidden = true;
  menuResult.hidden = result === '';
  menuResult.textContent = result;
  playButton.textContent = mode === 'start' ? 'Play' : mode === 'pause' ? 'Keep playing' : 'Play again';
  restartButton.hidden = mode !== 'pause';
  playButton.focus();
}

function closeMenu(): void {
  menu.hidden = true;
  menuButton.hidden = false;
  game.paused = false;
  canvas.focus();
}

playButton.addEventListener('click', () => {
  if (menuMode === 'won') game.restartGame();
  closeMenu();
});
restartButton.addEventListener('click', () => {
  game.restartGame();
  closeMenu();
});
menuButton.addEventListener('click', () => openMenu('pause'));

game.onWin = (seconds, falls) => {
  const fallText = falls === 0 ? 'without falling once!' : `and fell in the pit ${falls} ${falls === 1 ? 'time' : 'times'}.`;
  // Let the stickman cheer for a moment before the menu pops up.
  setTimeout(() => openMenu('won', `🏆 Rog beat Level 2 in ${seconds.toFixed(1)} seconds ${fallText}`), 900);
};

// ---------- keyboard ----------

const KEYS: Record<string, 'left' | 'right' | 'jump'> = {
  ArrowLeft: 'left',
  KeyA: 'left',
  ArrowRight: 'right',
  KeyD: 'right',
  Space: 'jump',
  ArrowUp: 'jump',
  KeyW: 'jump',
};

function releaseAll(): void {
  game.controls.left = false;
  game.controls.right = false;
  game.controls.jump = false;
}

window.addEventListener('keydown', (event) => {
  if (event.code === 'Escape' || event.code === 'KeyM') {
    event.preventDefault();
    if (menu.hidden) openMenu('pause');
    else if (menuMode !== 'won') closeMenu();
    return;
  }
  // While the menu is open, keys work the buttons instead of the stickman.
  if (!menu.hidden) return;
  const control = KEYS[event.code];
  if (!control) return;
  event.preventDefault();
  game.controls[control] = true;
});
window.addEventListener('keyup', (event) => {
  const control = KEYS[event.code];
  if (control) game.controls[control] = false;
});
window.addEventListener('blur', releaseAll);
document.addEventListener('visibilitychange', () => {
  if (document.hidden && menu.hidden) openMenu('pause');
});
canvas.addEventListener('pointerdown', () => canvas.focus());

// Optional setting: show the touch buttons even with a keyboard. Without the config domain it stays off.
if (runtimeHasDomain(CONFIG_DOMAIN)) {
  startSettings((values) => {
    alwaysShowTouch = values.touchButtons === true;
    updateTouchMode();
  });
}

updateTouchMode();
openMenu('start');
game.start();