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) };
}