**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`.
649 lines
22 KiB
JavaScript
649 lines
22 KiB
JavaScript
// 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,
|
||
};
|
||
}
|