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.
/**
* Dine and Dash: sneak around a diner, eat every plate of food, then run out the door
* without a waiter catching you. The restaurant is a grid of tiles; you see it
* through your own eyes (render3d.ts), with the belly meter drawn on top here.
*/
export type Dir = 'up' | 'down' | 'left' | 'right';
export type Phase = 'playing' | 'caught' | 'escaped';
/** Who you play: the sneaky dine-and-dasher, or the waiter trying to catch them. */
export type Role = 'dasher' | 'waiter';
const STEP: Record<Dir, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
/**
* The diner. # wall, K kitchen counter, T table, F table with food,
* D the door out, S where you start (in your seat).
* Walls and kitchen stay put; a fresh room is made for every game (see makeRoom).
*/
export const COLS = 15;
export const ROWS = 11;
/** How many tables a room gets, and how many of them have food when you're the dasher. */
const TABLES = 12;
const DASHER_MODE_FOOD = 5;
/** A new room: tables in random spots (never touching, so you can always walk round them), a random door and start corner. */
function makeRoom(): string[] {
for (;;) {
const grid: string[][] = Array.from({ length: ROWS }, (_, y) =>
Array.from({ length: COLS }, (_, x) => {
if (x === 0 || y === 0 || x === COLS - 1 || y === ROWS - 1) return '#';
if (y === 1 && (x <= 4 || x >= COLS - 5)) return 'K';
return '.';
}),
);
grid[ROWS - 1][3 + Math.floor(Math.random() * (COLS - 6))] = 'D';
grid[ROWS - 2][Math.random() < 0.5 ? 1 : COLS - 2] = 'S';
// Tables go in the middle rows, leaving a clear walkway by the kitchen and along the bottom.
const spots: Point[] = [];
for (let y = 3; y <= ROWS - 3; y++) for (let x = 2; x <= COLS - 3; x++) spots.push({ x, y });
spots.sort(() => Math.random() - 0.5);
const tables: Point[] = [];
for (const spot of spots) {
if (tables.length === TABLES) break;
if (tables.some((t) => Math.abs(t.x - spot.x) <= 1 && Math.abs(t.y - spot.y) <= 1)) continue;
tables.push(spot);
}
if (tables.length < TABLES) continue;
tables.forEach((t, i) => (grid[t.y][t.x] = i < DASHER_MODE_FOOD ? 'F' : 'T'));
return grid.map((row) => row.join(''));
}
}
/** The room being played right now. */
let MAP = makeRoom();
/** Room at the top of the picture for the belly meter and timer. */
export const HUD_H = 36;
/** The smallest the picture gets (it's scaled to fit, then stretched to fill the screen). */
export const VIEW_W = 480;
export const VIEW_H = 352 + HUD_H;
const FOODS = ['🍔', '🍕', '🍰', '🌭', '🍝', '🍩', '🌮'];
/** Seconds for one step. Waiters are slower than you, until they spot you. */
/** You walk freely: tiles per second forward, backward and sideways, and how fast the keys turn you. */
const WALK_SPEED = 5.5;
const BACK_SPEED = 3.5;
const TURN_SPEED = 3;
/** How wide you are (you bump into walls and tables at this distance). */
const RADIUS = 0.25;
/** How close to a table you need to be to grab its food. */
const REACH = 0.1;
const WAITER_STEP = 0.42;
const CHASE_STEP = 0.2;
/** How far a waiter can see in front of them. */
const SIGHT = 5;
/** How long a waiter looks around ("?") after losing you, before going back to work. */
const SEARCH_TIME = 1.5;
/** How long a waiter has to see you before they notice and chase (when you're the dasher). */
const WAITER_REACT_TIME = 0.6;
/** When you're the waiter: the dashers' step times, how close they notice you, and how long a plate takes them to eat. */
const DASHER_STEP = 0.2;
const DASHER_FLEE_STEP = 0.17;
const DASHER_SIGHT = 5;
const DASHER_EAT_TIME = 1.4;
/** How long a dasher has to see you before they notice and run. */
const DASHER_REACT_TIME = 0.35;
/** How many plates of food are out when you're the waiter. */
const WAITER_MODE_FOOD = 10;
/** How many plates each dasher eats before heading for the door. */
const DASHER_BELLY = 5;
/** Where you start as the waiter (the kitchen), and where the two dashers sit. */
const KITCHEN: Point = { x: 7, y: 1 };
const DASHER_SEATS: Point[] = [{ x: 1, y: 9 }, { x: 13, y: 9 }];
export interface Point {
x: number;
y: number;
}
export interface Mover {
x: number;
y: number;
/** Where they're stepping from, and how far through the step they are (0 to 1). */
fromX: number;
fromY: number;
t: number;
facing: Dir;
}
export interface Waiter extends Mover {
route: Point[];
next: number;
mode: 'patrol' | 'chase' | 'search';
lastSeen: Point | null;
searchTimer: number;
wait: number;
/** How long they've been looking at you without noticing yet. */
spotted: number;
}
export interface Dasher extends Mover {
ate: number;
/** Seconds left munching the plate in front of them. */
eating: number;
scared: boolean;
/** How long they've been looking at you without noticing yet. */
spotted: number;
caught: boolean;
}
interface Pop {
text: string;
x: number;
y: number;
age: number;
}
export function tileAt(x: number, y: number): string {
return MAP[y]?.[x] ?? '#';
}
function findTiles(kind: string): Point[] {
const found: Point[] = [];
MAP.forEach((row, y) => [...row].forEach((c, x) => c === kind && found.push({ x, y })));
return found;
}
function makeMover(x: number, y: number, facing: Dir): Mover {
return { x, y, fromX: x, fromY: y, t: 1, facing };
}
/** Which way you look for each facing, as a turn angle. Facing up is 0. */
const ANGLE: Record<Dir, number> = { up: 0, left: Math.PI / 2, down: Math.PI, right: -Math.PI / 2 };
/** How far a point is from a tile's square (0 if it's inside). */
function gapToTile(x: number, y: number, tx: number, ty: number): number {
const dx = Math.max(Math.abs(x - tx) - 0.5, 0);
const dy = Math.max(Math.abs(y - ty) - 0.5, 0);
return Math.hypot(dx, dy);
}
/** What the controls are asking for right now: each from -1 to 1. */
export interface MoveInput {
/** Forward (1) or backward (-1). */
forward: number;
/** Turn right (1) or left (-1). */
turn: number;
/** Step right (1) or left (-1). */
strafe: number;
}
/** Everything the 3D picture needs to know about one moment of the game. */
export interface GameView {
role: Role;
/** This game's room (a new one each game). */
map: readonly string[];
player: Mover;
/** Which way you're looking (0 is up the map, bigger turns left). */
angle: number;
/** Goes up as you walk, for swinging arms and a bouncy view. */
stride: number;
waiters: Waiter[];
dashers: Dasher[];
food: Map<string, string>;
bellyFull: boolean;
/** Whether the front door shows as open (green EXIT). */
doorOpen: boolean;
clock: number;
/** Where someone is right now, partway through a step. */
posOf: (m: Mover) => Point;
/** Floor tiles lit up by each waiter's yellow light. */
visionOf: (w: Waiter) => Point[];
}
/** Turns a point in the diner into a spot on the picture, or null if it's behind you. */
export type Project = (x: number, height: number, y: number) => { x: number; y: number } | null;
function dirTo(from: Point, to: Point): Dir {
if (to.x > from.x) return 'right';
if (to.x < from.x) return 'left';
return to.y > from.y ? 'down' : 'up';
}
export class Game {
phase: Phase = 'playing';
time = 0;
paused = true;
/** What the controls are pressing right now. */
input: MoveInput = { forward: 0, turn: 0, strafe: 0 };
/** The picture's size right now: it grows wider or taller to fill the screen. */
viewW = VIEW_W;
viewH = VIEW_H;
role: Role = 'dasher';
private player = makeMover(0, 0, 'up');
private angle = 0;
private stride = 0;
private moving = false;
private waiters: Waiter[] = [];
private dashers: Dasher[] = [];
private food = new Map<string, string>();
private eaten = 0;
private total = 0;
private pops: Pop[] = [];
private notice = '';
private noticeTimer = 0;
private clock = 0;
onEnd: (phase: Phase, seconds: number) => void = () => {};
constructor(private ctx: CanvasRenderingContext2D) {
this.reset();
}
get bellyFull(): boolean {
return this.eaten >= this.total;
}
reset(role: Role = this.role): void {
this.role = role;
MAP = makeRoom();
const start = role === 'waiter' ? KITCHEN : findTiles('S')[0];
this.player = makeMover(start.x, start.y, role === 'waiter' ? 'down' : 'up');
this.food.clear();
// As the waiter there's more food out: 10 plates for the two dashers to go after.
const plates = role === 'waiter' ? [...findTiles('F'), ...findTiles('T')].slice(0, WAITER_MODE_FOOD) : findTiles('F');
plates.forEach((p, i) => this.food.set(`${p.x},${p.y}`, FOODS[i % FOODS.length]));
this.total = this.food.size;
this.eaten = 0;
this.time = 0;
this.pops = [];
this.notice = '';
this.angle = ANGLE[this.player.facing];
this.stride = 0;
this.moving = false;
this.input = { forward: 0, turn: 0, strafe: 0 };
this.phase = 'playing';
if (role === 'waiter') {
// You're the only waiter: no helpers.
this.waiters = [];
this.dashers = DASHER_SEATS.map((seat) => ({
...makeMover(seat.x, seat.y, 'up'),
ate: 0,
eating: 0,
scared: false,
spotted: 0,
caught: false,
}));
return;
}
this.dashers = [];
// Two waiters walking laps around the tables.
this.waiters = [
this.makeWaiter(this.randomRoute(start)),
this.makeWaiter(this.randomRoute(start)),
];
}
/** Turn your view (looking around with the mouse). Positive turns left. */
turnBy(amount: number): void {
this.angle += amount;
}
showNotice(text: string): void {
this.notice = text;
this.noticeTimer = 2;
}
private makeWaiter(route: Point[]): Waiter {
const start = route[route.length - 1];
return { ...makeMover(start.x, start.y, 'left'), route, next: 0, mode: 'patrol', lastSeen: null, searchTimer: 0, wait: 0, spotted: 0 };
}
/** A random walking lap for a waiter, starting well away from you so you get a fair start. */
private randomRoute(awayFrom: Point): Point[] {
const floor = findTiles('.');
const pick = () => floor[Math.floor(Math.random() * floor.length)];
const route = [pick(), pick(), pick()];
let start = pick();
while (Math.abs(start.x - awayFrom.x) + Math.abs(start.y - awayFrom.y) < 8) start = pick();
return [...route, start];
}
private walkable(x: number, y: number, doorOpen = this.bellyFull): boolean {
const c = tileAt(x, y);
return c === '.' || c === 'S' || (c === 'D' && doorOpen);
}
/** A dasher heads for the door once they've eaten their fill (or there's nothing left to eat). */
private readyToLeave(d: Dasher): boolean {
return d.ate >= DASHER_BELLY || this.food.size === 0;
}
/** Whether the door shows as open: your belly's full, or (as the waiter) a dasher is heading out. */
private get doorOpen(): boolean {
if (this.role === 'waiter') return this.dashers.some((d) => !d.caught && this.readyToLeave(d));
return this.bellyFull;
}
update(dt: number): void {
this.clock += dt;
this.noticeTimer -= dt;
this.pops = this.pops.filter((p) => (p.age += dt) < 1);
if (this.paused || this.phase !== 'playing') return;
this.time += dt;
this.updatePlayer(dt);
if (this.role === 'waiter') {
for (const dasher of this.dashers) this.updateDasher(dasher, dt);
this.checkCaughtDashers();
return;
}
for (const waiter of this.waiters) this.updateWaiter(waiter, dt);
this.checkCaught();
}
private updatePlayer(dt: number): void {
const p = this.player;
const { forward, turn, strafe } = this.input;
this.angle -= turn * TURN_SPEED * dt;
// Walk the way you're looking, and step sideways.
const ahead = { x: -Math.sin(this.angle), y: -Math.cos(this.angle) };
const right = { x: Math.cos(this.angle), y: -Math.sin(this.angle) };
const speed = forward >= 0 ? WALK_SPEED : BACK_SPEED;
let dx = (ahead.x * forward + right.x * strafe) * speed * dt;
let dy = (ahead.y * forward + right.y * strafe) * speed * dt;
const length = Math.hypot(dx, dy);
const most = WALK_SPEED * dt;
if (length > most) {
dx *= most / length;
dy *= most / length;
}
// Slide along walls and tables: try across and up/down separately.
const startX = p.x;
const startY = p.y;
if (this.fits(p.x + dx, p.y)) p.x += dx;
if (this.fits(p.x, p.y + dy)) p.y += dy;
const moved = Math.hypot(p.x - startX, p.y - startY);
this.stride += moved;
this.moving = moved > 0;
p.fromX = p.x;
p.fromY = p.y;
p.t = 1;
// The closest of the four ways, for the waiters' eyes and your arms.
const quarter = Math.round(this.angle / (Math.PI / 2));
p.facing = (['up', 'left', 'down', 'right'] as Dir[])[((quarter % 4) + 4) % 4];
if (this.role === 'waiter') return;
if (tileAt(Math.round(p.x), Math.round(p.y)) === 'D') {
this.phase = 'escaped';
this.onEnd('escaped', this.time);
return;
}
// Walk up to a table with food to gobble it up.
for (const key of [...this.food.keys()]) {
const [tx, ty] = key.split(',').map(Number);
if (gapToTile(p.x, p.y, tx, ty) > RADIUS + REACH) continue;
this.food.delete(key);
this.eaten++;
this.pops.push({ text: 'NOM!', x: tx, y: ty, age: 0 });
if (this.bellyFull) this.showNotice('Belly full! Run for the door!');
}
if (!this.bellyFull && this.noticeTimer <= 0) {
const door = findTiles('D')[0];
if (gapToTile(p.x, p.y, door.x, door.y) <= RADIUS + REACH) {
this.showNotice(`Still hungry! Eat ${this.total - this.eaten} more first.`);
}
}
}
/** Whether you'd fit at a spot without poking into a wall, counter or table. */
private fits(x: number, y: number): boolean {
for (let ty = Math.floor(y - RADIUS); ty <= Math.ceil(y + RADIUS); ty++) {
for (let tx = Math.floor(x - RADIUS); tx <= Math.ceil(x + RADIUS); tx++) {
const blocked = !this.walkable(tx, ty) || (this.role === 'waiter' && tileAt(tx, ty) === 'D');
if (blocked && gapToTile(x, y, tx, ty) < RADIUS) return false;
}
}
return true;
}
/** The floor tile you're standing on (the waiters and dashers think in tiles). */
private playerTile(): Mover {
const p = this.player;
return makeMover(Math.round(p.x), Math.round(p.y), p.facing);
}
private moveTo(m: Mover, x: number, y: number): void {
m.facing = dirTo(m, { x, y });
m.fromX = m.x;
m.fromY = m.y;
m.x = x;
m.y = y;
m.t = 0;
}
/** Everyone who catches: you when you're the waiter, otherwise the waiters. */
private catchers(): Mover[] {
return this.role === 'waiter' ? [this.playerTile()] : this.waiters;
}
/** A dasher (when you're the waiter): eat the food, then run for the door. Run away if the waiter comes close. */
private updateDasher(d: Dasher, dt: number): void {
if (d.caught) return;
const inSight = this.catchers().some(
(w) => Math.abs(w.x - d.x) + Math.abs(w.y - d.y) <= DASHER_SIGHT && this.clearLine(d, w),
);
// They take a moment to notice you before they run.
d.spotted = inSight ? d.spotted + dt : 0;
const scared = d.spotted >= DASHER_REACT_TIME;
d.t = Math.min(1, d.t + dt / (scared ? DASHER_FLEE_STEP : DASHER_STEP));
if (d.t < 1) return;
if (tileAt(d.x, d.y) === 'D') {
this.phase = 'escaped';
this.pops.push({ text: 'BYE!', x: d.x, y: d.y, age: 0 });
this.onEnd('escaped', this.time);
return;
}
if (scared) {
if (!d.scared) this.pops.push({ text: '!', x: d.x, y: d.y, age: 0 });
d.scared = true;
d.eating = 0;
const next = this.fleeStep(d, this.readyToLeave(d));
if (next) this.moveTo(d, next.x, next.y);
return;
}
d.scared = false;
const leaving = this.readyToLeave(d);
// Next to a plate (and still hungry): munch it for a moment, then it's gone.
const plate = leaving ? null : this.foodNextTo(d);
if (plate) {
if (d.eating <= 0) d.eating = DASHER_EAT_TIME;
d.facing = dirTo(d, plate);
d.eating -= dt;
if (d.eating > 0) return;
this.food.delete(`${plate.x},${plate.y}`);
this.eaten++;
d.ate++;
this.pops.push({ text: 'NOM!', x: plate.x, y: plate.y, age: 0 });
if (this.readyToLeave(d)) this.showNotice("A dasher is full! They're running for the door!");
return;
}
const next = leaving
? this.pathToward(d, (p) => tileAt(p.x, p.y) === 'D', true)
: this.pathToward(d, (p) => this.foodNextTo(p) !== null, false);
if (next) this.moveTo(d, next.x, next.y);
}
private foodNextTo(p: Point): Point | null {
for (const [dx, dy] of Object.values(STEP)) {
if (this.food.has(`${p.x + dx},${p.y + dy}`)) return { x: p.x + dx, y: p.y + dy };
}
return null;
}
/** Run away: step to the tile that's the longest walk from the waiters. Head for the door if it's open. */
private fleeStep(d: Dasher, doorOpen: boolean): Point | null {
const far = this.walkDistances(this.catchers());
let best: Point | null = null;
let bestScore = far.get(`${d.x},${d.y}`) ?? 0;
for (const [dx, dy] of Object.values(STEP)) {
const n = { x: d.x + dx, y: d.y + dy };
if (!this.walkable(n.x, n.y, doorOpen)) continue;
if (tileAt(n.x, n.y) === 'D') return n;
const score = (far.get(`${n.x},${n.y}`) ?? 0) + Math.random() * 0.5;
if (score > bestScore) {
best = n;
bestScore = score;
}
}
return best;
}
/** How many steps it takes to walk to every tile from the nearest of some starting tiles. */
private walkDistances(from: Point[]): Map<string, number> {
const dist = new Map<string, number>(from.map((p) => [`${p.x},${p.y}`, 0]));
const queue: Point[] = [...from];
while (queue.length > 0) {
const at = queue.shift()!;
const d = dist.get(`${at.x},${at.y}`)!;
for (const [dx, dy] of Object.values(STEP)) {
const n = { x: at.x + dx, y: at.y + dy };
const key = `${n.x},${n.y}`;
if (dist.has(key) || tileAt(n.x, n.y) === 'D' || !this.walkable(n.x, n.y)) continue;
dist.set(key, d + 1);
queue.push(n);
}
}
return dist;
}
private checkCaughtDashers(): void {
const waiters = [this.player, ...this.waiters].map((w) => this.drawPos(w));
for (const d of this.dashers) {
if (d.caught) continue;
const q = this.drawPos(d);
if (waiters.some((p) => Math.hypot(p.x - q.x, p.y - q.y) < 0.65)) {
d.caught = true;
this.pops.push({ text: 'GOTCHA!', x: d.x, y: d.y, age: 0 });
const left = this.dashers.filter((other) => !other.caught).length;
if (left > 0) this.showNotice(`Gotcha! ${left} more to catch!`);
else {
this.phase = 'caught';
this.onEnd('caught', this.time);
}
}
}
}
private updateWaiter(w: Waiter, dt: number): void {
// Waiters chase you when you're the dasher, or the dashers when you're the waiter.
const prey = this.role === 'dasher' ? [this.playerTile()] : this.dashers.filter((d) => !d.caught);
const p = prey.find((target) => this.canSee(w, target));
// When you're the dasher, they take a moment to notice you (once chasing, they keep up).
w.spotted = p ? w.spotted + dt : 0;
const react = this.role === 'dasher' ? WAITER_REACT_TIME : 0;
if (p && (w.mode === 'chase' || w.spotted >= react)) {
if (w.mode !== 'chase') this.pops.push({ text: '!', x: w.x, y: w.y, age: 0 });
w.mode = 'chase';
w.lastSeen = { x: p.x, y: p.y };
}
const step = w.mode === 'chase' ? CHASE_STEP : WAITER_STEP;
w.t = Math.min(1, w.t + dt / step);
if (w.t < 1) return;
let target: Point;
if (w.mode === 'chase' && w.lastSeen) {
if (w.x === w.lastSeen.x && w.y === w.lastSeen.y) {
// Lost you. Look around for a moment.
w.mode = 'search';
w.searchTimer = SEARCH_TIME;
w.lastSeen = null;
this.pops.push({ text: '?', x: w.x, y: w.y, age: 0 });
return;
}
target = w.lastSeen;
} else if (w.mode === 'search') {
w.searchTimer -= dt;
// Turn around while looking.
const turns: Dir[] = ['up', 'right', 'down', 'left'];
w.facing = turns[Math.floor(this.clock * 1.5) % 4];
if (w.searchTimer > 0) return;
w.mode = 'patrol';
target = w.route[w.next];
} else {
if (w.x === w.route[w.next].x && w.y === w.route[w.next].y) {
w.next = (w.next + 1) % w.route.length;
}
target = w.route[w.next];
}
const next = this.pathStep(w, target);
if (!next) return;
w.facing = dirTo(w, next);
w.fromX = w.x;
w.fromY = w.y;
w.x = next.x;
w.y = next.y;
w.t = 0;
}
/** The first tile on the shortest walk from a mover to a target. */
private pathStep(from: Point, to: Point): Point | null {
return this.pathToward(from, (p) => p.x === to.x && p.y === to.y, false);
}
/** The first tile on the shortest walk to the nearest tile that `isGoal` likes. */
private pathToward(from: Point, isGoal: (p: Point) => boolean, door: boolean): Point | null {
const seen = new Map<string, Point | null>([[`${from.x},${from.y}`, null]]);
const queue: Point[] = [{ x: from.x, y: from.y }];
while (queue.length > 0) {
const at = queue.shift()!;
if (isGoal(at)) {
let step = at;
let back = seen.get(`${step.x},${step.y}`);
while (back && !(back.x === from.x && back.y === from.y)) {
step = back;
back = seen.get(`${step.x},${step.y}`);
}
return step.x === from.x && step.y === from.y ? null : step;
}
for (const [dx, dy] of Object.values(STEP)) {
const n = { x: at.x + dx, y: at.y + dy };
const key = `${n.x},${n.y}`;
// Waiters never leave through the front door.
if (seen.has(key) || (!door && tileAt(n.x, n.y) === 'D') || !this.walkable(n.x, n.y, door)) continue;
seen.set(key, at);
queue.push(n);
}
}
return null;
}
/** Tiles a waiter can see: a cone in front of them, blocked by tables and walls. */
private visionTiles(w: Waiter): Point[] {
const [fx, fy] = STEP[w.facing];
const tiles: Point[] = [];
for (let d = 1; d <= SIGHT; d++) {
const spread = Math.floor(d / 2);
for (let side = -spread; side <= spread; side++) {
const x = w.x + fx * d + fy * side;
const y = w.y + fy * d + fx * side;
if (this.walkable(x, y) && this.clearLine(w, { x, y })) tiles.push({ x, y });
}
}
return tiles;
}
private clearLine(a: Point, b: Point): boolean {
const steps = Math.max(Math.abs(b.x - a.x), Math.abs(b.y - a.y));
for (let i = 1; i < steps; i++) {
const x = Math.round(a.x + ((b.x - a.x) * i) / steps);
const y = Math.round(a.y + ((b.y - a.y) * i) / steps);
if (!this.walkable(x, y)) return false;
}
return true;
}
private canSee(w: Waiter, p: Point): boolean {
// Right next to a waiter counts as seen, even behind their back.
if (Math.abs(w.x - p.x) + Math.abs(w.y - p.y) <= 1) return true;
return this.visionTiles(w).some((t) => t.x === p.x && t.y === p.y);
}
private checkCaught(): void {
const p = this.drawPos(this.player);
for (const w of this.waiters) {
const q = this.drawPos(w);
if (Math.hypot(p.x - q.x, p.y - q.y) < 0.65) {
this.phase = 'caught';
this.pops.push({ text: 'CAUGHT!', x: this.player.x, y: this.player.y, age: 0 });
this.onEnd('caught', this.time);
return;
}
}
}
private drawPos(m: Mover): Point {
const t = m.t;
return { x: m.fromX + (m.x - m.fromX) * t, y: m.fromY + (m.y - m.fromY) * t };
}
view(): GameView {
return {
role: this.role,
map: MAP,
player: this.player,
angle: this.angle,
stride: this.stride,
waiters: this.waiters,
dashers: this.dashers,
food: this.food,
bellyFull: this.bellyFull,
doorOpen: this.doorOpen,
clock: this.clock,
posOf: (m) => this.drawPos(m),
visionOf: (w) => this.visionTiles(w),
};
}
// ---------- drawing on top of the 3D picture ----------
draw(project: Project): void {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.viewW, this.viewH);
this.drawChased();
this.drawPops(project);
this.drawHands();
this.drawHud();
}
/** The edges of the screen glow red while a waiter is chasing you. */
private drawChased(): void {
if (this.role !== 'dasher' || !this.waiters.some((w) => w.mode === 'chase')) return;
const ctx = this.ctx;
const glow = ctx.createRadialGradient(this.viewW / 2, this.viewH / 2, this.viewH * 0.35, this.viewW / 2, this.viewH / 2, this.viewW * 0.6);
glow.addColorStop(0, 'rgba(255, 40, 40, 0)');
glow.addColorStop(1, `rgba(255, 40, 40, ${0.35 + Math.sin(this.clock * 10) * 0.15})`);
ctx.fillStyle = glow;
ctx.fillRect(0, HUD_H, this.viewW, this.viewH - HUD_H);
}
/** Your own arms at the bottom of the screen: green with white hoodie sleeves, or black for the waiter. */
private drawHands(): void {
const ctx = this.ctx;
const swing = this.moving ? Math.sin(this.stride * 3) * 10 : Math.sin(this.clock * 2) * 2;
const waiter = this.role === 'waiter';
ctx.lineCap = 'round';
for (const side of [-1, 1]) {
const bob = side === 1 ? swing : -swing;
const shoulderX = this.viewW / 2 + side * 185;
const shoulderY = this.viewH + 40;
const elbowX = this.viewW / 2 + side * 155;
const elbowY = this.viewH - 20 + bob;
const handX = this.viewW / 2 + side * 125;
const handY = this.viewH - 55 + bob;
// Sleeve up to the elbow.
ctx.strokeStyle = waiter ? '#111111' : '#f4f4f4';
ctx.lineWidth = waiter ? 9 : 24;
ctx.beginPath();
ctx.moveTo(shoulderX, shoulderY);
ctx.lineTo(elbowX, elbowY);
ctx.stroke();
if (!waiter) {
ctx.strokeStyle = '#d0d0d0';
ctx.lineWidth = 26;
ctx.beginPath();
ctx.moveTo(elbowX + (shoulderX - elbowX) * 0.08, elbowY + (shoulderY - elbowY) * 0.08);
ctx.lineTo(elbowX, elbowY);
ctx.stroke();
}
// Stick forearm and a round hand.
ctx.strokeStyle = waiter ? '#111111' : '#2fbf4f';
ctx.lineWidth = 8;
ctx.beginPath();
ctx.moveTo(elbowX, elbowY);
ctx.lineTo(handX, handY);
ctx.stroke();
ctx.fillStyle = ctx.strokeStyle;
ctx.beginPath();
ctx.arc(handX, handY, 9, 0, Math.PI * 2);
ctx.fill();
}
}
/** Little words like NOM! and ! that float up over people and tables. */
private drawPops(project: Project): void {
const ctx = this.ctx;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const pop of this.pops) {
const at = project(pop.x, 1.25 + pop.age * 0.3, pop.y);
if (!at) continue;
ctx.globalAlpha = 1 - pop.age;
ctx.font = 'bold 18px system-ui, sans-serif';
ctx.lineWidth = 4;
ctx.strokeStyle = '#2a2430';
ctx.strokeText(pop.text, at.x, at.y);
ctx.fillStyle = pop.text === 'NOM!' ? '#ffd640' : '#ff5a8a';
ctx.fillText(pop.text, at.x, at.y);
}
ctx.globalAlpha = 1;
}
private drawHud(): void {
const ctx = this.ctx;
ctx.fillStyle = '#1c1420';
ctx.fillRect(0, 0, this.viewW, HUD_H);
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.font = 'bold 14px system-ui, sans-serif';
ctx.fillStyle = '#ff5a8a';
if (this.role === 'waiter') {
// Food still on the tables, and how many dashers you've caught.
ctx.fillText('FOOD', 10, HUD_H / 2);
for (let i = 0; i < this.total; i++) {
ctx.fillStyle = i < this.total - this.eaten ? '#ffd640' : '#3b2f45';
ctx.beginPath();
ctx.arc(60 + i * 22, HUD_H / 2, 8, 0, Math.PI * 2);
ctx.fill();
}
// Kept right of the middle so the Menu button doesn't cover it.
ctx.fillStyle = '#ff5a8a';
ctx.textAlign = 'right';
ctx.fillText('CAUGHT', this.viewW / 2 + 96, HUD_H / 2);
this.dashers.forEach((d, i) => {
ctx.fillStyle = d.caught ? '#2fbf4f' : '#3b2f45';
ctx.beginPath();
ctx.arc(this.viewW / 2 + 110 + i * 22, HUD_H / 2, 8, 0, Math.PI * 2);
ctx.fill();
});
} else {
ctx.fillText('BELLY', 10, HUD_H / 2);
for (let i = 0; i < this.total; i++) {
ctx.fillStyle = i < this.eaten ? '#ffd640' : '#3b2f45';
ctx.beginPath();
ctx.arc(64 + i * 22, HUD_H / 2, 8, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.textAlign = 'right';
ctx.fillStyle = '#4fe3d0';
ctx.fillText(`${this.time.toFixed(1)} s`, this.viewW - 10, HUD_H / 2);
if (this.noticeTimer > 0 && this.notice) {
ctx.textAlign = 'center';
ctx.font = 'bold 15px system-ui, sans-serif';
const y = HUD_H + 24;
ctx.lineWidth = 4;
ctx.strokeStyle = '#1c1420';
ctx.strokeText(this.notice, this.viewW / 2, y);
ctx.fillStyle = '#ffffff';
ctx.fillText(this.notice, this.viewW / 2, y);
}
}
}
