Spaces:
Running
Running
File size: 11,314 Bytes
020d2dc 71eae78 020d2dc 2428d78 020d2dc 3647d73 020d2dc 3647d73 020d2dc 71eae78 020d2dc 7d93bf0 020d2dc 7d93bf0 020d2dc 3647d73 75ec82b 020d2dc 75ec82b 020d2dc 71eae78 020d2dc 3647d73 42ec301 020d2dc 3647d73 9f89772 75ec82b 71eae78 94ca139 94e856b 71eae78 2428d78 71eae78 2428d78 45cc183 835b445 2428d78 7d93bf0 71eae78 020d2dc 71eae78 020d2dc 71eae78 020d2dc 71eae78 020d2dc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 | // 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) };
}
|