// 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 // `` 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 `` 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 ``, 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 `` 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 * `` (a `` renders nothing on its own). `` 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 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 `` 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 * ``, 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(); }