Back to Stick Runner
SOURCE / PINNED RELEASE

Made of little things.

Stick Runner

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

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

src/main.ts
import { CONFIG_DOMAIN, GAMEPAD_DOMAIN } from '@napplet/sdk';
import { gamepad } from '@napplet/sdk/gamepad';
import { runtimeHasDomain } from './domain-availability.js';
import { createGamepadInput } from './gamepad.js';
import { startSettings } from './napplet-settings.js';
import { Game, VIEW_H, VIEW_W, type Difficulty } from './game/game.js';
import { Renderer2D } from './game/render2d.js';
import { drawStairsScene } from './game/stairs-scene.js';
import { drawBossHud, drawBossScene } from './game/boss-scene.js';
import { createWorldView } from './game/render3d.js';
import { START_VIEW, VIEW_NAMES, VIEW_ORDER, type ViewMode } from './game/view-mode.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 difficultyBox = requireElement<HTMLDivElement>('#difficulty');
const difficultyHelp = requireElement<HTMLDivElement>('#difficultyHelp');
const difficultyButtons = [...difficultyBox.querySelectorAll<HTMLButtonElement>('button[data-difficulty]')];
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 canvas3d = requireElement<HTMLCanvasElement>('#game3d');
const hud = requireElement<HTMLCanvasElement>('#hud');
const no3d = requireElement<HTMLDivElement>('#no3d');
const eyes = requireElement<HTMLElement>('#eyes');
const viewButton = requireElement<HTMLButtonElement>('#viewButton');
const touchLeft = requireElement<HTMLButtonElement>('#touchLeft');
const touchRight = requireElement<HTMLButtonElement>('#touchRight');

// Two canvases: one for the flat 2D view, one for the 3D views (a canvas can only do one kind).
const flat = new Renderer2D(canvas);
const world3d = createWorldView(canvas3d);
const game = new Game(hud, flat);
game.stairsScene = drawStairsScene;
game.bossScene = drawBossScene;
game.bossHud = drawBossHud;
let view: ViewMode = '2d';

function setView(next: ViewMode, announce: boolean): void {
  // If this browser can't do 3D, stay in 2D and say why.
  if (next !== '2d' && !world3d) {
    no3d.hidden = false;
    setTimeout(() => (no3d.hidden = true), 2000);
    next = '2d';
  }
  view = next;
  peeking = false;
  showPicture(view);
  // No rolling in first person, so the view never flips over.
  game.rollsEnabled = view !== 'first';
  // The keys mean different things in each view, so let go of everything when switching.
  releaseAll();
  // On touch screens the run buttons become forward ▲ and back ▼ in the 3D views.
  touchLeft.textContent = view === '2d' ? '◀' : '▼';
  touchRight.textContent = view === '2d' ? '▶' : '▲';
  touchLeft.setAttribute('aria-label', view === '2d' ? 'Run left' : 'Run back');
  touchRight.setAttribute('aria-label', view === '2d' ? 'Run right' : 'Run forward');
  eyes.hidden = view !== 'first';
  if (announce) game.showNotice(`View: ${VIEW_NAMES[view]}  (M to switch)`);
}

/**
 * In first person you can't see the dragon behind you, so while a fireball is flying the picture
 * switches to 2D, and back to first person once it hits something. Only the picture changes:
 * the keys keep working the first-person way the whole time.
 */
let peeking = false;

function showPicture(mode: ViewMode): void {
  canvas.hidden = mode !== '2d';
  canvas3d.hidden = mode === '2d';
  if (mode === '2d') game.setWorld(flat);
  else if (world3d) {
    world3d.setMode(mode);
    game.setWorld(world3d);
  }
}

function watchFireballs(): void {
  const flying = game.fireballFlying;
  if (!peeking && view === 'first' && flying) {
    peeking = true;
    showPicture('2d');
  } else if (peeking && (!flying || view !== 'first')) {
    peeking = false;
    // If you switched view yourself in the meantime, that view stays.
    if (view === 'first') showPicture('first');
  }
  requestAnimationFrame(watchFireballs);
}
requestAnimationFrame(watchFireballs);

function nextView(): void {
  setView(VIEW_ORDER[(VIEW_ORDER.indexOf(view) + 1) % VIEW_ORDER.length], true);
}

viewButton.addEventListener('click', () => {
  nextView();
  canvas.focus();
});

// ---------- 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' | 'punch'): 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');
holdButton('#touchPunch', 'punch');

// The PUNCH button only shows up in the boss fight.
const touchPunch = requireElement<HTMLButtonElement>('#touchPunch');
function updatePunchButton(): void {
  touchPunch.hidden = !game.inBoss;
  requestAnimationFrame(updatePunchButton);
}
requestAnimationFrame(updatePunchButton);

// ---------- 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;
  viewButton.hidden = true;
  menuResult.hidden = result === '';
  menuResult.textContent = result;
  playButton.textContent = mode === 'start' ? 'Play' : mode === 'pause' ? 'Keep playing' : 'Play again';
  restartButton.hidden = mode !== 'pause';
  // Difficulty is picked before a game starts (and before playing again), not halfway through.
  difficultyBox.hidden = mode === 'pause';
  playButton.focus();
}

const DIFFICULTY_HELP: Record<Difficulty, string> = {
  easy: 'Lose, and Rog starts again at the last building he was on.',
  normal: 'Lose, and Rog starts the level again.',
  hard: 'Lose, and Rog goes back one level!',
};

function setDifficulty(difficulty: Difficulty): void {
  game.difficulty = difficulty;
  for (const button of difficultyButtons) {
    button.setAttribute('aria-checked', String(button.dataset.difficulty === difficulty));
  }
  difficultyHelp.textContent = DIFFICULTY_HELP[difficulty];
}

for (const button of difficultyButtons) {
  button.addEventListener('click', () => setDifficulty(button.dataset.difficulty as Difficulty));
}
setDifficulty('normal');

function closeMenu(): void {
  menu.hidden = true;
  menuButton.hidden = false;
  viewButton.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 the giant spider and won the game! The last level took ${seconds.toFixed(1)} seconds ${fallText}`), 900);
};

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

type Control = 'left' | 'right' | 'jump' | 'punch';

// 2D: ← → run, ↑ or Space jumps.
const KEYS_2D: Record<string, Control> = {
  ArrowLeft: 'left',
  KeyA: 'left',
  ArrowRight: 'right',
  KeyD: 'right',
  Space: 'jump',
  ArrowUp: 'jump',
  KeyW: 'jump',
  ShiftLeft: 'punch',
  ShiftRight: 'punch',
};

// First and third person: ↑ runs forward (towards the finish), ↓ runs back, Space jumps.
const KEYS_3D: Record<string, Control> = {
  ArrowUp: 'right',
  KeyW: 'right',
  ArrowDown: 'left',
  KeyS: 'left',
  ArrowRight: 'right',
  KeyD: 'right',
  ArrowLeft: 'left',
  KeyA: 'left',
  Space: 'jump',
  ShiftLeft: 'punch',
  ShiftRight: 'punch',
};

/** In the boss fight: which way is towards the spider (1 = right, -1 = left)? */
function towardsSpider(): number {
  const fight = game.boss;
  if (!fight) return 1;
  return Math.sign(fight.spider.x - fight.rog.x) || fight.rog.facing;
}

function controlFor(code: string): Control | undefined {
  if (game.inBoss) {
    // In the boss fight Space punches and 1 jumps.
    if (code === 'Space') return 'punch';
    if (code === 'Digit1' || code === 'Numpad1') return 'jump';
    if (view !== '2d') {
      // First and third person: ↑ goes towards the spider, ↓ away (decided when the key is pressed).
      if (code === 'ArrowUp' || code === 'KeyW') return towardsSpider() > 0 ? 'right' : 'left';
      if (code === 'ArrowDown' || code === 'KeyS') return towardsSpider() > 0 ? 'left' : 'right';
    }
    // Side-on: ← → move, ↑ or W jumps.
    return KEYS_2D[code];
  }
  return (view === '2d' ? KEYS_2D : KEYS_3D)[code];
}

/** Which keys are held down right now, and what each one is doing. */
const keysDown = new Map<string, Control>();

function releaseAll(): void {
  keysDown.clear();
  game.controls.left = false;
  game.controls.right = false;
  game.controls.jump = false;
  game.controls.punch = false;
  // A held controller button counts again on the next frame.
  padHeld.left = false;
  padHeld.right = false;
  padHeld.jump = false;
  padHeld.punch = false;
}

window.addEventListener('keydown', (event) => {
  if (event.code === 'KeyM') {
    // M switches between 2D, first person and third person.
    event.preventDefault();
    if (!event.repeat) nextView();
    return;
  }
  if (event.code === 'Escape') {
    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;
  // A held key keeps doing what it started doing (so ↑ in the boss fight doesn't flip as you run under the spider).
  if (keysDown.has(event.code)) {
    event.preventDefault();
    return;
  }
  const control = controlFor(event.code);
  if (!control) return;
  event.preventDefault();
  keysDown.set(event.code, control);
  game.controls[control] = true;
});
window.addEventListener('keyup', (event) => {
  // Let go of exactly what this key was doing when it was pressed (a key can mean different
  // things in different views or in the boss fight), unless another held key is doing it too.
  const control = keysDown.get(event.code);
  keysDown.delete(event.code);
  if (control && ![...keysDown.values()].includes(control)) game.controls[control] = false;
});
window.addEventListener('blur', releaseAll);
document.addEventListener('visibilitychange', () => {
  if (document.hidden && menu.hidden) openMenu('pause');
});
canvas.addEventListener('pointerdown', () => canvas.focus());
canvas3d.addEventListener('pointerdown', () => canvas.focus());

// ---------- game controller ----------

// Controllers come through the host (NAP-GAMEPAD). Hosts without it just leave keyboard and touch.
const hasGamepad = runtimeHasDomain(GAMEPAD_DOMAIN);
// Stick or D-pad runs, A (bottom button) jumps, Y (top button) or Back switches view, Start opens the menu.
// In 2D, D-pad up jumps too; in first and third person, up runs forward and down runs back.
const pad = createGamepadInput({
  moveX: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
  moveY: [{ axis: 1 }, { button: 12, scale: -1 }, { button: 13 }],
  padUp: [{ button: 12 }],
  jump: [{ button: 0 }],
  // X (left button) or B (right button) punches in the boss fight.
  punch: [{ button: 2 }, { button: 1 }],
  view: [{ button: 3 }, { button: 8 }],
  pause: [{ button: 9 }],
}, {
  read: () => gamepad.getGamepads(),
  // The host only sends live values to the napplet the player is using.
  active: () => gamepad.available && gamepad.focused && !document.hidden,
});
// What the controller was holding last frame, so it only changes the stickman when the controller changes.
const padHeld: Record<Control, boolean> = { left: false, right: false, jump: false, punch: false };
/** In the boss fight in 3D: which way the stick's up/down is moving Rog (decided when it's pushed). */
let padForward = 0;
// After A presses a menu button, wait until it's let go before it can jump.
let padJumpLocked = false;

function setFromPad(control: Control, down: boolean): void {
  if (padHeld[control] === down) return;
  padHeld[control] = down;
  game.controls[control] = down;
}

function menuButtons(): HTMLButtonElement[] {
  const difficulty = difficultyBox.hidden ? [] : difficultyButtons;
  return [...difficulty, playButton, restartButton].filter((button) => !button.hidden);
}

function pollPad(): void {
  const frame = pad.poll();
  if (frame.connected.length > 0) game.showNotice('Controller connected!');
  if (frame.disconnected.length > 0) {
    (Object.keys(padHeld) as Control[]).forEach((control) => setFromPad(control, false));
    if (frame.players.length === 0 && menu.hidden) openMenu('pause');
  }
  let left = false;
  let right = false;
  let jump = false;
  let punch = false;
  for (const controller of frame.players) {
    const { moveX, moveY, padUp, jump: jumpButton, view: viewButtonPad, pause, punch: punchButton } = controller.actions;
    if (pause.pressed) {
      if (menu.hidden) openMenu('pause');
      else if (menuMode !== 'won') closeMenu();
      continue;
    }
    if (!menu.hidden) {
      // In the menu: the stick or D-pad picks a button, A presses it.
      const buttons = menuButtons();
      const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
      // Up/down or left/right moves between the buttons (left/right is handy for the difficulty row).
      const move = moveY.pressed ? moveY.value : moveX.pressed ? moveX.value : 0;
      if (move !== 0 && buttons.length > 0) {
        const step = move > 0 ? 1 : -1;
        buttons[(Math.max(at, 0) + step + buttons.length) % buttons.length].focus();
      }
      if (jumpButton.pressed) {
        padJumpLocked = true;
        (buttons[at] ?? playButton).click();
      }
      continue;
    }
    if (viewButtonPad.pressed) nextView();
    punch ||= punchButton.down;
    if (game.inBoss && view !== '2d') {
      // Boss fight in first or third person: stick up goes towards the spider, down goes away.
      if (Math.abs(moveY.value) >= 0.5 && padForward === 0) padForward = towardsSpider() * (moveY.value < 0 ? 1 : -1);
      if (Math.abs(moveY.value) < 0.5) padForward = 0;
      left ||= moveX.value <= -0.5 || padForward < 0;
      right ||= moveX.value >= 0.5 || padForward > 0;
      jump ||= jumpButton.down;
    } else if (view === '2d' || game.inBoss) {
      left ||= moveX.value <= -0.5;
      right ||= moveX.value >= 0.5;
      jump ||= jumpButton.down || padUp.down;
    } else {
      left ||= moveX.value <= -0.5 || moveY.value >= 0.5;
      right ||= moveX.value >= 0.5 || moveY.value <= -0.5;
      jump ||= jumpButton.down;
    }
  }
  setFromPad('left', menu.hidden && left);
  setFromPad('right', menu.hidden && right);
  if (!jump) padJumpLocked = false;
  setFromPad('jump', menu.hidden && jump && !padJumpLocked);
  setFromPad('punch', menu.hidden && punch);
  requestAnimationFrame(pollPad);
}

// 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();
  });
}

setView(START_VIEW, false);
updateTouchMode();
openMenu('start');
game.start();
if (hasGamepad) requestAnimationFrame(pollPad);