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.
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);
