Back to Dine and Dash
SOURCE / PINNED RELEASE

Made of little things.

Dine and Dash

Release
32e37bfe002f…
Author-recorded commit
dce273752e95…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-2f845a2d471

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

src/main.ts
import { CONFIG_DOMAIN, GAMEPAD_DOMAIN, KEYS_DOMAIN, keys } 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, HUD_H, VIEW_H, VIEW_W, type Dir, type MoveInput, type Role } from './game.js';
import { Renderer3D } from './render3d.js';
import './styles.css';

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 stage = requireElement<HTMLDivElement>('#stage');
const screen = requireElement<HTMLDivElement>('#screen');
const canvas = requireElement<HTMLCanvasElement>('#game');
const worldCanvas = requireElement<HTMLCanvasElement>('#world');
const touch = requireElement<HTMLDivElement>('#touch');
const menu = requireElement<HTMLDivElement>('#menu');
const menuResult = requireElement<HTMLDivElement>('#menuResult');
const menuButton = requireElement<HTMLButtonElement>('#menuButton');
const fullButton = requireElement<HTMLButtonElement>('#fullButton');
const resumeButton = requireElement<HTMLButtonElement>('#resumeButton');
const dasherButton = requireElement<HTMLButtonElement>('#dasherButton');
const waiterButton = requireElement<HTMLButtonElement>('#waiterButton');

const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas 2D is not available');
const ctx = context;
const game = new Game(ctx);
const world = new Renderer3D(worldCanvas);

// ---------- screen size ----------

let scale = 1;

function layout(): void {
  const style = getComputedStyle(stage);
  const padX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
  const padY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
  const touchH = touch.hidden ? 0 : touch.offsetHeight + 8;
  const w = Math.max(1, stage.clientWidth - padX);
  const h = Math.max(1, stage.clientHeight - padY - touchH);
  // Fill the whole screen: things are sized so the smallest picture fits, and
  // the picture gets wider or taller to use up the rest.
  scale = Math.min(w / VIEW_W, h / VIEW_H);
  game.viewW = w / scale;
  game.viewH = h / scale;
  screen.style.width = `${Math.floor(w)}px`;
  screen.style.height = `${Math.floor(h)}px`;
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.max(1, Math.round(w * dpr));
  canvas.height = Math.max(1, Math.round(h * dpr));
  // The 3D diner fills the space under the belly bar.
  const worldH = Math.floor(h - HUD_H * scale);
  worldCanvas.style.width = `${Math.floor(w)}px`;
  worldCanvas.style.height = `${worldH}px`;
  world.resize(Math.floor(w), worldH, game.viewW, game.viewH);
  fullButton.style.top = `${Math.round(HUD_H * scale) + 6}px`;
}

new ResizeObserver(layout).observe(stage);

// ---------- menu ----------

function openMenu(result?: string): void {
  game.paused = true;
  mouseEdge = 0;
  releaseAll();
  menuResult.hidden = !result;
  menuResult.textContent = result ?? '';
  // "Keep playing" only shows when a game was paused partway through.
  resumeButton.hidden = !(game.phase === 'playing' && game.time > 0);
  menu.hidden = false;
  menuButton.hidden = true;
  fullButton.hidden = true;
  (resumeButton.hidden ? (game.role === 'waiter' ? waiterButton : dasherButton) : resumeButton).focus();
}

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

function toggleMenu(): void {
  if (menu.hidden) openMenu();
  else if (!resumeButton.hidden) closeMenu();
}

function startAs(role: Role): void {
  game.reset(role);
  world.snapView();
  game.showNotice(role === 'waiter' ? 'Catch both dine and dashers!' : 'Eat all the food, then escape!');
  closeMenu();
}

resumeButton.addEventListener('click', closeMenu);
dasherButton.addEventListener('click', () => startAs('dasher'));
waiterButton.addEventListener('click', () => startAs('waiter'));
menuButton.addEventListener('click', () => openMenu());

// Fill the whole computer screen, if the host lets the game do that.
fullButton.addEventListener('click', () => {
  const asked = document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen();
  asked.catch(() => (fullButton.hidden = true)).finally(() => canvas.focus());
});

game.onEnd = (phase, seconds) => {
  const time = seconds.toFixed(1);
  const text =
    game.role === 'waiter'
      ? phase === 'caught'
        ? `🍽️ You caught both dine and dashers in ${time} seconds! They're washing the dishes now.`
        : '🏃💨 A dine and dasher got away! Try again.'
      : phase === 'escaped'
        ? `🏃💨 You got away with a full belly in ${time} seconds!`
        : '🧽 Caught! Now you have to wash the dishes...';
  setTimeout(() => openMenu(text), 1000);
};

document.addEventListener('visibilitychange', () => {
  if (document.hidden && menu.hidden) openMenu();
});

// ---------- moving ----------

/** Things you can hold down: up/down walk forward and back, left/right turn, or step sideways. */
type Move = Dir | 'stepLeft' | 'stepRight';

// Everything being held right now, from every kind of control. They all add together.
const heldKeys: { code: string; move: Move }[] = [];
const touchHeld = new Set<Dir>();
let padMove: MoveInput = { forward: 0, turn: 0, strafe: 0 };

function updateInput(): void {
  const held = new Set<Move>([...heldKeys.map((key) => key.move), ...touchHeld]);
  const has = (move: Move) => (held.has(move) ? 1 : 0);
  const clamp = (value: number) => Math.max(-1, Math.min(1, value));
  game.input = {
    forward: clamp(has('up') - has('down') + padMove.forward),
    turn: clamp(has('right') - has('left') + padMove.turn),
    strafe: clamp(has('stepRight') - has('stepLeft') + padMove.strafe),
  };
}

/** In the menu, the movement controls pick between the buttons. */
function press(move: Move): void {
  if (menu.hidden) return;
  moveMenuFocus(move === 'up' || move === 'left' || move === 'stepLeft' ? -1 : 1);
}

/** Arrow keys and the controller pick between the menu buttons. */
function moveMenuFocus(step: number): void {
  const buttons = [resumeButton, dasherButton, waiterButton].filter((button) => !button.hidden);
  const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
  buttons[(Math.max(at, 0) + step + buttons.length) % buttons.length].focus();
}

function releaseAll(): void {
  heldKeys.length = 0;
  touchHeld.clear();
  padMove = { forward: 0, turn: 0, strafe: 0 };
  touch.querySelectorAll('.held').forEach((button) => button.classList.remove('held'));
  updateInput();
}

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

const KEY_ACTIONS: { id: string; label: string; defaultKey: string; move: Move }[] = [
  { id: 'player.up', label: 'Walk forward', defaultKey: 'ArrowUp', move: 'up' },
  { id: 'player.down', label: 'Walk backward', defaultKey: 'ArrowDown', move: 'down' },
  { id: 'player.left', label: 'Turn left', defaultKey: 'ArrowLeft', move: 'left' },
  { id: 'player.right', label: 'Turn right', defaultKey: 'ArrowRight', move: 'right' },
  { id: 'player.upAlt', label: 'Walk forward (other key)', defaultKey: 'W', move: 'up' },
  { id: 'player.downAlt', label: 'Walk backward (other key)', defaultKey: 'S', move: 'down' },
  { id: 'player.stepLeft', label: 'Step left', defaultKey: 'A', move: 'stepLeft' },
  { id: 'player.stepRight', label: 'Step right', defaultKey: 'D', move: 'stepRight' },
];

const FALLBACK_KEYS: Record<string, Move> = {
  ArrowUp: 'up',
  KeyW: 'up',
  ArrowDown: 'down',
  KeyS: 'down',
  ArrowLeft: 'left',
  ArrowRight: 'right',
  KeyA: 'stepLeft',
  KeyD: 'stepRight',
};

function holdKey(code: string, move: Move): void {
  if (!heldKeys.some((key) => key.code === code)) heldKeys.push({ code, move });
  updateInput();
}

function letGoKey(code: string): void {
  const at = heldKeys.findIndex((key) => key.code === code);
  if (at < 0) return;
  heldKeys.splice(at, 1);
  updateInput();
}

// Letting go of a key stops that move.
window.addEventListener('keyup', (event) => letGoKey(event.code));

// Read the keyboard here, and keep moving while a key is held.
// Used when the host doesn't offer keys, or its keys don't work.
function useOwnKeyboard(): void {
  window.addEventListener('keydown', (event) => {
    if (event.code === 'Escape') {
      event.preventDefault();
      toggleMenu();
      return;
    }
    const move = FALLBACK_KEYS[event.code];
    if (!move) return;
    event.preventDefault();
    if (event.repeat) return;
    if (!menu.hidden) press(move);
    else holdKey(event.code, move);
  });
}

// The host owns the keyboard and tells us when an action's key is pressed, but not when it's let go.
// The key's own keydown still reaches us right after, so remember which key it was;
// its keyup then stops the move.
let hostPressed: Move | null = null;

function onHostAction(move: Move): void {
  if (!menu.hidden) {
    press(move);
    return;
  }
  hostPressed = move;
  setTimeout(() => {
    if (!hostPressed) return;
    // Pressed from outside the game (no keydown here): just a short nudge.
    const code = `host-${move}`;
    holdKey(code, move);
    setTimeout(() => letGoKey(code), 200);
    hostPressed = null;
  }, 0);
}

function useHostKeys(): void {
  window.addEventListener('keydown', (event) => {
    if (!hostPressed) return;
    holdKey(event.code, hostPressed);
    hostPressed = null;
  });
  const bindings = [
    ...KEY_ACTIONS.map((action) => () =>
      keys.register({ id: action.id, label: action.label, defaultKey: action.defaultKey }, () => onHostAction(action.move)),
    ),
    () => keys.register({ id: 'game.menu', label: 'Open or close the menu', defaultKey: 'Escape' }, toggleMenu),
  ].map((register) => {
    try {
      return register();
    } catch (error) {
      return Promise.reject(error);
    }
  });
  void Promise.allSettled(bindings).then((results) => {
    const done = results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []));
    if (done.length < results.length) {
      // Some keys didn't work: let go of the rest and read the keyboard here instead.
      for (const binding of done) binding.close();
      useOwnKeyboard();
      return;
    }
    window.addEventListener('pagehide', () => done.forEach((binding) => binding.close()), { once: true });
  });
}

if (runtimeHasDomain(KEYS_DOMAIN)) useHostKeys();
else useOwnKeyboard();
window.addEventListener('blur', releaseAll);

// ---------- looking around with the mouse ----------

// Just move the mouse over the game to look around, no clicking needed. Keep it near
// the left or right edge to keep turning. On a touch screen, drag a finger to look.
let dragging: { id: number; x: number; y: number } | null = null;
/** How far into the left (-1) or right (1) edge the mouse is. */
let mouseEdge = 0;
const EDGE = 0.15;
const EDGE_TURN_SPEED = 2.5;

function lookBy(dx: number, dy: number): void {
  game.turnBy(-dx * 0.004);
  world.pitchBy(dy * 0.004);
}

canvas.addEventListener('pointerdown', (event) => {
  canvas.focus();
  if (event.pointerType === 'mouse' || !menu.hidden) return;
  dragging = { id: event.pointerId, x: event.clientX, y: event.clientY };
  canvas.setPointerCapture(event.pointerId);
});

canvas.addEventListener('pointermove', (event) => {
  if (!menu.hidden || game.paused) return;
  if (event.pointerType === 'mouse') {
    lookBy(event.movementX, event.movementY);
    const box = canvas.getBoundingClientRect();
    const across = (event.clientX - box.left) / Math.max(1, box.width);
    mouseEdge = across < EDGE ? (across - EDGE) / EDGE : across > 1 - EDGE ? (across - (1 - EDGE)) / EDGE : 0;
    return;
  }
  if (!dragging || dragging.id !== event.pointerId) return;
  lookBy(event.clientX - dragging.x, event.clientY - dragging.y);
  dragging.x = event.clientX;
  dragging.y = event.clientY;
});

canvas.addEventListener('pointerleave', () => (mouseEdge = 0));

const stopDragging = (event: PointerEvent) => {
  if (dragging?.id === event.pointerId) dragging = null;
};
canvas.addEventListener('pointerup', stopDragging);
canvas.addEventListener('pointercancel', stopDragging);

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

for (const button of touch.querySelectorAll<HTMLButtonElement>('button[data-dir]')) {
  const dir = button.dataset.dir as Dir;
  const letGo = () => {
    button.classList.remove('held');
    touchHeld.delete(dir);
    updateInput();
  };
  button.addEventListener('pointerdown', (event) => {
    event.preventDefault();
    button.setPointerCapture(event.pointerId);
    button.classList.add('held');
    press(dir);
    touchHeld.add(dir);
    updateInput();
  });
  button.addEventListener('pointerup', letGo);
  button.addEventListener('pointercancel', letGo);
}

let alwaysShowTouch = false;
const coarse = window.matchMedia('(pointer: coarse)');

function updateTouchMode(): void {
  const on = alwaysShowTouch || coarse.matches;
  touch.hidden = !on;
  document.body.classList.toggle('touch-mode', on);
  layout();
}
coarse.addEventListener('change', updateTouchMode);

if (runtimeHasDomain(CONFIG_DOMAIN)) {
  startSettings((values) => {
    alwaysShowTouch = values.touchButtons === true;
    updateTouchMode();
  });
}

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

// Controllers come through the host (NAP-GAMEPAD). Hosts without it just leave keyboard and touch.
const hasGamepad = runtimeHasDomain(GAMEPAD_DOMAIN);
const pad = createGamepadInput(
  {
    moveX: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
    moveY: [{ axis: 1 }, { button: 12, scale: -1 }, { button: 13 }],
    confirm: [{ button: 0 }],
    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,
  },
);

/** Which way the controller was pushed last frame (for picking menu buttons). */
let padDir: Dir | null = null;

function pollPad(): void {
  const frame = pad.poll();
  if (frame.connected.length > 0) game.showNotice('Controller connected!');
  if (frame.disconnected.length > 0 && frame.players.length === 0 && menu.hidden) openMenu();
  let dir: Dir | null = null;
  for (const controller of frame.players) {
    const { moveX, moveY, confirm, pause } = controller.actions;
    if (pause.pressed) {
      toggleMenu();
      continue;
    }
    if (!menu.hidden && confirm.pressed) {
      // A presses the menu button that's picked.
      const picked = document.activeElement;
      (picked instanceof HTMLButtonElement && menu.contains(picked) ? picked : dasherButton).click();
      continue;
    }
    // Go whichever way the stick is pushed the most.
    if (Math.abs(moveX.value) >= 0.5 || Math.abs(moveY.value) >= 0.5) {
      if (Math.abs(moveX.value) > Math.abs(moveY.value)) dir = moveX.value > 0 ? 'right' : 'left';
      else dir = moveY.value > 0 ? 'down' : 'up';
    }
  }
  if (dir !== padDir) {
    if (dir) press(dir);
    padDir = dir;
  }
  // In the game, the stick walks and turns smoothly: push it further to go faster.
  const deadZone = (value: number) => (Math.abs(value) < 0.2 ? 0 : value);
  const stick = frame.players[0]?.actions;
  padMove = {
    forward: menu.hidden && stick ? -deadZone(stick.moveY.value) : 0,
    turn: menu.hidden && stick ? deadZone(stick.moveX.value) : 0,
    strafe: 0,
  };
  updateInput();
}

// ---------- main loop ----------

let last = performance.now();
function frame(now: number): void {
  const dt = Math.min(0.05, (now - last) / 1000);
  last = now;
  if (hasGamepad) pollPad();
  if (menu.hidden && !game.paused) game.turnBy(-mouseEdge * EDGE_TURN_SPEED * dt);
  game.update(dt);
  const dpr = window.devicePixelRatio || 1;
  ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
  world.render(game.view());
  game.draw(world.project);
  requestAnimationFrame(frame);
}

updateTouchMode();
openMenu();
requestAnimationFrame(frame);