Files
undercooked/app/static/js/render/canvas.js
T
ducoterra 95ca3fd1fa phase: 07_canvas_client_and_hud
**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
2026-10-10 18:03:52 -04:00

649 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};
}