// Undercooked — the canvas renderer (phase 07, task 04). // // `drawFrame(view, nowMs)` draws one render-ready state (the loop's // `lerpState` output, docs/CLIENT.md "The snapshot buffer and the // render loop") in a fixed layer order — `LAYERS`, in draw order: // // floor → floor decals (spills) → station bases → carried-under // items (the content a station holds) → players (y-sorted) → carried // overlays (the held item) → station progress rings → hazards → // particles (the FX) → selection / target highlight. // // Sprite selection maps the wire's vocabulary onto atlas ids: // `stationSpriteId(kind, state)` (the stove has a sprite per state — // `STATION_STATE_VARIANT`; the other fixtures are state-blind), // `itemSpriteId(kind, form)` (the `REACHABLE_ITEM_FORMS` table — // `tests/unit/test_client_sprite_map.py` keeps it equal to the sim's // `reachable_item_forms()`, so a pair the simulation cannot produce // falls back to the kind's `raw` sprite instead of a blank), // `cookSpriteId(seat, facing, frame)` (4 bodies × 4 facings × 2 walk // frames) and `tileSpriteId(kind, x, y)` (the floor's 4 texture // variants, chosen by a deterministic cell hash). An atlas id that // throws (an unknown id, a sprite with no pixels) is logged **once** // (console, naming the id) and drawn as a plain fallback rect — // never silently blank, never spammed. // // Animation is fixed-step: the walk cycle is `floor(now / 125) % 2` // (8 steps/s, a 120 Hz display does not animate faster), the stun spin // and the hazard pulses step the same way, and every clock is derived // from the loop's `now` — the loop itself pauses on `document.hidden` // (`render/loop.js`), so the sprites pause with it; a large `now` jump // (a resume) cannot jump the animation, only land on the next step. // // The kitchen's floor is a client-side approximation: the grid never // leaves the server (PLAN §8), so the floor is the stations' + the // players' bounding box plus a one-tile margin, with the wall as the // rectangle's edge. The target highlight is the same kind of // approximation: `candidateCells(x, y, facing)` mirrors // `sim/interact.py`'s `candidate_cells` (the front tile, the two // diagonal-front, the tile under the cook) so reaching for a station // reads the way the server will resolve it. // // `renderStats()` — the frame census the e2e perf assertions read off // `window.__uc`: `drawCalls` (this frame), `frameMs` (moving average // of the measured draw time), `frameMsP95` (over every frame since the // renderer was created), `frames`, `warnedSprites`. import * as atlas from "./atlas.js"; import { facingName, splitItemShort } from "../net/decode.js"; /** The layer order (the task's list, frozen — docs/CLIENT.md's table). */ export const LAYERS = Object.freeze([ "floor", "floorDecals", "stationBases", "itemsUnder", "players", "carryOverlays", "progressRings", "hazards", "particles", "targetHighlight", ]); /** The walk cycle's step (ms per half-cycle: 8 steps/s, fixed-step). */ export const WALK_STEP_MS = 125; /** One sprite of the stove per state (`burning` is a hazard, drawn in * the hazard layer — a `fire` on the stove's cell switches the base). */ export const STATION_STATE_VARIANT = Object.freeze({ idle: "off", working: "working", busy: "working", ready: "ready", dirty: "off", blocked: "off", }); /** * The (kind, form) pairs the simulation can produce — the client's copy * of `undercooked.tools.gen_art_index.reachable_item_forms()`. * `tests/unit/test_client_sprite_map.py` keeps it equal to the sim's * fixpoint walk (a sim change that orphans a pair is a build * failure, naming the pair). An unreachable pair maps to the kind's * `raw` sprite (every kind has one): a protocol bug then draws a * *something*, and the atlas-throw path stays reserved for art that * is genuinely missing. */ export const REACHABLE_ITEM_FORMS = Object.freeze({ tomato: ["raw", "chopped", "cooking", "cooked", "burnt"], onion: ["raw", "chopped", "cooking", "cooked", "burnt"], lettuce: ["raw", "chopped", "cooking", "cooked", "burnt"], dough: ["raw", "chopped", "cooking", "cooked", "burnt"], patty: ["raw", "chopped", "cooking", "cooked", "burnt"], fish: ["raw", "chopped", "cooking", "cooked", "burnt"], rice: ["raw", "cooking", "cooked", "burnt"], plate: ["raw", "dirty", "plated"], }); /** One station's atlas id (the stove's state, the others state-blind). */ export function stationSpriteId(kind, state) { if (kind === "stove") { const variant = STATION_STATE_VARIANT[state] ?? "off"; return `station-stove-${variant}`; } return `station-${kind}`; } /** * One (kind, form) pair's atlas id. A reachable pair is direct * (`item-tomato-chopped`); an unreachable one (the sim cannot produce * it — `rice_chopped`, a non-plate `plated`) falls back to the kind's * `raw` sprite. */ export function itemSpriteId(kind, form) { const forms = REACHABLE_ITEM_FORMS[kind]; if (forms !== undefined && forms.includes(form)) { return `item-${kind}-${form}`; } return `item-${kind}-raw`; } /** One cook's atlas id (seat 0-3, facing index 0-3, frame 0-1). */ export function cookSpriteId(seat, facing, frame) { const name = facingName(facing) ?? "down"; const body = (Math.max(0, Math.trunc(seat)) % 4) + 1; const step = Number.isInteger(frame) ? Math.abs(frame) % 2 : 0; return `cook-${body}-${name}-${step}`; } /** The floor's variant for a cell (deterministic: ~1/3 are textured). */ export function tileSpriteId(kind, x, y) { if (kind !== "floor") return `tile-${kind}`; const n = (Math.floor(x) * 3 + Math.floor(y) * 5) % 5; return n < 2 ? `tile-floor-${n + 1}` : "tile-floor"; } /** The stove's cookware for a kind (the sim's `STOVE_RAW_SECONDS` half: * the pan at 5 s for patty/fish, the pot for the rest, rice included). */ export function cookwareSpriteId(kind) { return kind === "patty" || kind === "fish" ? "station-pan" : "station-pot"; } /** * The reach zone per facing (the client-side mirror of * `sim/interact.py`'s `candidate_cells`: the front tile, the two * diagonal-front tiles, the tile under the cook — in priority order, * deduplicated). The facing is the wire's *name* (`"up"` / …). */ const CANDIDATE_OFFSETS = Object.freeze({ up: [ [0, -1], [-1, -1], [1, -1], [0, 0], ], right: [ [1, 0], [1, -1], [1, 1], [0, 0], ], down: [ [0, 1], [1, 1], [-1, 1], [0, 0], ], left: [ [-1, 0], [-1, 1], [-1, -1], [0, 0], ], }); /** * The cells the reaching hand walks, in priority order — the * `interact.candidate_cells` mirror. `(x, y)` is the cook's continuous * position; the cells are the (possibly out-of-bounds) integer pairs. */ export function candidateCells(x, y, facing) { const base = [Math.floor(x), Math.floor(y)]; const offsets = CANDIDATE_OFFSETS[facing] ?? CANDIDATE_OFFSETS.down; const cells = []; const seen = new Set(); for (const [dx, dy] of offsets) { const key = `${base[0] + dx},${base[1] + dy}`; if (!seen.has(key)) { seen.add(key); cells.push([base[0] + dx, base[1] + dy]); } } return cells; } const FLOOR_MARGIN = 1; // the bounding box's padding (tiles) const FALLBACK_WORLD = { minX: 0, minY: 0, w: 12, h: 8 }; /** The kitchen's bounds: the stations' + players' cells, padded. */ function kitchenBounds(view) { const cells = []; for (const entry of view.st ?? []) { if (Number.isFinite(entry.x) && Number.isFinite(entry.y)) { cells.push([Math.trunc(entry.x), Math.trunc(entry.y)]); } } for (const entry of view.p ?? []) { if (Number.isFinite(entry.x) && Number.isFinite(entry.y)) { cells.push([Math.floor(entry.x), Math.floor(entry.y)]); } } if (cells.length === 0) return { ...FALLBACK_WORLD }; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const [cx, cy] of cells) { minX = Math.min(minX, cx); minY = Math.min(minY, cy); maxX = Math.max(maxX, cx); maxY = Math.max(maxY, cy); } minX -= FLOOR_MARGIN; minY -= FLOOR_MARGIN; return { minX, minY, w: maxX - minX + 1 + FLOOR_MARGIN, h: maxY - minY + 1 + FLOOR_MARGIN, }; } /** * One renderer. `ensureReady()` awaits the atlas once (a failed load * throws — the composition surfaces it on the error path). * `drawFrame(view, nowMs)` draws the frame (and counts the draw * calls). `renderStats()` is the census. `dispose()` drops the * canvas (a new round mounts a fresh play screen — the composition * rebuilds the renderer). */ export function createRenderer({ canvas = null, camera = null, fx = null, ownSeat = null, reducedMotion = () => false, now = null, } = {}) { const ctx = canvas !== null ? canvas.getContext("2d") : null; const warned = new Set(); const frameTimes = []; // every measured draw time (ms), since creation const frameAvgWindow = 30; let drawCalls = 0; let frames = 0; let lastNow = null; let readyPromise = null; let ready = false; let boundsCache = null; // the kitchen's absolute corners, grow-only per runtime /** The reduced-motion half (the fx.js rule, mirrored): the canvas's * `data-motion` attribute first (the composition sets it from * `?motion=0`), a forced override, then the media query. */ function reducedNow() { if (canvas !== null && canvas.dataset !== null) { if (canvas.dataset.motion === "reduced") return true; } if (typeof reducedMotion === "function") { const forced = reducedMotion(); if (forced === true) return true; if (forced === false) return false; } return typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false; } /** Await the atlas once (the first draw's one-time cost). */ function ensureReady() { if (ready) return Promise.resolve(); if (readyPromise === null) { readyPromise = atlas.ready().then(() => { ready = true; return true; }); } return readyPromise; } function statClock() { return typeof now === "function" ? now() : performance.now(); } /** * Draw one sprite (a counted draw call). A throw — an unknown id, a * sprite with no pixels — is logged once (naming the id), suppressed * afterwards (no spam), and drawn as a fallback rect: the frame never * loses a tile to a missing symbol. */ function sprite(id, x, y, w, h, opts = {}) { drawCalls += 1; try { atlas.drawSync(ctx, id, x, y, w, h, opts); } catch (error) { if (!warned.has(id)) { warned.add(id); console.error(`undercooked: sprite ${id} failed (${error.message})`); } ctx.save(); ctx.globalAlpha = (typeof opts.alpha === "number" ? opts.alpha : 1) * 0.55; ctx.fillStyle = "#857259"; ctx.fillRect(x, y, w, h); ctx.restore(); } } function stepFrame(nowMs) { // The fixed-step walk frame (8 steps/s — a 120 Hz display lands on // the same frame sequence a 60 Hz one does). return Math.floor(nowMs / WALK_STEP_MS) % 2; } function stepIndex(nowMs, divisorMs) { return Math.floor(nowMs / divisorMs); } /** Draw one player (the layer 5 body + overlays are drawn here). */ function drawPlayer(p, nowMs, toLocal, shake, s) { const [lx, ly] = toLocal(p.x, p.y); const cx = lx + (shake !== null ? shake.dx : 0) * s; const cy = ly + (shake !== null ? shake.dy : 0) * s; const body = s * 0.95; const walk = p.s === "moving" ? stepFrame(nowMs) : 0; sprite(cookSpriteId(p.pi ?? 0, p.f ?? 2, walk), cx - body / 2, cy - body * 0.92, body, body); if (p.s === "disconnected") { sprite("ui-ghost", cx - s * 0.3, cy - s * 1.05, s * 0.6, s * 0.6, { alpha: 0.85 }); } if (p.s === "stunned") { const spin = reducedNow() ? 0 : (stepIndex(nowMs, WALK_STEP_MS) % 4) * Math.PI * 0.5; ctx.save(); ctx.translate(cx, cy - s * 0.5); ctx.rotate(spin); sprite("cook-stun", -s * 0.42, -s * 0.42, s * 0.84, s * 0.84, { alpha: 0.9 }); ctx.restore(); } // The carried item (layer 6) with its overlay: the held item above // the head, the carry layer at the hand. if (p.h !== null && p.h !== undefined && p.h !== "") { const item = splitItemShort(p.h); if (item !== null) { sprite("cook-carry", cx - s * 0.3, cy - s * 0.72, s * 0.6, s * 0.6, { alpha: 0.95 }); sprite( itemSpriteId(item.kind, item.form), cx - s * 0.25, cy - s * 1.32, s * 0.5, s * 0.5, ); } } } /** * One frame. `view` is the `lerpState` output (the entity lists * already interpolated); `nowMs` the loop's clock (the animation's * only time source — the loop's pause on `document.hidden` is the * pause, and a resume's delta is dropped, not measured). */ function drawFrame(view, nowMs) { if (ctx === null) return; const t0 = statClock(); lastNow = nowMs; drawCalls = 0; frames += 1; const bounds = growBounds(kitchenBounds(view)); // The size reconciliation: the canvas can grow after the fit (the // `hidden` un-hide, a window resize the camera's listeners miss on // a canvas that was detached) — re-read the CSS size only when it // changed, so the backing store and the fit track the element. if (camera !== null && canvas !== null && canvas.isConnected) { const css = camera.stats(); if (canvas.clientWidth !== css.cssW || canvas.clientHeight !== css.cssH) { camera.resize(); } } if (camera !== null) camera.setWorld(bounds.w, bounds.h); const stats = camera !== null ? camera.stats() : { scale: 32, offX: 0, offY: 0, cssW: 1024, cssH: 640, dpr: 1 }; const s = stats.scale; const toLocal = (x, y) => { const p = camera !== null ? camera.worldToScreen(x - bounds.minX, y - bounds.minY) : { x: 0, y: 0 }; return [p.x, p.y]; }; // The backing store's transform (DPR-aware; the math stays in CSS px). const dpr = stats.dpr || 1; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, stats.cssW, stats.cssH); drawCalls += 1; // 1 — the floor (the approximated kitchen, its edge the wall). for (let cy = 0; cy < bounds.h; cy += 1) { for (let cx = 0; cx < bounds.w; cx += 1) { const [lx, ly] = toLocal(cx, cy); sprite(tileSpriteId("floor", cx + bounds.minX, cy + bounds.minY), lx, ly, s, s); } } const [ex, ey] = toLocal(0, 0); ctx.save(); ctx.strokeStyle = "#4a4036"; ctx.lineWidth = Math.max(2, s * 0.08); ctx.globalAlpha = 0.9; ctx.strokeRect(ex, ey, bounds.w * s, bounds.h * s); ctx.restore(); drawCalls += 1; // 2 — floor decals (the spills: a splashed stain under everything). for (const hz of view.hz ?? []) { if (hz.t !== "spill") continue; const [lx, ly] = toLocal(hz.x, hz.y); sprite("fx-splash", lx + s * 0.05, ly + s * 0.2, s * 0.9, s * 0.55, { alpha: 0.5 }); } // 3 — the station bases (a fire on a stove's cell is the burning // variant — the hazard's visual half; the flame itself is layer 8). const fireCells = new Set( (view.hz ?? []).filter((hz) => hz.t === "fire").map((hz) => `${Math.trunc(hz.x)},${Math.trunc(hz.y)}`), ); for (const st of view.st ?? []) { const [lx, ly] = toLocal(st.x, st.y); let id = stationSpriteId(st.k, st.s); if (st.k === "stove" && fireCells.has(`${Math.trunc(st.x)},${Math.trunc(st.y)}`)) { id = "station-stove-burning"; } sprite(id, lx + s * 0.05, ly + s * 0.1, s * 0.9, s * 0.9); } // 4 — the carried-under items (a station's content, on the fixture). for (const st of view.st ?? []) { if (st.g === null || st.g === undefined || st.g === "") continue; const item = splitItemShort(st.g); if (item === null) continue; const [lx, ly] = toLocal(st.x, st.y); if (st.k === "stove") { sprite(cookwareSpriteId(item.kind), lx + s * 0.18, ly + s * 0.3, s * 0.64, s * 0.64); } sprite( itemSpriteId(item.kind, item.form), lx + s * 0.28, ly + s * 0.22, s * 0.44, s * 0.44, ); } // 5 — the players, y-sorted (a deeper cook draws over a shallower). const players = [...(view.p ?? [])].sort((a, b) => a.y - b.y); for (const p of players) { let shake = null; if (fx !== null && p.i !== undefined) shake = fx.shakeOffset(p.i, nowMs); drawPlayer(p, nowMs, toLocal, shake, s); } // 7 — the progress rings (a working station's arc; the states' full // rings). Drawn before the hazards (layer 8) so a flame sits over // the ring it is burning. for (const st of view.st ?? []) { const [lx, ly] = toLocal(st.x, st.y); const cx = lx + s * 0.5; const cy = ly + s * 0.5; const r = s * 0.46; let color = null; let fraction = null; const progress = Number.isFinite(st.p) ? st.p : 0; if (st.s === "working" && progress > 0 && progress < 1) { color = "#ffa63d"; fraction = progress; } else if (st.s === "ready") { color = "#67d16c"; fraction = 1; } else if (st.s === "busy") { color = "#ffa63d"; fraction = 1; } else if (st.s === "blocked") { color = "#ff5a4e"; fraction = 1; } else if (st.s === "dirty") { color = "#9a8f7a"; fraction = 1; } if (color !== null) { ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = Math.max(1.5, s * 0.07); ctx.beginPath(); if (fraction === 1) { ctx.arc(cx, cy, r, 0, Math.PI * 2); } else { ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + fraction * Math.PI * 2); } ctx.stroke(); ctx.restore(); drawCalls += 1; } } // 8 — the hazards (fires the animated flame, the moving obstacle a // solid block, the rush a pulsing border — the reduced-motion // fallbacks are static: same markers, no pulse). for (const hz of view.hz ?? []) { const [lx, ly] = toLocal(hz.x, hz.y); if (hz.t === "fire") { const pulse = reducedNow() ? 1 : 1 + 0.12 * Math.sin((stepIndex(nowMs, WALK_STEP_MS) / 4) * Math.PI * 2); sprite("fx-flame", lx + s * 0.12, ly + s * 0.05 * pulse, s * 0.76, s * 0.8 * pulse, { alpha: 0.95 }); } else if (hz.t === "moving_obstacle") { ctx.save(); ctx.globalAlpha = 0.9; ctx.fillStyle = "#5c5248"; ctx.fillRect(lx + s * 0.1, ly + s * 0.1, s * 0.8, s * 0.8); ctx.strokeStyle = "#ff5a4e"; ctx.lineWidth = Math.max(1.5, s * 0.06); ctx.strokeRect(lx + s * 0.1, ly + s * 0.1, s * 0.8, s * 0.8); ctx.restore(); drawCalls += 1; } else if (hz.t === "rush") { const [rx, ry] = toLocal(0, 0); const alpha = reducedNow() ? 0.35 : 0.25 + 0.2 * Math.abs(Math.sin((stepIndex(nowMs, 125) / 2) * Math.PI * 2)); ctx.save(); ctx.strokeStyle = "#ff5a4e"; ctx.globalAlpha = alpha; ctx.lineWidth = Math.max(2, s * 0.1); ctx.strokeRect(rx + s * 0.05, ry + s * 0.05, bounds.w * s - s * 0.1, bounds.h * s - s * 0.1); ctx.restore(); drawCalls += 1; } // a `spill` is a floor decal (layer 2) — no hazard-layer mark. } // 9 — the FX's particles (the event effects; `update` advances the // pool, this draws it). if (fx !== null) fx.draw(ctx, toLocal, s, nowMs); // 10 — the target highlight: the viewer's own cook's reach zone, // the `candidate_cells` mirror, faint (the server's own target, // approximated — reaching reads predictably). const own = typeof ownSeat === "function" ? ownSeat() : ownSeat !== null && ownSeat !== undefined ? ownSeat : null; const me = own !== null ? (view.p ?? []).find((p) => p.pi === own) : undefined; if (me !== undefined) { const facing = facingName(me.f) ?? "down"; ctx.save(); ctx.strokeStyle = "#67d16c"; ctx.globalAlpha = 0.28; ctx.lineWidth = Math.max(1.5, s * 0.06); for (const [cx, cy] of candidateCells(me.x, me.y, facing)) { // The candidate cells are absolute grid cells; `toLocal` wants // bounding-box-local coordinates — subtract the box's origin. const px = camera !== null ? camera.worldToScreen(cx - bounds.minX, cy - bounds.minY) : { x: 0, y: 0 }; ctx.strokeRect(px.x + s * 0.08, px.y + s * 0.08, s * 0.84, s * 0.84); } ctx.restore(); drawCalls += 1; } const dtMeasured = statClock() - t0; frameTimes.push(dtMeasured); return { drawCalls, bounds }; } /** The grow-only bounds cache: a wandering cook can extend the box * (a new cell) but never shrink it — the camera refits only when the * kitchen is genuinely discovered further, not on every sub-cell * crossing (a mid-walk re-snap is the shimmer the task forbids). */ function growBounds(bounds) { const box = { minX: bounds.minX, minY: bounds.minY, maxX: bounds.minX + bounds.w - 1, maxY: bounds.minY + bounds.h - 1, }; if (boundsCache === null) { boundsCache = box; } else { boundsCache.minX = Math.min(boundsCache.minX, box.minX); boundsCache.minY = Math.min(boundsCache.minY, box.minY); boundsCache.maxX = Math.max(boundsCache.maxX, box.maxX); boundsCache.maxY = Math.max(boundsCache.maxY, box.maxY); } return { minX: boundsCache.minX, minY: boundsCache.minY, w: boundsCache.maxX - boundsCache.minX + 1, h: boundsCache.maxY - boundsCache.minY + 1, }; } function dispose() { ready = false; readyPromise = null; boundsCache = null; } /** The frame census (the e2e perf assertions' seam, on `window.__uc`). */ function renderStats() { const recent = frameTimes.slice(-frameAvgWindow); const avg = recent.length === 0 ? 0 : recent.reduce((sum, v) => sum + v, 0) / recent.length; const sorted = [...frameTimes].sort((a, b) => a - b); const p95 = sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.ceil(0.95 * sorted.length) - 1)]; return { drawCalls, frames, frameMs: avg, frameMsP95: p95, frameMsMax: sorted.length === 0 ? 0 : sorted[sorted.length - 1], warnedSprites: [...warned], }; } return { ensureReady, drawFrame, renderStats, dispose, isReady: () => ready, }; }