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:
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user