astara / src /assets.js
system's picture
system HF Staff
d7dab35: Her own drawings for the sword, the fall and the torch
75ec82b verified
Raw History Blame Contribute Delete
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) };
}