// Loading, and turning lit art into cached black silhouettes. // // Everything about a clip — how many frames, how fast, how big, where the // anchor is — comes from the manifest. Nothing here assumes a count or a size. import { createSilhouetteBuilder, defaultSealRadius } from './silhouette.js'; const pad2 = (i) => String(i).padStart(2, '0'); /** * Ink on a canvas edge is only a bug on edges the art was not meant to reach. * Characters stand on the bottom of their canvas; a sprite that tiles sideways * runs off the left and right on purpose. The top is never legitimate. */ function suspect(edges, def) { // Some art runs off its canvas on purpose: a sideways-tiling sprite at the // left and right, a ceiling at the top. Those are declared in the manifest; // anything undeclared is a bug. const ok = new Set(def.bleedEdges ?? (def.tile === 'x' ? ['left', 'right'] : [])); let n = 0; if (!ok.has('top')) n += edges.top; if (!ok.has('left')) n += edges.left; if (!ok.has('right')) n += edges.right; return n; } function loadImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error(`could not load ${url}`)); img.src = url; }); } export async function loadManifest(url = 'manifest.json') { // NEVER FROM CACHE. This one file decides what every other file even is, so a // stale copy does not fail loudly — it silently runs an older build. It has // cost this project twice: once a whole round of screenshots showing grey // placeholder boxes long after the art existed, and once a debug line that // would not update because the page had been loaded between two commits. // // The art files themselves are still allowed to cache. They are named, and a // manifest that names them is by definition fresh. const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(`manifest: ${res.status} ${res.statusText}`); const data = await res.json(); if (!data.clips) throw new Error('manifest has no "clips"'); for (const [name, clip] of Object.entries(data.clips)) { for (const field of ['path', 'file', 'frames', 'fps', 'native', 'pivot']) { if (clip[field] === undefined) throw new Error(`clip "${name}" is missing "${field}"`); } if (!Array.isArray(clip.native) || clip.native.length !== 2) { throw new Error(`clip "${name}": "native" must be [width, height]`); } } return data; } function frameUrls(clip, pattern) { const out = []; for (let i = 0; i < clip.frames; i++) out.push(clip.path + pattern.replace('{nn}', pad2(i))); return out; } export async function loadClips(manifest) { const scratch = document.createElement('canvas'); const sctx = scratch.getContext('2d', { willReadFrequently: true }); const clips = {}; const report = []; const startedAll = performance.now(); for (const [name, def] of Object.entries(manifest.clips)) { const [nw, nh] = def.native; const useMask = def.silhouette === 'mask'; // SOME THINGS ARE NOT SILHOUETTES. Fire is the one: it is where the light // comes from, so it cannot be a black shape in the dark pass. selfLit hands // the same art to both passes and skips building a silhouette nothing will // ever draw. const selfLit = def.selfLit === true; const sealRadius = def.sealRadius ?? defaultSealRadius(nh); const litImages = await Promise.all(frameUrls(def, def.file).map(loadImage)); const maskImages = useMask ? await Promise.all(frameUrls(def, def.maskFile ?? 'mask_{nn}.png').map(loadImage)) : null; scratch.width = nw; scratch.height = nh; // One builder per clip: every frame in a clip shares a size, so the working // buffers are allocated once instead of once per frame. const build = (useMask || selfLit) ? null : createSilhouetteBuilder(nw, nh, sealRadius, def.keepWhite === true); const started = performance.now(); let inkTotal = 0; let silTotal = 0; const edges = { top: 0, bottom: 0, left: 0, right: 0 }; // WHERE THE DRAWING ACTUALLY IS INSIDE ITS CANVAS, measured rather than // declared. A clip's `native` size is the canvas; the art inside it may use // a third of that and sit anywhere in it. Anything that wants to fit a clip // into a box on screen — a page held up on the map sheet — needs the ink, // not the canvas, or it draws a small picture in a large empty frame. // // Measured: 17.7M pixels across 28 clips, 41ms, about 2% of a 1.9s load. // It is one alpha compare per pixel on data already read for the silhouette // pass, which is why it is cheap — not free, and worth the number. const ink = { x0: nw, y0: nh, x1: 0, y1: 0 }; const frames = []; for (let i = 0; i < litImages.length; i++) { sctx.clearRect(0, 0, nw, nh); sctx.drawImage(useMask ? maskImages[i] : litImages[i], 0, 0, nw, nh); const data = sctx.getImageData(0, 0, nw, nh); const px = data.data; for (let y = 0; y < nh; y++) { for (let x = 0; x < nw; x++) { if (px[(y * nw + x) * 4 + 3] < 8) continue; if (x < ink.x0) ink.x0 = x; if (x > ink.x1) ink.x1 = x; if (y < ink.y0) ink.y0 = y; if (y > ink.y1) ink.y1 = y; } } let pixels; if (selfLit) { pixels = null; } else if (useMask) { // A supplied mask is already the shape. Just recolour it black -- all // but an eye left white in it, on a clip that keeps white: his torch // raise comes as masks, and recolouring everything blinded him. pixels = data.data; const keep = def.keepWhite === true; for (let p = 0; p < pixels.length; p += 4) { if (keep && pixels[p] > 200 && pixels[p + 1] > 200 && pixels[p + 2] > 200 && pixels[p + 3] > 200) continue; pixels[p] = 0; pixels[p + 1] = 0; pixels[p + 2] = 0; } } else { const result = build(data.data); pixels = result.pixels; inkTotal += result.inkPixels; silTotal += result.silhouettePixels; edges.top += result.edgeTop; edges.bottom += result.edgeBottom; edges.left += result.edgeLeft; edges.right += result.edgeRight; } const dark = selfLit ? litImages[i] : await createImageBitmap(new ImageData(pixels, nw, nh)); // THE EYE, ON ITS OWN LAYER. // // It is baked white into both passes, and the renderer has to be able to // DIM it — that is the health readout now. So the white is lifted out // here as a separate black stencil: draw the figure, then draw this over // the eye at whatever alpha, and the eye goes from white to out. // // Only for clips that keep white, which is only the prince. Nothing else // in the game has anything but black standing on the ground line. let eyes = null; if (def.keepWhite === true) { const stencil = new Uint8ClampedArray(nw * nh * 4); let any = 0; for (let p = 0; p < nw * nh; p++) { const q = p * 4; if (pixels[q] > 200 && pixels[q + 3] > 200) { stencil[q + 3] = 255; any++; } } if (any) eyes = await createImageBitmap(new ImageData(stencil, nw, nh)); } frames.push({ lit: litImages[i], dark, eyes }); } clips[name] = { name, frames, count: frames.length, fps: def.fps, loop: def.loop !== false, width: nw, height: nh, pivotX: def.pivot[0], pivotY: def.pivot[1], handPivot: def.handPivot ?? null, selfLit, torchArm: def.torchArm ?? null, // How far up the fire sprite its light comes from, for a pose that holds // the torch in the drawing itself rather than on the jointed arm. fireCentre: def.fireCentre ?? null, // And where the fire stands in each frame, for a pose whose frames move // the torch: one [x, y] per frame, in the clip's own pixels. handPivots: def.handPivots ?? null, groundSpeed: def.groundSpeed ?? null, footFrames: def.footFrames ?? null, // Which frames a body makes a noise on. Same idea as footFrames and kept // separate from it: a footstep is a foot landing, this is a kind of body // doing the one thing it is still doing. soundFrames: def.soundFrames ?? null, keptAnchor: def.keptAnchor ?? null, keptAngle: def.keptAngle ?? null, variantScale: def.variantScale ?? null, keptDrop: def.keptDrop ?? null, doorAt: def.doorAt ?? null, // Two spots beside each door, stacked: the letter, which never comes // down, and under it the slot a fragment is pinned into. Generated, never // hand-written — the rects these replace included one that drifted. letterAt: def.letterAt ?? null, slotAt: def.slotAt ?? null, keyAt: def.keyAt ?? null, mapPivot: def.mapPivot ?? null, plots: def.plots ?? null, plotSize: def.plotSize ?? null, slots: def.slots ?? null, slotSize: def.slotSize ?? null, // NAMED FRAME INDICES. Which frame of the sheet is the map and which is // blank paper; which page shape is whole and which is torn. Named here // rather than counted in the renderer, for the same reason nothing else // in this build hardcodes a frame number. sheetFace: def.sheetFace ?? null, pageFace: def.pageFace ?? null, // The union of every frame's ink, in the clip's own pixels. Null for a // clip with nothing drawn in it at all. ink: ink.x1 >= ink.x0 ? { x: ink.x0, y: ink.y0, w: ink.x1 - ink.x0 + 1, h: ink.y1 - ink.y0 + 1 } : null, authoredFacing: def.authoredFacing === 'left' ? -1 : 1, hitFrame: def.hitFrame ?? null, reach: def.reach ?? 0, tile: def.tile ?? null, variants: def.variants === true, sealRadius, }; report.push({ clip: name, frames: frames.length, native: `${nw}x${nh}`, sealRadius: useMask ? '(mask)' : sealRadius, ms: +(performance.now() - started).toFixed(1), // Silhouette area over ink area. NOT a pass/fail threshold — it depends // on how much interior detail the art carries. A dense figure with lots // of interior line work scores lower than a sparse one even when both // fill correctly. Useful for spotting a REGRESSION in one clip over time; // useless for comparing two different drawings. Judge correctness by // looking at the silhouette. gain: useMask ? '(mask)' : +(silTotal / Math.max(inkTotal, 1)).toFixed(2), // Ink on an edge the art was not meant to touch. Its contour is open // there, so the fill walks in and the silhouette comes out hollow. edgeBleed: useMask ? '(mask)' : suspect(edges, def), }); const bad = useMask ? 0 : suspect(edges, def); if (bad > 0) { console.warn(`[silhouette] "${name}" has ${bad} ink pixels on a canvas edge it should not ` + `touch (${JSON.stringify(edges)}). The contour is open there and the fill will leak in.`); } } return { clips, report, totalMs: +(performance.now() - startedAll).toFixed(1) }; }