Spaces:
Running
Running
Download src/assets.js from cloudronin/astara: direct link, hf CLI and curl.
- Browser
- Download file 11.3 kB
-
https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/assets.js
- Command line
-
hf download hf://spaces/cloudronin/astara/src/assets.js
-
curl -L -o assets.js https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/assets.js
11.3 kB
| // 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) }; | |
| } | |