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