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`.
This commit is contained in:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+312
View File
@@ -0,0 +1,312 @@
// Undercooked — the SVG atlas (phase 07, task 01).
//
// Loads `app/static/assets/index.json` (written by
// `undercooked.tools.gen_art_index`) and turns every symbol into an
// `<img>` the renderer can draw. Browsers do not render a
// `file.svg#symbol-id` fragment as an image (the symbol paints
// nothing), and the page CSP (`style-src 'self'`) blocks the
// `style`-attribute colour hooks the art uses — both in the decoded
// image and while `DOMParser` parses the file in the page context —
// so each symbol is decoded the way the engine actually supports: the
// file is fetched as text, the `style` hooks are pulled into neutral
// attributes, the wanted symbol is spliced into a standalone document
// (its `<use>` references expanded to plain geometry, the hooks
// resolved to literal `fill`s), and that document is decoded through a
// `data:` URL — the CSP already allows it (`img-src data:`), and the
// bytes are the app's own.
//
// Contract:
// * `atlas.ready()` resolves when every symbol has decoded; it rejects
// (naming every sprite that failed) when the index or a sprite does
// not load, and a failed load clears the cache so a later `ready()`
// retries from scratch.
// * `atlas.draw(ctx, id, x, y, w, h, opts)` throws an `Error` for an
// unknown id or a sprite that decoded without pixels — it never
// draws nothing silently.
// * `opts.tint` is a CSS colour painted over the non-transparent part
// of the sprite (`source-atop`), `opts.alpha` a 0..1 opacity,
// `opts.flipX` mirrors the sprite in the target rect.
const INDEX_URL = "/static/assets/index.json";
const SVG_BASE = "/static/assets/";
const index = new Map(); // id -> { file, w, h }
const images = new Map(); // id -> HTMLImageElement
const svgTexts = new Map(); // file -> the fetched file text
let readyPromise = null;
/**
* Resolve one `color: var(--uc-*, #hex)` (or literal `color: #hex`)
* style into a hex colour, or `null` when the style has no colour.
* An image document cannot resolve custom properties — the page's
* styles are CSP-blocked there and the decode fails with them — so
* the art's cook-colour hooks (a `style` on the `<use>`, a
* `currentColor` on the shape it pulls in) are resolved here, before
* the document is serialised.
*/
function colourFromStyle(style) {
if (!style) return null;
const varMatch = style.match(
/color\s*:\s*var\(\s*(--[\w-]+)\s*,\s*(#[0-9a-fA-F]{3,8})\s*\)/i,
);
if (varMatch) return varMatch[2];
const literal = style.match(/color\s*:\s*(#[0-9a-fA-F]{3,8})(\s*[;,]|\s*$)/i);
return literal ? literal[1] : null;
}
/**
* Expand every `<use>` in a (cloned) subtree into a clone of its
* target, then paint each `fill="currentColor"` with `color`. The
* expansion happens because the colour hooks ride on a `style`
* attribute the image context forbids; once expanded, the shapes are
* plain geometry and the colour is a plain `fill`.
*/
function paintColour(container, color, document) {
const walk = (node) => {
for (const element of Array.from(node.children)) {
const isUse = element.tagName === "use";
const isLink = element.tagName === "a";
if (isUse || isLink) {
const href = element.getAttribute("href");
const target = href ? document.getElementById(href.slice(1)) : null;
if (target) {
const expansion = target.cloneNode(true);
expansion.removeAttribute("id");
element.replaceWith(expansion);
walk(expansion);
} else {
element.remove();
}
continue;
}
if (element.getAttribute("fill") === "currentColor") {
element.setAttribute("fill", color);
}
walk(element);
}
};
walk(container);
}
/**
* Pull the `style` hooks out of the file text before parsing: a
* `DOMParser` of `image/svg+xml` applies inline `style` attributes in
* the embedding document's context, where the page CSP
* (`style-src 'self'`) blocks the `var()` colours before they can be
* read. The hooks are carried on a neutral attribute instead, in
* document order, so the parsed tree can be walked and each hook's
* colour resolved from the extracted value.
*/
function extractStyleHooks(fileText) {
const hooks = [];
const sanitized = fileText.replace(/\sstyle="([^"]*)"/g, (_match, value) => {
hooks.push(value);
return ` ucstyle="${hooks.length - 1}"`;
});
return { hooks, sanitized };
}
/**
* The standalone document one symbol decodes into: the file's root with
* every other symbol removed and the wanted one replaced by a plain
* `<g>` (a `<symbol>` renders nothing on its own). `<use>` references
* are expanded to plain geometry and the cook-colour `style` hooks are
* resolved to literal fills, because the image context forbids the
* `style` attribute (and any `var()`) the hooks would otherwise need.
* The symbol's `viewBox` (or `width`/`height`) sizes the root's
* viewport, so the geometry lands 1:1.
*/
function buildSvgDocument(fileText, file, id) {
const { hooks, sanitized } = extractStyleHooks(fileText);
const document = new DOMParser().parseFromString(sanitized, "image/svg+xml");
if (document.querySelector("parsererror")) {
throw new Error(`atlas file failed to parse: ${file}`);
}
const root = document.documentElement.cloneNode(true);
const symbol = root.querySelector(`symbol#${CSS.escape(id)}`);
if (!symbol) throw new Error(`atlas symbol not found in ${file}: ${id}`);
for (const other of root.querySelectorAll("symbol")) {
if (other !== symbol) other.remove();
}
const viewBox = symbol.getAttribute("viewBox");
const group = document.createElementNS(document.documentElement.namespaceURI, "g");
while (symbol.firstChild) group.appendChild(symbol.firstChild);
symbol.replaceWith(group);
// The colour hooks: a `ucstyle` on the <use> that pulls in the shape.
for (const use of Array.from(group.children)) {
if (use.tagName !== "use") continue;
const hook = use.getAttribute("ucstyle");
const color =
hook === null ? null : colourFromStyle(hooks[Number(hook)]);
use.removeAttribute("ucstyle");
if (color === null) continue;
const href = use.getAttribute("href");
const target = href ? document.getElementById(href.slice(1)) : null;
if (!target) {
use.remove();
continue;
}
const expansion = target.cloneNode(true);
expansion.removeAttribute("id");
use.replaceWith(expansion);
paintColour(expansion, color, document);
}
if (viewBox) {
root.setAttribute("viewBox", viewBox);
} else {
const width = symbol.getAttribute("width");
const height = symbol.getAttribute("height");
if (width) root.setAttribute("width", width);
if (height) root.setAttribute("height", height);
}
return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(
new XMLSerializer().serializeToString(root),
);
}
function decodeImage(file, id) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`atlas image failed to decode: ${file}#${id}`));
const text = svgTexts.get(file);
if (text === undefined) {
reject(new Error(`atlas has no text for ${file} (id ${id})`));
return;
}
img.src = buildSvgDocument(text, file, id);
dataUrls.set(id, img.src); // the `data:` URL the DOM icons use (dataUrl)
});
}
async function load() {
const response = await fetch(INDEX_URL);
if (!response.ok) {
throw new Error(`atlas index fetch failed: HTTP ${response.status}`);
}
const table = await response.json();
const entries = Object.entries(table);
if (entries.length === 0) throw new Error("atlas index is empty");
const files = [...new Set(entries.map(([, entry]) => entry.file))];
await Promise.all(
files.map(async (file) => {
const fileResponse = await fetch(`${SVG_BASE}${file}`);
if (!fileResponse.ok) {
throw new Error(`atlas fetch failed for ${file}: HTTP ${fileResponse.status}`);
}
svgTexts.set(file, await fileResponse.text());
}),
);
const settled = await Promise.allSettled(
entries.map(async ([id, entry]) => {
images.set(id, await decodeImage(entry.file, id));
}),
);
const failures = settled
.filter((outcome) => outcome.status === "rejected")
.map((outcome) => outcome.reason.message);
if (failures.length > 0) throw new Error(`atlas load failed: ${failures.join("; ")}`);
for (const [id, entry] of entries) index.set(id, entry);
}
/** Resolve when every symbol has decoded; reject naming the failures. */
export function ready() {
if (readyPromise === null) {
readyPromise = load().catch((error) => {
readyPromise = null; // a failed load may be retried from scratch
images.clear();
index.clear();
svgTexts.clear();
dataUrls.clear();
throw error;
});
}
return readyPromise;
}
/** The number of symbols the index names (0 before `ready()` resolves). */
export function size() {
return index.size;
}
/** The `{ file, w, h }` entry for one id, or `null` when unknown. */
export function meta(id) {
return index.get(id) ?? null;
}
/**
* The decoded `data:` URL for one id (or `null` when unknown / not
* ready) — the DOM half of the atlas: the HUD's recipe-ticket and
* streak icons are `<img>` elements on these URLs (the CSP allows
* `img-src data:`; the bytes are the app's own, the same decode the
* canvas uses). `#symbol` fragments never render from a cross-file
* `<img>`, so the `data:` URL is the only same-origin way to put a
* symbol in the DOM.
*/
export function dataUrl(id) {
const entry = index.get(id);
if (entry === undefined) return null;
return dataUrls.get(id) ?? null;
}
const dataUrls = new Map(); // id -> the decoded data: URL (the decode's cache)
/**
* Draw one symbol, scaled to the `(x, y, w, h)` rect, onto `ctx`.
*
* @param {CanvasRenderingContext2D} ctx the target 2D context
* @param {string} id a symbol id from index.json
* @param {number} x the rect's left edge
* @param {number} y the rect's top edge
* @param {number} w the rect's width
* @param {number} h the rect's height
* @param {{tint?: string, alpha?: number, flipX?: boolean}} [opts]
* @throws {Error} when the id is unknown or the sprite has no pixels —
* the atlas never draws nothing silently.
*/
export async function draw(ctx, id, x, y, w, h, opts = {}) {
await ready();
paint(ctx, id, x, y, w, h, opts);
}
/**
* The synchronous half of `draw` (the renderer's hot path: after
* `ready()` has resolved, every symbol is decoded and this draws
* without a promise round-trip). Throws like `draw` on an unknown id
* or a pixel-less sprite; throws `atlas not ready` before the load has
* settled (the renderer awaits `ready()` first).
*/
export function drawSync(ctx, id, x, y, w, h, opts = {}) {
if (readyPromise === null) {
throw new Error("atlas not ready (call ready() first)");
}
paint(ctx, id, x, y, w, h, opts);
}
function paint(ctx, id, x, y, w, h, opts = {}) {
const entry = index.get(id);
if (!entry) throw new Error(`unknown atlas id: ${id}`);
const img = images.get(id);
if (!img || !img.complete || img.naturalWidth === 0 || img.naturalHeight === 0) {
throw new Error(`atlas image has no pixels: ${id}`);
}
ctx.save();
if (opts.flipX) {
// Mirror in the target rect: flip about its centre, draw as usual.
ctx.translate(x + w / 2, y + h / 2);
ctx.scale(-1, 1);
ctx.translate(-(x + w / 2), -(y + h / 2));
}
if (typeof opts.alpha === "number") {
ctx.globalAlpha = Math.max(0, Math.min(1, opts.alpha));
}
ctx.drawImage(img, x, y, w, h);
if (opts.tint) {
// `source-atop` paints the tint over the sprite's non-transparent
// pixels only, so the background stays untouched.
ctx.globalCompositeOperation = "source-atop";
ctx.fillStyle = opts.tint;
ctx.fillRect(x, y, w, h);
}
ctx.restore();
}
+197
View File
@@ -0,0 +1,197 @@
// Undercooked — the camera (phase 07, task 04).
//
// Fits the (approximated) kitchen into the canvas viewport: one scale
// (pixels per tile) plus a centred letterbox offset. The scale is the
// fit-to-viewport value, snapped *down* to an integer-ish step
// (`snapScale`) so tile boundaries land on whole device pixels (a
// sub-pixel drift that changes every frame is shimmer), then clamped to
// the zoom bounds, which the task states in tiles-per-pixel
// (`minTilesPerPx` = the most zoomed-in, `maxTilesPerPx` = the most
// zoomed-out; `scale = 1 / tilesPerPx`).
//
// DPR-aware: the backing store is `cssSize × dpr` with
// `dpr = min(devicePixelRatio, 2)` (a 4K 3× display is not worth the
// fill-rate); `snapScale`'s step is `1 / (2 × dpr)`, so the snap is
// integer at the device-pixel level. `resize()` re-reads the CSS size
// (and re-applies the backing store), so a window resize or a DPR
// change (a drag between monitors) refits.
//
// A pure core (`computeFit`, `snapScale`) for the e2e suites plus a
// `createCamera` wrapper that owns the DOM plumbing (the canvas
// backing store, the `resize` / `visibilitychange` listeners). The
// world bounds are the kitchen's: the renderer derives them from the
// snapshot (the grid never leaves the server, PLAN §8 — the floor is
// the entity bounding box + a one-tile margin, the client-side
// approximation documented in `docs/CLIENT.md`), `setWorld(w, h)`
// refits only when the (rounded) bounds change, so a cook walking does
// not re-snap the scale mid-step.
/** The `devicePixelRatio` cap (a 3× display renders at 2×). */
export const MAX_DPR = 2;
/**
* Snap a scale down to the integer-ish step. `step = 1 / (2 × dpr)`
* (dpr 1 → 0.5 px/tile, dpr 2 → 0.25): the largest multiple of the
* step at or below `scale`, at least one step (a zero-sized viewport
* must not produce a zero scale). `scale ≤ 0` is `0` (the caller's
* letterbox handles it — nothing to fit).
*/
export function snapScale(scale, dpr) {
const ratio = Math.max(1, Number.isFinite(dpr) ? dpr : 1);
if (!(scale > 0)) return 0;
const step = 1 / (2 * ratio);
const whole = Math.floor(scale / step - 1e-9);
return Math.max(1, whole) * step;
}
/**
* One fit: `{scale, offX, offY}` in CSS pixels. The raw fit is
* `min(view / world)` on each axis; it is snapped down (`snapScale`)
* and clamped to `[1 / maxTilesPerPx, 1 / minTilesPerPx]`; the offset
* centres the world on the remainder (the letterbox, negative when the
* clamp zoomed in past the fit — the kitchen is bigger than the view,
* and the centre of both coincide).
*/
export function computeFit({
viewW,
viewH,
worldW,
worldH,
dpr = 1,
minTilesPerPx = 0.02,
maxTilesPerPx = 0.5,
} = {}) {
const vw = Math.max(0, Number(viewW) || 0);
const vh = Math.max(0, Number(viewH) || 0);
const ww = Math.max(1e-6, Number(worldW) || 0);
const wh = Math.max(1e-6, Number(worldH) || 0);
if (vw === 0 || vh === 0) return { scale: 0, offX: 0, offY: 0 };
const minScale = 1 / Math.max(1e-9, Number(maxTilesPerPx) || 0.5);
const maxScale = 1 / Math.max(1e-9, Number(minTilesPerPx) || 0.02);
const raw = Math.min(vw / ww, vh / wh);
const scale = Math.min(maxScale, Math.max(minScale, snapScale(raw, dpr)));
return {
scale,
offX: (vw - ww * scale) / 2,
offY: (vh - wh * scale) / 2,
};
}
/**
* One camera.
*
* `resize()` — re-reads the CSS size (`getSize()`), applies the backing
* store at the capped DPR and refits. `setWorld(w, h)` — the kitchen's
* bounds; a change (integer-rounded, so sub-pixel drift does not
* refit) refits. `worldToScreen(x, y)` / `screenToWorld(x, y)` — the
* transforms in CSS pixels (the renderer scales the context by the DPR
* itself, so the math stays in CSS units). `attach()` / `detach()` —
* the `resize` and `visibilitychange` listeners (the DPR can change
* with the window).
*/
export function createCamera({
canvas = null,
minTilesPerPx = 0.02,
maxTilesPerPx = 0.5,
getSize = null,
dprOf = null,
onResize = null,
} = {}) {
const sizeOf =
typeof getSize === "function"
? getSize
: () => ({
w: canvas !== null ? canvas.clientWidth : 0,
h: canvas !== null ? canvas.clientHeight : 0,
});
const dprGet =
typeof dprOf === "function"
? dprOf
: () =>
typeof window !== "undefined" &&
Number.isFinite(window.devicePixelRatio)
? Math.min(MAX_DPR, window.devicePixelRatio)
: 1;
let worldW = 0;
let worldH = 0;
let key = null; // the rounded world + size + dpr the fit was computed for
let fit = { scale: 0, offX: 0, offY: 0 };
let dpr = 1;
let cssW = 0;
let cssH = 0;
const listeners = [];
function applySize() {
const size = sizeOf();
cssW = size.w;
cssH = size.h;
dpr = dprGet();
if (canvas !== null) {
canvas.width = Math.max(0, Math.round(cssW * dpr));
canvas.height = Math.max(0, Math.round(cssH * dpr));
}
refit();
}
function refit() {
const k = `${Math.round(worldW)}x${Math.round(worldH)}@${cssW}x${cssH}×${dpr}`;
if (k === key) return;
key = k;
fit = computeFit({
viewW: cssW,
viewH: cssH,
worldW,
worldH,
dpr,
minTilesPerPx,
maxTilesPerPx,
});
if (typeof onResize === "function") onResize();
}
function attach() {
if (typeof window !== "undefined") {
for (const name of ["resize", "visibilitychange"]) {
window.addEventListener(name, applySize);
listeners.push(name);
}
}
applySize();
}
function detach() {
if (typeof window !== "undefined") {
for (const name of listeners.splice(0)) window.removeEventListener(name, applySize);
}
}
return {
attach,
detach,
resize: applySize,
setWorld: (w, h) => {
worldW = w;
worldH = h;
refit();
},
worldToScreen: (x, y) => ({
x: fit.offX + x * fit.scale,
y: fit.offY + y * fit.scale,
}),
screenToWorld: (x, y) => ({
x: (x - fit.offX) / (fit.scale || 1),
y: (y - fit.offY) / (fit.scale || 1),
}),
stats: () => ({
scale: fit.scale,
offX: fit.offX,
offY: fit.offY,
worldW,
worldH,
cssW,
cssH,
dpr,
}),
};
}
+648
View File
@@ -0,0 +1,648 @@
// 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,
};
}
+361
View File
@@ -0,0 +1,361 @@
// Undercooked — the event FX (phase 07, task 04).
//
// The wire's transient events (the snapshot's `e[]`, the standalone
// `ev`) become on-screen effects here: `chopped`'s spark, `cooked` /
// `ready`'s steam, `served`'s pop + the score float, `burnt`'s smoke,
// `denied`'s cross + the nudge, `stunned`'s spin, `order_missed`'s
// flash, `score`'s float, plus `washed` / `trashed` / `picked` /
// `handed_off`'s small marks.
//
// The renderer drains the event queue (`net/events.js`'s `due(now)`)
// each frame and hands the live entries to `consume`; `consume`
// spawns each entry's effect **once** — its own 100 ms dedup window
// (the same key the queue uses) suppresses the frames the same queued
// entry is repeated over, and a genuinely new event (more than the
// window later, or the queue's tick-reset cleared the old one) spawns
// again. The queue's TTL is the effect's lifetime source: a `served`
// lives 1200 ms, a `chopped` 800.
//
// **A small particle pool, no allocation per event.** `PARTICLES` is a
// fixed array of pre-built particle objects; `spawn` reuses them by a
// cursor (a full pool wraps and steals the oldest), and the one-shot
// effects (the sprite marks: the steam, the deny cross, the flash)
// live in a second fixed `MARKS` array the same way. Nothing is
// `new`-ed after `createFx` — a chop burst cannot grow the heap.
//
// `[data-motion="reduced"]` (or `prefers-reduced-motion`) is the
// fallback: the markers stay (the deny cross, the spin mark, the
// flash's border — the *static* versions), the particles do not
// launch, the nudge's shake never applies (`shakeApplied` stays 0),
// and nothing moves. `reduced()` reads the canvas's `data-motion`
// attribute first (the composition sets it from the `?motion=0`
// param, `docs/CLIENT.md` "The URL params"), then the media query.
import * as atlas from "./atlas.js";
const PARTICLE_COUNT = 96; // the pool size (a full burst wraps, never grows)
const MARK_COUNT = 24; // the one-shot effect slots
const DEDUP_WINDOW_MS = 100; // the queue's window — the same dedup rule
/**
* The event → effect table. `sprite` is the atlas id of the one-shot
* mark (the `reduced` fallback's static marker); `particle` the pool
* particle's colour and shape (the non-reduced launch); `lifeMs` the
* effect's own lifetime (within the queue kind's TTL).
*/
const EFFECTS = Object.freeze({
chopped: { sprite: "fx-chop", particle: { color: "#ffd166" }, lifeMs: 500 },
cooked: { sprite: "fx-steam", particle: { color: "#d9d4c8" }, lifeMs: 800 },
ready: { sprite: "fx-steam", particle: { color: "#d9d4c8" }, lifeMs: 600 },
washed: { sprite: "fx-steam", particle: { color: "#bfe3ff" }, lifeMs: 600 },
served: { sprite: "fx-serve", particle: { color: "#ffe066" }, lifeMs: 1000 },
burnt: { sprite: "fx-flame", particle: { color: "#8a8a8a" }, lifeMs: 900 },
denied: { sprite: "fx-deny", particle: null, lifeMs: 600, nudge: true },
stunned: { sprite: "fx-steam", particle: null, lifeMs: 800, spin: true },
order_missed: { sprite: "fx-deny", particle: null, lifeMs: 1000, flash: true },
score: { sprite: "fx-score", particle: { color: "#ffe066" }, lifeMs: 1000, float: true },
trashed: { sprite: "fx-splash", particle: { color: "#9a8f7a" }, lifeMs: 500 },
picked: { sprite: "fx-chop", particle: { color: "#d9d4c8" }, lifeMs: 350 },
dropped: { sprite: "fx-splash", particle: { color: "#9a8f7a" }, lifeMs: 350 },
handed_off: { sprite: "fx-score", particle: { color: "#d9d4c8" }, lifeMs: 350 },
fire_started: { sprite: "fx-flame", particle: { color: "#ffa63d" }, lifeMs: 800 },
ghost_on: { sprite: "ui-ghost", particle: null, lifeMs: 1200 },
ghost_off: { sprite: "ui-ghost", particle: null, lifeMs: 800 },
});
function keyOf(entry) {
const payload =
entry.payload !== undefined && entry.payload !== null
? JSON.stringify(entry.payload, Object.keys(entry.payload).sort())
: "";
return `${entry.k}|${Math.round((entry.x ?? 0) * 100)}|${Math.round((entry.y ?? 0) * 100)}|${payload}`;
}
/**
* One FX engine. `consume(entries, view, nowMs)` — the queued entries
* (the queue's `due(now)`), the frame (for the nudge's player lookup),
* the clock. `update(nowMs)` — advances the particle pool (a clamped
* `dt`: a resume's jump cannot fling the particles). `draw(ctx,
* toLocal, tile, nowMs)` — draws the live marks + particles (the
* renderer's layer 9). `shakeOffset(playerId, nowMs)` — the nudge's
* decaying offset in *tiles* (the renderer adds it to the player's
* draw position). `stats()` — the census (the e2e's reduced-motion
* seam: `processed`, `shakeApplied`, `particlesLaunched`).
*/
export function createFx({
canvas = null,
reduced = null,
media = null,
} = {}) {
const query =
typeof media === "function"
? media
: () =>
typeof window !== "undefined" &&
typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false;
const particles = Array.from({ length: PARTICLE_COUNT }, () => ({
active: false,
x: 0,
y: 0,
vx: 0,
vy: 0,
born: 0,
life: 0,
color: "#ffffff",
size: 1,
}));
let particleCursor = 0;
const marks = Array.from({ length: MARK_COUNT }, () => ({
active: false,
x: 0,
y: 0,
born: 0,
life: 0,
sprite: "",
text: "",
color: "#ffffff",
alpha: 1,
}));
let markCursor = 0;
const seen = new Map(); // dedup key -> the last accepted spawn (nowMs)
const shakes = new Map(); // player id -> { born, life } (the nudge's window)
let lastNow = null;
const stats = {
processed: {},
shakeApplied: 0,
particlesLaunched: 0,
deduped: 0,
};
/** The reduced-motion half: the `data-motion` attribute first, then
* the media query (either one is enough). */
function reducedMotion() {
if (typeof reduced === "function") {
const forced = reduced();
if (forced !== null && forced !== undefined) {
return Boolean(forced);
}
}
if (canvas !== null && canvas.dataset !== null) {
const attr = canvas.dataset.motion;
if (attr === "reduced") return true;
}
return query();
}
function stealMark() {
const slot = marks[markCursor];
markCursor = (markCursor + 1) % MARK_COUNT;
return slot;
}
function stealParticle() {
const slot = particles[particleCursor];
particleCursor = (particleCursor + 1) % PARTICLE_COUNT;
return slot;
}
function launchParticles(x, y, color, nowMs, count) {
for (let i = 0; i < count; i += 1) {
const p = stealParticle();
p.active = true;
p.x = x;
p.y = y;
const angle = (i / count) * Math.PI * 2 + (nowMs % 100) * 0.01;
p.vx = Math.cos(angle) * 0.6;
p.vy = Math.sin(angle) * 0.6 - 0.4;
p.born = nowMs;
p.life = 500 + (i % 4) * 80;
p.color = color;
p.size = 0.08 + (i % 3) * 0.03;
stats.particlesLaunched += 1;
}
}
/**
* One queued entry (its `k` is the event kind, `x`/`y` tile units,
* `payload` the short-keyed map). Spawns the effect once; the 100 ms
* window suppresses the same entry's repeat frames.
*/
function consumeOne(entry, view, nowMs) {
const spec = EFFECTS[entry.k];
if (spec === undefined) return; // an unlisted kind (the queue's admission gate already skipped it)
const key = keyOf(entry);
const last = seen.get(key);
if (last !== undefined && nowMs - last < DEDUP_WINDOW_MS) {
stats.deduped += 1; // the echo: the effect already plays
return;
}
seen.set(key, nowMs);
stats.processed[entry.k] = (stats.processed[entry.k] ?? 0) + 1;
const reducedNow = reducedMotion();
if (spec.sprite !== "") {
const mark = stealMark();
mark.active = true;
mark.x = entry.x ?? 0;
mark.y = entry.y ?? 0;
mark.born = nowMs;
mark.life = spec.lifeMs;
mark.sprite = spec.sprite;
mark.alpha = 1;
// The float text (the score's `+pts`, the served's `pts`).
const pts =
entry.payload !== null && entry.payload !== undefined
? entry.payload.pts
: undefined;
mark.text =
spec.float && Number.isFinite(pts) ? `+${pts}` : "";
mark.color = spec.particle !== null ? spec.particle.color : "#ffffff";
}
if (spec.particle !== null && !reducedNow) {
launchParticles(entry.x ?? 0, entry.y ?? 0, spec.particle.color, nowMs, 5);
}
if (spec.nudge && view !== null && view !== undefined) {
const pid =
entry.payload !== null && entry.payload !== undefined
? entry.payload.i
: undefined;
if (pid !== undefined) {
const existing = shakes.get(pid);
if (existing === undefined || nowMs - existing.born > DEDUP_WINDOW_MS) {
shakes.set(pid, { born: nowMs, life: 250 });
}
}
}
}
/**
* The queued entries (the queue's `due(now)` — a list; a single
* entry is tolerated) — each is spawned once.
*/
function consume(entries, view, nowMs) {
const list = Array.isArray(entries) ? entries : [entries];
for (const entry of list) consumeOne(entry, view, nowMs);
}
/** The queue's prune's mirror: drop the dedup keys and the nudge
* windows past their lifetime (the queue's `drift` does its half). */
function prune(nowMs) {
for (const [key, at] of [...seen]) {
if (nowMs - at >= DEDUP_WINDOW_MS) seen.delete(key);
}
for (const [pid, win] of [...shakes]) {
if (nowMs - win.born >= win.life) shakes.delete(pid);
}
}
function update(nowMs) {
const dt =
lastNow === null ? 0 : Math.min(100, Math.max(0, nowMs - lastNow));
lastNow = nowMs;
if (dt > 0) {
const seconds = dt / 1000;
for (const p of particles) {
if (!p.active) continue;
if (nowMs - p.born >= p.life) {
p.active = false;
continue;
}
p.x += p.vx * seconds;
p.y += p.vy * seconds;
p.vy += 1.4 * seconds; // the gravity (tiles/s²)
}
}
prune(nowMs);
}
/**
* The nudge's offset in tiles (the denied's push-back): a damped
* 250 ms wobble along the event's arrival — zero when reduced motion
* (the task's "no shake" half) or outside the window.
*/
function shakeOffset(playerId, nowMs) {
const win = shakes.get(playerId);
if (win === undefined || reducedMotion() || nowMs - win.born >= win.life) {
return null;
}
const t = (nowMs - win.born) / win.life; // 0..1
const wobble = Math.sin(t * Math.PI * 3) * 0.06 * (1 - t);
stats.shakeApplied += 1;
return { dx: wobble, dy: -Math.abs(wobble) * 0.5 };
}
/** Draw the live marks + particles (the renderer's layer 9). */
function draw(ctx, toLocal, tile, nowMs) {
for (const mark of marks) {
if (!mark.active) continue;
const age = nowMs - mark.born;
if (age >= mark.life) {
mark.active = false;
continue;
}
const [x, y] = toLocal(mark.x, mark.y);
const alpha = 1 - age / mark.life;
if (mark.sprite === "fx-flame" && mark.text === "" && mark.color === "#8a8a8a") {
// The burnt's smoke: the flame sprite, grey, rising.
ctx.save();
ctx.globalAlpha = alpha * 0.55;
ctx.translate(0, -age * 0.0012 * tile);
// `fx-flame` is the art's only smoke-able symbol: greyed, it
// reads as smoke.
drawSprite(ctx, "fx-flame", x - tile * 0.2, y - tile * 0.6, tile * 0.55, tile * 0.7, { alpha: 1 });
ctx.restore();
continue;
}
drawSprite(ctx, mark.sprite, x - tile * 0.3, y - tile * 0.55, tile * 0.6, tile * 0.55, { alpha });
if (mark.text !== "") {
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = "#ffe066";
ctx.font = `bold ${Math.max(12, tile * 0.34)}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.fillText(mark.text, x, y - tile * 0.7 - age * 0.0015 * tile);
ctx.restore();
}
}
for (const p of particles) {
if (!p.active) continue;
const age = nowMs - p.born;
if (age >= p.life) continue;
const [x, y] = toLocal(p.x, p.y);
ctx.save();
ctx.globalAlpha = (1 - age / p.life) * 0.9;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(x, y - tile * 0.4, Math.max(1.5, p.size * tile), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// The marks draw through the atlas directly; a throw (a missing
// symbol) cannot blank the frame — the mark's slot just does not
// draw this frame (the renderer's own sprite helper owns the
// once-logged fallback for the *layer* sprites).
function drawSprite(ctx, id, x, y, w, h, opts) {
try {
atlas.drawSync(ctx, id, x, y, w, h, opts);
} catch {
/* a missing symbol: the mark's slot stays, the frame goes on */
}
}
return {
consume,
update,
draw,
shakeOffset,
reduced: reducedMotion,
stats: () => ({
processed: { ...stats.processed },
shakeApplied: stats.shakeApplied,
particlesLaunched: stats.particlesLaunched,
deduped: stats.deduped,
activeParticles: particles.filter((p) => p.active).length,
}),
};
}
+144
View File
@@ -0,0 +1,144 @@
// Undercooked — the single render loop (phase 07 task 03).
//
// One `requestAnimationFrame` loop per page — the single-owner rule:
// exactly one loop instance exists (the composition root, `main.js`,
// creates it), and it is the only thing that samples the buffer,
// calls the renderer and updates the HUD
// (`sample → render → hud.update` per frame, docs/CLIENT.md "The
// render loop"). No screen, no module, no test spawns a second loop:
// two owners of the clock is how the frame-time accounting goes wrong.
//
// The loop's lifecycle:
//
// * **paused when `document.hidden`** — the rAF chain stops entirely
// (a pending frame that finds `hidden` renders nothing and does not
// reschedule; the `visibilitychange` back to visible restarts it).
// The delta spanning the pause is *dropped*, not measured, so a
// resume cannot pollute the p50/p95 window.
// * **idle when the screen is not `PLAYING`** — `active()` is the
// composition's predicate (the screen's name); a non-active frame
// renders nothing but keeps the chain alive, so the loop resumes on
// the very next frame after the screen turns `PLAYING` (no event
// needed).
// * **`?freeze=1`** — the composition's business (it holds the *sample
// time* at the first playing frame); the loop itself just renders
// whatever `render(nowMs)` asks for, so a frozen render is a still
// image without the loop pausing.
//
// Self-measured: every rendered frame's time (the `now` deltas) lands
// in a sliding 120-sample window; `frameStats()` reports `frames`,
// `running`, `hidden`, `p50` / `p95` (the `frameMs` the spec wants on
// `window.__uc`) and `max`. The window resets on `start()`.
export function createRenderLoop({
active = () => true,
render = null,
now = null,
raf = null,
unraf = null,
owner = null,
} = {}) {
const clock = typeof now === "function" ? now : () => performance.now();
const schedule = typeof raf === "function" ? raf : (fn) => requestAnimationFrame(fn);
const cancel = typeof unraf === "function" ? unraf : (id) => cancelAnimationFrame(id);
const visOwner = owner !== null ? owner : typeof document !== "undefined" ? document : null;
let running = false;
let scheduled = null;
let hidden = visOwner !== null ? Boolean(visOwner.hidden) : false;
let frames = 0;
let lastNow = null;
let pausedAt = null; // the pause boundary (the resume's delta drops)
const sampleWindow = 120;
const deltas = [];
const onVisibilityChange = () => {
const wasHidden = hidden;
hidden = visOwner !== null ? Boolean(visOwner.hidden) : false;
if (hidden && !wasHidden) pausedAt = clock(); // mark the pause
if (running && !hidden && scheduled === null) scheduleFrame(); // resume
};
function frame() {
scheduled = null;
if (!running || hidden) return; // stopped or paused: wait for a resume
const t = clock();
if (active()) {
if (lastNow !== null) {
const dt = t - lastNow;
const resumed = pausedAt !== null; // a pause just ended
pausedAt = null; // consumed once, by the first resumed frame
if (!resumed) {
deltas.push(dt);
if (deltas.length > sampleWindow) deltas.shift();
}
}
lastNow = t;
frames += 1;
if (typeof render === "function") render(t);
if (running && !hidden) scheduleFrame();
} else {
// Idle (the screen is not PLAYING): keep the chain alive so the
// active() flip is picked up on the next frame; a fresh round
// starts with a fresh delta, and a pending pause boundary cannot
// survive an idle period (it would drop a real delta later).
pausedAt = null;
lastNow = null;
scheduleFrame();
}
}
function scheduleFrame() {
scheduled = schedule(frame);
}
function start() {
if (running) return;
running = true;
frames = 0;
lastNow = null;
pausedAt = null;
deltas.length = 0;
if (visOwner !== null) {
visOwner.addEventListener("visibilitychange", onVisibilityChange);
}
if (!hidden) scheduleFrame();
}
function stop() {
if (!running) return;
running = false;
if (scheduled !== null) {
cancel(scheduled);
scheduled = null;
}
if (visOwner !== null) {
visOwner.removeEventListener("visibilitychange", onVisibilityChange);
}
}
/** The self-measured frame census (the `frameMs` p50/p95 on `window.__uc`). */
function frameStats() {
const sorted = [...deltas].sort((a, b) => a - b);
const quantile = (q) =>
sorted.length === 0
? 0
: sorted[Math.min(sorted.length - 1, Math.ceil(q * sorted.length) - 1)];
return {
frames,
running,
hidden,
p50: quantile(0.5),
p95: quantile(0.95),
max: sorted.length === 0 ? 0 : sorted[sorted.length - 1],
};
}
return {
start,
stop,
frameStats,
isRunning: () => running,
isHidden: () => hidden,
};
}
+52
View File
@@ -0,0 +1,52 @@
// Undercooked palette (phase 07, task 01).
//
// The canvas half of app/static/assets/palette.css: the same hex values,
// re-exported for the 2D renderer, so a tint in the HUD and a tint on
// the floor can never disagree. tests/unit/test_assets.py asserts the two
// files agree in both directions; edit one and the build fails until the
// other follows. No bundler, no framework, no remote import (A10/A12).
export const DEFAULT_PALETTE = Object.freeze({
cook1: "#e0533d",
cook2: "#3d84a8",
cook3: "#7da84b",
cook4: "#b0699a",
danger: "#ff5a4e",
hot: "#ffa63d",
ready: "#67d16c",
dirty: "#9a8f7a",
});
// Okabe-Ito (colourblind-safe, deutan/protan) cook colours: the
// [data-palette="cb"] block of the CSS, the same semantics.
export const COLORBLIND_PALETTE = Object.freeze({
...DEFAULT_PALETTE,
cook1: "#d55e00",
cook2: "#0072b2",
cook3: "#009e73",
cook4: "#cc79a7",
});
/** The four seat colours, in seat order (index 0 is seat 1). */
export const COOK_COLORS = Object.freeze([
DEFAULT_PALETTE.cook1,
DEFAULT_PALETTE.cook2,
DEFAULT_PALETTE.cook3,
DEFAULT_PALETTE.cook4,
]);
/** The seat colours under [data-palette="cb"], in seat order. */
export const COOK_COLORS_COLORBLIND = Object.freeze([
COLORBLIND_PALETTE.cook1,
COLORBLIND_PALETTE.cook2,
COLORBLIND_PALETTE.cook3,
COLORBLIND_PALETTE.cook4,
]);
/** The semantic state colours shared by the HUD and the canvas. */
export const SEMANTIC = Object.freeze({
danger: DEFAULT_PALETTE.danger,
hot: DEFAULT_PALETTE.hot,
ready: DEFAULT_PALETTE.ready,
dirty: DEFAULT_PALETTE.dirty,
});