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

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

313 lines
12 KiB
JavaScript

// 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();
}