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 {
BoxGeometry,
CanvasTexture,
CylinderGeometry,
DirectionalLight,
DoubleSide,
Fog,
Group,
HemisphereLight,
Mesh,
MeshBasicMaterial,
MeshLambertMaterial,
PerspectiveCamera,
PlaneGeometry,
RepeatWrapping,
SRGBColorSpace,
Scene,
SphereGeometry,
Sprite,
SpriteMaterial,
Vector3,
WebGLRenderer,
type Material,
} from 'three';
import { COLS, HUD_H, ROWS, VIEW_H, VIEW_W, tileAt, type Dir, type GameView, type Mover, type Project } from './game.js';
/**
* The diner seen through your own eyes. Each tile of the map is one step wide.
* Tile (x, y) sits at x across and z = y deep; up is height.
*/
const WALL_H = 1.6;
/** How high your eyes are (stickmen are about 1 tall). */
const EYES = 0.82;
const WAITER_INK = '#151515';
const DASHER_GREEN = '#2fbf4f';
const HOODIE = '#f4f4f4';
/** Which way to turn for each facing. Facing up looks along -z. */
const YAW: Record<Dir, number> = { up: 0, left: Math.PI / 2, down: Math.PI, right: -Math.PI / 2 };
/** Make a texture by drawing on a little canvas. */
function paint(width: number, height: number, draw: (ctx: CanvasRenderingContext2D) => void): CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
draw(canvas.getContext('2d')!);
const texture = new CanvasTexture(canvas);
texture.colorSpace = SRGBColorSpace;
texture.anisotropy = 4;
return texture;
}
/** Turn smoothly from one angle to another, the short way round. */
function turnToward(from: number, to: number, amount: number): number {
let diff = to - from;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
return from + diff * amount;
}
const EMOJI_FONT = '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", system-ui, sans-serif';
/** A stickman built from thin tubes, with arms and legs that swing when they walk. */
class Stickman {
group = new Group();
private legs: Group[] = [];
private arms: Group[] = [];
private yaw = 0;
constructor(kind: 'waiter' | 'dasher') {
const color = kind === 'waiter' ? WAITER_INK : DASHER_GREEN;
const ink = new MeshLambertMaterial({ color });
const white = new MeshLambertMaterial({ color: HOODIE });
const limb = new CylinderGeometry(0.028, 0.028, 1, 8);
const head = new Mesh(new SphereGeometry(0.1, 16, 12), ink);
head.position.y = 0.88;
const body = new Mesh(limb, kind === 'dasher' ? white : ink);
body.scale.y = 0.34;
body.position.y = 0.62;
this.group.add(head, body);
// Eyes on the front of the head, so you can tell which way they're looking.
const eye = new MeshBasicMaterial({ color: kind === 'waiter' ? '#ffffff' : '#111111' });
for (const side of [-1, 1]) {
const dot = new Mesh(new SphereGeometry(0.018, 8, 6), eye);
dot.position.set(side * 0.035, 0.9, -0.09);
this.group.add(dot);
}
if (kind === 'waiter') {
// A white bow tie under the chin.
for (const side of [-1, 1]) {
const wing = new Mesh(new BoxGeometry(0.09, 0.06, 0.02), white);
wing.position.set(side * 0.045, 0.765, -0.04);
wing.rotation.z = side * 0.35;
this.group.add(wing);
}
const knot = new Mesh(new SphereGeometry(0.018, 8, 6), white);
knot.position.set(0, 0.765, -0.05);
this.group.add(knot);
} else {
// A white hoodie: a chunky body and a hood behind the head.
const hoodie = new Mesh(new BoxGeometry(0.2, 0.3, 0.12), white);
hoodie.position.y = 0.63;
const hood = new Mesh(
new SphereGeometry(0.12, 16, 12, 0, Math.PI, 0, Math.PI * 0.75),
new MeshLambertMaterial({ color: HOODIE, side: DoubleSide }),
);
hood.position.set(0, 0.885, 0.01);
this.group.add(hoodie, hood);
}
// Legs and arms hang from pivots at the hips and shoulders.
for (const side of [-1, 1]) {
const hip = new Group();
hip.position.set(side * 0.05, 0.45, 0);
const leg = new Mesh(limb, ink);
leg.scale.y = 0.45;
leg.position.y = -0.225;
hip.add(leg);
this.legs.push(hip);
const shoulder = new Group();
shoulder.position.set(side * (kind === 'dasher' ? 0.12 : 0.04), 0.76, 0);
shoulder.rotation.z = side * 0.25;
const arm = new Mesh(limb, ink);
arm.scale.y = 0.36;
arm.position.y = -0.18;
shoulder.add(arm);
if (kind === 'dasher') {
const sleeve = new Mesh(new CylinderGeometry(0.05, 0.05, 0.18, 8), white);
sleeve.position.y = -0.08;
shoulder.add(sleeve);
}
this.arms.push(shoulder);
this.group.add(hip, shoulder);
}
}
place(view: GameView, m: Mover): void {
const pos = view.posOf(m);
const walking = m.t < 1 && (m.x !== m.fromX || m.y !== m.fromY);
const swing = walking ? Math.sin(m.t * Math.PI * 2) * 0.6 : 0;
this.group.position.set(pos.x, walking ? Math.abs(Math.sin(m.t * Math.PI * 2)) * 0.03 : 0, pos.y);
this.yaw = turnToward(this.yaw, YAW[m.facing], 0.25);
this.group.rotation.y = this.yaw;
this.legs[0].rotation.x = swing;
this.legs[1].rotation.x = -swing;
this.arms[0].rotation.x = -swing;
this.arms[1].rotation.x = swing;
}
}
export class Renderer3D {
private renderer: WebGLRenderer;
private scene = new Scene();
private camera = new PerspectiveCamera(70, 1, 0.05, 40);
private pitch = 0;
private yawReady = false;
private plates = new Map<string, Sprite>();
private flames: Mesh[] = [];
/** Each table's candle, hidden while there's food on it. */
private candles = new Map<string, Group>();
private lightTiles: Mesh[] = [];
private lightGeometry = new PlaneGeometry(1, 1);
private seenMaterial = new MeshBasicMaterial({ color: '#ffd640', transparent: true, opacity: 0.35, depthWrite: false });
private chaseMaterial = new MeshBasicMaterial({ color: '#ff3c3c', transparent: true, opacity: 0.35, depthWrite: false });
private waiters: Stickman[] = [];
private dashers: Stickman[] = [];
private doorMaterial: MeshLambertMaterial;
private doorLocked: CanvasTexture;
private doorOpen: CanvasTexture;
private doorShowsOpen = false;
private sign: MeshBasicMaterial | null = null;
/** Everything in the room that gets rebuilt when a new game makes a new room. */
private room = new Group();
private builtMap: readonly string[] | null = null;
constructor(canvas: HTMLCanvasElement) {
this.renderer = new WebGLRenderer({ canvas, antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.scene.fog = new Fog('#2a1810', 6, 16);
this.renderer.setClearColor('#2a1810');
const sun = new DirectionalLight('#fff1d6', 1.2);
sun.position.set(3, 8, 4);
this.scene.add(new HemisphereLight('#fff4e0', '#6a4530', 2.2), sun);
this.doorLocked = this.paintDoor(false);
this.doorOpen = this.paintDoor(true);
this.doorMaterial = new MeshLambertMaterial({ map: this.doorLocked });
this.scene.add(this.room);
}
/** The picture's size, in the same units the belly bar uses. */
private viewW = VIEW_W;
private viewH = VIEW_H;
resize(cssWidth: number, cssHeight: number, viewW: number, viewH: number): void {
this.viewW = viewW;
this.viewH = viewH;
this.renderer.setSize(cssWidth, cssHeight, false);
this.camera.aspect = cssWidth / Math.max(1, cssHeight);
this.camera.updateProjectionMatrix();
}
render(view: GameView): void {
if (view.map !== this.builtMap) this.rebuildRoom(view.map);
this.syncFood(view);
this.syncPeople(view);
this.syncLight(view);
if (view.doorOpen !== this.doorShowsOpen) {
this.doorShowsOpen = view.doorOpen;
this.doorMaterial.map = view.doorOpen ? this.doorOpen : this.doorLocked;
this.doorMaterial.emissive.set(view.doorOpen ? '#1a6a3a' : '#000000');
this.doorMaterial.needsUpdate = true;
}
this.flames.forEach((flame, i) => flame.scale.setScalar(0.8 + Math.sin(view.clock * 12 + i * 2) * 0.25));
if (this.sign) this.sign.opacity = 0.85 + Math.sin(view.clock * 3) * 0.15;
// Look through your own eyes, with a little bounce as you walk.
const pos = view.posOf(view.player);
if (!this.yawReady) {
this.pitch = 0;
this.yawReady = true;
}
const bob = Math.abs(Math.sin(view.stride * 3)) * 0.03;
this.camera.position.set(pos.x, EYES + bob, pos.y);
this.camera.rotation.set(this.pitch, view.angle, 0, 'YXZ');
this.renderer.render(this.scene, this.camera);
}
/** Snap the view to wherever you're facing (used when a new game starts). */
snapView(): void {
this.yawReady = false;
}
/** Look up and down with the mouse (or a finger). */
pitchBy(dy: number): void {
this.pitch = Math.max(-0.7, Math.min(0.7, this.pitch - dy));
}
/** Where a spot in the diner shows up on the picture, in the same units the belly bar uses. */
project: Project = (x, height, y) => {
const point = new Vector3(x, height, y);
if (point.clone().applyMatrix4(this.camera.matrixWorldInverse).z > -0.1) return null;
point.project(this.camera);
return { x: ((point.x + 1) / 2) * this.viewW, y: HUD_H + ((1 - point.y) / 2) * (this.viewH - HUD_H) };
};
// ---------- building the diner ----------
/** Clear away the old room (and its food) and build the new one. */
private rebuildRoom(map: readonly string[]): void {
const keep = new Set<unknown>([this.doorMaterial, this.doorLocked, this.doorOpen]);
this.room.traverse((part) => {
if (!(part instanceof Mesh)) return;
part.geometry.dispose();
for (const material of [part.material].flat() as MeshLambertMaterial[]) {
if (keep.has(material)) continue;
if (material.map && !keep.has(material.map)) material.map.dispose();
material.dispose();
}
});
this.room.clear();
for (const sprite of this.plates.values()) {
this.scene.remove(sprite);
sprite.material.dispose();
}
this.plates.clear();
this.flames = [];
this.candles.clear();
this.builtMap = map;
this.buildRoom();
}
private buildRoom(): void {
const wallpaper = paint(64, 128, (ctx) => {
ctx.fillStyle = '#7d2f3a';
ctx.fillRect(0, 0, 64, 128);
ctx.fillStyle = '#8f3a45';
for (let i = 0; i < 64; i += 16) ctx.fillRect(i, 0, 8, 128);
// Wooden panels along the bottom.
ctx.fillStyle = '#4a2a1a';
ctx.fillRect(0, 92, 64, 36);
ctx.fillStyle = '#5c3622';
ctx.fillRect(4, 98, 56, 26);
ctx.fillStyle = '#e9c46a';
ctx.fillRect(0, 90, 64, 3);
});
const wallMaterial = new MeshLambertMaterial({ map: wallpaper });
const wallGeometry = new BoxGeometry(1, WALL_H, 1);
const floorTexture = paint(64, 64, (ctx) => {
for (let row = 0; row < 4; row++) {
ctx.fillStyle = row % 2 === 0 ? '#b5804f' : '#a97447';
ctx.fillRect(0, row * 16, 64, 16);
ctx.fillStyle = 'rgba(60, 30, 10, 0.4)';
ctx.fillRect(0, row * 16 + 15, 64, 1);
ctx.fillRect(((row * 23) % 48) + 8, row * 16, 1, 16);
}
});
floorTexture.wrapS = floorTexture.wrapT = RepeatWrapping;
floorTexture.repeat.set(COLS, ROWS);
const floor = new Mesh(new PlaneGeometry(COLS, ROWS), new MeshLambertMaterial({ map: floorTexture }));
floor.rotation.x = -Math.PI / 2;
floor.position.set(COLS / 2 - 0.5, 0, ROWS / 2 - 0.5);
const ceiling = new Mesh(new PlaneGeometry(COLS, ROWS), new MeshLambertMaterial({ color: '#5a3a30', side: DoubleSide }));
ceiling.rotation.x = Math.PI / 2;
ceiling.position.set(COLS / 2 - 0.5, WALL_H, ROWS / 2 - 0.5);
this.room.add(floor, ceiling);
// Round hanging lamps over the room.
const lampGeometry = new SphereGeometry(0.07, 12, 8);
const lampMaterial = new MeshBasicMaterial({ color: '#ffe08a' });
for (let y = 2; y < ROWS - 1; y += 3) {
for (let x = 2; x < COLS - 1; x += 4) {
const lamp = new Mesh(lampGeometry, lampMaterial);
lamp.position.set(x + 0.5, WALL_H - 0.06, y + 0.5);
this.room.add(lamp);
}
}
const kitchenTops = [0, 1, 2, 3].map((kind) => this.paintKitchenTop(kind));
const steel = new MeshLambertMaterial({ color: '#b8c4cc' });
const tableParts = this.makeTableParts();
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
const c = tileAt(x, y);
if (c === '#') {
const wall = new Mesh(wallGeometry, wallMaterial);
wall.position.set(x, WALL_H / 2, y);
this.room.add(wall);
} else if (c === 'K') {
const top = new MeshLambertMaterial({ map: kitchenTops[x % 4] });
// Box sides: steel, with the cooking stuff painted on top.
const counter = new Mesh(new BoxGeometry(1, 0.55, 1), [steel, steel, top, steel, steel, steel]);
counter.position.set(x, 0.275, y);
this.room.add(counter);
} else if (c === 'T' || c === 'F') {
this.room.add(this.makeTable(x, y, tableParts));
} else if (c === 'D') {
// The front door at the back of a little doorway.
const door = new Mesh(new PlaneGeometry(1, 1.3), this.doorMaterial);
door.position.set(x, 0.65, y + 0.49);
door.rotation.y = Math.PI;
this.room.add(door);
for (const side of [-1, 1]) {
const post = new Mesh(new BoxGeometry(0.02, WALL_H, 1), wallMaterial);
post.position.set(x + side * 0.5, WALL_H / 2, y);
this.room.add(post);
}
const above = new Mesh(new BoxGeometry(1, WALL_H - 1.3, 1), wallMaterial);
above.position.set(x, 1.3 + (WALL_H - 1.3) / 2, y);
this.room.add(above);
}
}
}
this.decorateWalls();
}
/** Windows, pictures, wall lamps and the glowing DINER sign. */
private decorateWalls(): void {
const windowTexture = paint(64, 64, (ctx) => {
ctx.fillStyle = '#9fd3f0';
ctx.fillRect(8, 6, 48, 50);
ctx.fillStyle = '#ffffff';
ctx.fillRect(30, 6, 4, 50);
ctx.fillRect(8, 29, 48, 4);
ctx.fillStyle = '#e9c46a';
ctx.fillRect(0, 0, 12, 64);
ctx.fillRect(52, 0, 12, 64);
});
const pictureTexture = paint(64, 48, (ctx) => {
ctx.fillStyle = '#d4a548';
ctx.fillRect(0, 0, 64, 48);
ctx.fillStyle = '#5fa8d3';
ctx.fillRect(6, 6, 52, 36);
ctx.fillStyle = '#4caf50';
ctx.fillRect(6, 28, 52, 14);
ctx.fillStyle = '#ffd640';
ctx.beginPath();
ctx.arc(46, 16, 6, 0, Math.PI * 2);
ctx.fill();
});
const signTexture = paint(256, 64, (ctx) => {
ctx.fillStyle = '#2a1420';
ctx.beginPath();
ctx.roundRect(0, 0, 256, 64, 12);
ctx.fill();
ctx.font = 'bold 44px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = '#ff5a8a';
ctx.shadowBlur = 14;
ctx.fillStyle = '#ffc8dc';
ctx.fillText('DINER', 128, 34);
});
const windowMaterial = new MeshLambertMaterial({ map: windowTexture, emissive: '#30506a' });
const pictureMaterial = new MeshLambertMaterial({ map: pictureTexture });
const lampMaterial = new MeshBasicMaterial({ color: '#ffe08a' });
this.sign = new MeshBasicMaterial({ map: signTexture, transparent: true });
const mid = Math.floor(COLS / 2);
const sign = new Mesh(new PlaneGeometry(1.8, 0.45), this.sign);
sign.position.set(mid, 1.2, 0.51);
this.room.add(sign);
for (let x = 1; x < COLS - 1; x++) {
if (Math.abs(x - mid) <= 1) continue;
// Back wall (above the kitchen) and the front wall by the door.
const back = new Mesh(new PlaneGeometry(0.6, x % 3 === 0 ? 0.6 : 0.45), x % 3 === 0 ? windowMaterial : pictureMaterial);
back.position.set(x, 1.15, 0.51);
if (x % 3 !== 2) this.room.add(back);
if (x % 3 === 0 && Math.abs(x - mid) > 1) {
const front = new Mesh(new PlaneGeometry(0.6, 0.6), windowMaterial);
front.position.set(x, 1.0, ROWS - 1.51);
front.rotation.y = Math.PI;
this.room.add(front);
}
}
for (let y = 2; y < ROWS - 1; y++) {
for (const [x, turn] of [[0.51, Math.PI / 2], [COLS - 1.51, -Math.PI / 2]] as const) {
if (y % 3 === 0) {
const lamp = new Mesh(new SphereGeometry(0.07, 10, 8), lampMaterial);
lamp.position.set(x, 1.05, y);
this.room.add(lamp);
} else if (y % 3 === 1) {
const picture = new Mesh(new PlaneGeometry(0.5, 0.38), pictureMaterial);
picture.position.set(x, 1.05, y);
picture.rotation.y = turn;
this.room.add(picture);
}
}
}
}
private makeTableParts() {
return {
cloth: new Mesh(new CylinderGeometry(0.36, 0.38, 0.22, 20), new MeshLambertMaterial({ color: '#c8303c' })),
top: new Mesh(new CylinderGeometry(0.34, 0.34, 0.02, 20), new MeshLambertMaterial({ color: '#fff8ee' })),
leg: new Mesh(new CylinderGeometry(0.04, 0.06, 0.25, 8), new MeshLambertMaterial({ color: '#4a2a1a' })),
wood: new MeshLambertMaterial({ color: '#5a3420' }),
};
}
/** A round table with a red tablecloth and a chair on each side. */
private makeTable(x: number, y: number, parts: ReturnType<Renderer3D['makeTableParts']>): Group {
const table = new Group();
table.position.set(x, 0, y);
const cloth = parts.cloth.clone();
cloth.position.y = 0.34;
const top = parts.top.clone();
top.position.y = 0.46;
const leg = parts.leg.clone();
leg.position.y = 0.12;
table.add(cloth, top, leg);
for (const side of [-1, 1]) {
const chair = new Group();
chair.position.z = side * 0.42;
const seat = new Mesh(new BoxGeometry(0.28, 0.04, 0.2), parts.wood);
seat.position.y = 0.28;
const back = new Mesh(new BoxGeometry(0.28, 0.32, 0.03), parts.wood);
back.position.set(0, 0.44, side * 0.1);
chair.add(seat, back);
for (const [lx, lz] of [[-0.11, -0.08], [0.11, -0.08], [-0.11, 0.08], [0.11, 0.08]]) {
const chairLeg = new Mesh(new BoxGeometry(0.03, 0.28, 0.03), parts.wood);
chairLeg.position.set(lx, 0.14, lz);
chair.add(chairLeg);
}
table.add(chair);
}
{
// Tables without food just have a little candle.
const candle = new Mesh(new CylinderGeometry(0.025, 0.025, 0.1, 8), new MeshLambertMaterial({ color: '#f2e6c9' }));
candle.position.y = 0.52;
const flame = new Mesh(new SphereGeometry(0.025, 8, 6), new MeshBasicMaterial({ color: '#ffb43c' }));
flame.position.y = 0.6;
this.flames.push(flame);
const light = new Group();
light.add(candle, flame);
this.candles.set(`${x},${y}`, light);
table.add(light);
}
return table;
}
private paintKitchenTop(kind: number): CanvasTexture {
return paint(64, 64, (ctx) => {
ctx.fillStyle = '#d9dee2';
ctx.fillRect(0, 0, 64, 64);
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
ctx.fillRect(4, 4, 56, 3);
switch (kind) {
case 1:
// Stove with a glowing burner and a pot.
ctx.fillStyle = '#2a2a2a';
ctx.fillRect(8, 8, 48, 48);
ctx.fillStyle = '#ff5a28';
ctx.beginPath();
ctx.arc(32, 32, 17, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#9aa5ad';
ctx.beginPath();
ctx.arc(32, 32, 12, 0, Math.PI * 2);
ctx.fill();
break;
case 2:
// Stack of clean plates.
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(32, 32, 18, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#c8d0d6';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(32, 32, 12, 0, Math.PI * 2);
ctx.stroke();
break;
case 3:
// Frying pan with an egg.
ctx.fillStyle = '#333333';
ctx.fillRect(36, 30, 24, 6);
ctx.beginPath();
ctx.arc(26, 32, 16, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(26, 32, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#f4d35e';
ctx.beginPath();
ctx.arc(26, 32, 4, 0, Math.PI * 2);
ctx.fill();
break;
default:
// Cutting board with a knife.
ctx.fillStyle = '#c9925a';
ctx.fillRect(14, 18, 36, 28);
ctx.fillStyle = '#e8eef2';
ctx.fillRect(20, 30, 24, 4);
}
});
}
private paintDoor(open: boolean): CanvasTexture {
return paint(64, 84, (ctx) => {
ctx.fillStyle = '#4a2a1a';
ctx.fillRect(0, 0, 64, 84);
ctx.fillStyle = open ? '#3ad17a' : '#8a5a34';
ctx.fillRect(4, 4, 27, 80);
ctx.fillRect(33, 4, 27, 80);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (open) {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 16px system-ui, sans-serif';
ctx.fillText('EXIT', 32, 30);
} else {
ctx.font = `20px ${EMOJI_FONT}`;
ctx.fillText('🔒', 32, 36);
}
});
}
// ---------- things that change while you play ----------
private syncFood(view: GameView): void {
for (const [key, candle] of this.candles) candle.visible = !view.food.has(key);
for (const [key, sprite] of this.plates) {
if (!view.food.has(key)) {
this.scene.remove(sprite);
this.plates.delete(key);
}
}
for (const [key, dish] of view.food) {
let sprite = this.plates.get(key);
if (!sprite) {
sprite = new Sprite(new SpriteMaterial({ map: this.paintDish(dish) }));
sprite.scale.setScalar(0.36);
const [x, y] = key.split(',').map(Number);
sprite.position.set(x, 0.62, y);
this.scene.add(sprite);
this.plates.set(key, sprite);
}
// Food bobs a little so you can spot it.
sprite.position.y = 0.62 + Math.sin(view.clock * 4 + sprite.position.x) * 0.02;
}
}
private dishTextures = new Map<string, CanvasTexture>();
private paintDish(dish: string): CanvasTexture {
let texture = this.dishTextures.get(dish);
if (!texture) {
texture = paint(64, 64, (ctx) => {
ctx.font = `52px ${EMOJI_FONT}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(dish, 32, 36);
});
this.dishTextures.set(dish, texture);
}
return texture;
}
private syncPeople(view: GameView): void {
while (this.waiters.length < view.waiters.length) this.addPerson(this.waiters, 'waiter');
while (this.dashers.length < view.dashers.length) this.addPerson(this.dashers, 'dasher');
this.waiters.forEach((man, i) => {
const w = view.waiters[i];
man.group.visible = !!w;
if (w) man.place(view, w);
});
this.dashers.forEach((man, i) => {
const d = view.dashers[i];
// Caught dashers are off washing dishes.
man.group.visible = !!d && !d.caught;
if (d) man.place(view, d);
});
}
private addPerson(list: Stickman[], kind: 'waiter' | 'dasher'): void {
const man = new Stickman(kind);
list.push(man);
this.scene.add(man.group);
}
/** The waiters' yellow light (red when chasing) on the floor. */
private syncLight(view: GameView): void {
let used = 0;
for (const w of view.waiters) {
const material: Material = w.mode === 'chase' ? this.chaseMaterial : this.seenMaterial;
for (const t of view.visionOf(w)) {
let tile = this.lightTiles[used];
if (!tile) {
tile = new Mesh(this.lightGeometry, material);
tile.rotation.x = -Math.PI / 2;
tile.renderOrder = 1;
this.lightTiles.push(tile);
this.scene.add(tile);
}
tile.material = material;
tile.position.set(t.x, 0.01 + used * 0.0001, t.y);
tile.visible = true;
used++;
}
}
for (let i = used; i < this.lightTiles.length; i++) this.lightTiles[i].visible = false;
}
}
