@ludic-labs/assembly 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +2 -0
  3. package/README.md +50 -2
  4. package/dist/bake.d.ts +80 -0
  5. package/dist/bake.js +214 -0
  6. package/dist/browser.d.ts +89 -0
  7. package/dist/browser.js +534 -0
  8. package/dist/compat.d.ts +17 -0
  9. package/dist/compat.js +153 -0
  10. package/dist/compiler.d.ts +18 -0
  11. package/dist/compiler.js +1264 -0
  12. package/dist/diagnostics.d.ts +5 -0
  13. package/dist/diagnostics.js +8 -0
  14. package/dist/export.d.ts +54 -0
  15. package/dist/export.js +272 -0
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.js +3 -0
  18. package/dist/legacy.d.ts +36 -0
  19. package/dist/legacy.js +180 -0
  20. package/dist/math.d.ts +16 -0
  21. package/dist/math.js +62 -0
  22. package/dist/mesh.d.ts +11 -0
  23. package/dist/mesh.js +153 -0
  24. package/dist/motion.d.ts +63 -0
  25. package/dist/motion.js +1476 -0
  26. package/dist/pixel.d.ts +268 -0
  27. package/dist/pixel.js +942 -0
  28. package/dist/required.d.ts +2 -0
  29. package/dist/required.js +6 -0
  30. package/dist/shapes.d.ts +71 -0
  31. package/dist/shapes.js +396 -0
  32. package/dist/sprite-brief.d.ts +5 -0
  33. package/dist/sprite-brief.js +47 -0
  34. package/dist/sprite-compiler.d.ts +62 -0
  35. package/dist/sprite-compiler.js +1745 -0
  36. package/dist/sprite-decompile.d.ts +68 -0
  37. package/dist/sprite-decompile.js +786 -0
  38. package/dist/sprite-format.d.ts +4 -0
  39. package/dist/sprite-format.js +155 -0
  40. package/dist/sprite-guide.d.ts +40 -0
  41. package/dist/sprite-guide.js +330 -0
  42. package/dist/sprite-scene.d.ts +277 -0
  43. package/dist/sprite-scene.js +664 -0
  44. package/dist/sprite-types.d.ts +110 -0
  45. package/dist/sprite-types.js +1 -0
  46. package/dist/sprite.d.ts +8 -0
  47. package/dist/sprite.js +8 -0
  48. package/dist/svg/schema.d.ts +71 -0
  49. package/dist/svg/schema.js +217 -0
  50. package/dist/svg/types.d.ts +148 -0
  51. package/dist/svg/types.js +1 -0
  52. package/dist/svg.d.ts +26 -0
  53. package/dist/svg.js +539 -0
  54. package/dist/text.d.ts +12 -0
  55. package/dist/text.js +75 -0
  56. package/dist/types.d.ts +478 -0
  57. package/dist/types.js +1 -0
  58. package/docs/svg/README.md +150 -0
  59. package/docs/svg/specification.md +108 -0
  60. package/examples/svg/arcane-shield.json +261 -0
  61. package/examples/svg/aurora-ribbon.json +158 -0
  62. package/examples/svg/bouncing-ember.json +76 -0
  63. package/examples/svg/ember-fountain.json +224 -0
  64. package/examples/svg/lantern-keeper.json +1159 -0
  65. package/examples/svg/night-workshop.json +1120 -0
  66. package/examples/svg/waygate-platform.json +64 -0
  67. package/examples/svg/waygate-spark.json +92 -0
  68. package/examples/svg/waygate-wisp.json +105 -0
  69. package/examples/svg/waygate.json +485 -0
  70. package/package.json +132 -4
  71. package/schemas/asset-assembly-0.1.schema.json +354 -0
  72. package/schemas/asset-assembly-0.2.schema.json +429 -0
  73. package/schemas/asset-assembly-0.3.schema.json +572 -0
  74. package/schemas/motion-set-0.1.schema.json +178 -0
  75. package/schemas/pixel-sprite-0.1.schema.json +892 -0
  76. package/schemas/pixel-sprite-scene-0.1.schema.json +149 -0
  77. package/schemas/svg-asset-0.1.schema.json +1157 -0
  78. package/tools/svg-asset.mjs +106 -0
@@ -0,0 +1,664 @@
1
+ import { PIXEL_CAMERAS, PIXEL_PROFILES, composePixelScene, linearToOklab, pixelCamera, pixelRamp, renderPixelArt, } from "./pixel.js";
2
+ import { compileAsset } from "./compiler.js";
3
+ import { posedAsset, spriteGuideResolver, spriteRenderResolver } from "./sprite-guide.js";
4
+ import { compileSprite, SPRITE_LIMITS } from "./sprite-compiler.js";
5
+ import { requireValue } from "./required.js";
6
+ const PRESETS = {
7
+ dimetric: [135, 30],
8
+ dimetricRight: [225, 30],
9
+ dimetricOpposite: [315, 30],
10
+ dimetricLeft: [45, 30],
11
+ front: [180, 8],
12
+ topDown: [180, 45],
13
+ };
14
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
15
+ function checkFrameSize(width, height) {
16
+ if (![width, height].every((n) => Number.isSafeInteger(n) && n > 0 && n <= SPRITE_LIMITS.sceneDimension))
17
+ throw new Error(`scene frame dimensions must be integers within 1..${SPRITE_LIMITS.sceneDimension}`);
18
+ }
19
+ // Object member order has no meaning in a JSON scene contract; array order does.
20
+ function structurallyEqual(a, b) {
21
+ if (a === b)
22
+ return true;
23
+ if (!a || !b || typeof a !== "object" || typeof b !== "object")
24
+ return false;
25
+ if (Array.isArray(a) || Array.isArray(b))
26
+ return (Array.isArray(a) &&
27
+ Array.isArray(b) &&
28
+ a.length === b.length &&
29
+ a.every((v, i) => structurallyEqual(v, b[i])));
30
+ const left = a, right = b;
31
+ return (Object.keys(left).length === Object.keys(right).length &&
32
+ Object.keys(left).every((key) => Object.hasOwn(right, key) && structurallyEqual(left[key], right[key])));
33
+ }
34
+ /** Prepare a scene using caller-owned asset JSON; performs no IO. */
35
+ export function prepareSpriteScene(scene, resolve) {
36
+ if (scene.format !== "pixel-sprite-scene/0.1")
37
+ throw new Error("expected pixel-sprite-scene/0.1");
38
+ const assets = new Map();
39
+ const ids = new Set();
40
+ for (const p of scene.placements) {
41
+ if (ids.has(p.id))
42
+ throw new Error(`duplicate placement ${p.id}`);
43
+ ids.add(p.id);
44
+ if (assets.has(p.asset))
45
+ continue;
46
+ const meta = Object.hasOwn(scene.assets, p.asset) ? scene.assets[p.asset] : null;
47
+ if (!meta)
48
+ throw new Error(`placement ${p.id}: asset ${p.asset} is not declared in assets`);
49
+ const { spec, motion = null } = resolve(p.asset);
50
+ assets.set(p.asset, {
51
+ ref: p.asset,
52
+ spec,
53
+ compiled: compileAsset(spec),
54
+ motion,
55
+ sprite: meta.sprite,
56
+ states: meta.states ?? {},
57
+ });
58
+ }
59
+ const placements = scene.placements.map((p) => ({ ...p, yaw: p.yaw ?? 0, tint: p.tint ?? null }));
60
+ return { scene, assets, placements };
61
+ }
62
+ const anglesOf = (view) => typeof view.camera === "string" ? requireValue(PRESETS[view.camera]) : [view.camera.yaw, view.camera.pitch];
63
+ export const cameraOf = (camera) => typeof camera === "string" ? requireValue(PIXEL_CAMERAS[camera]) : pixelCamera(camera.yaw, camera.pitch);
64
+ /** Name of the asset view for a scene view and state ("dimetric", "dimetric-open"). */
65
+ export const assetViewName = (view, state = "idle") => state === "idle" ? view : `${view}-${state}`;
66
+ /** Cel id a placement shows in a scene view and state. */
67
+ export const celIdFor = (view, state = "idle") => `${view}/${state}/0`;
68
+ /**
69
+ * The asset-local view a placement needs: a placement with yaw ψ seen by a
70
+ * camera of yaw φ is its asset seen from yaw φ − ψ at the same pitch.
71
+ */
72
+ export function placementView(loaded, placement, view, state = "idle") {
73
+ const v = requireValue(loaded.scene.views[view]);
74
+ const [yaw, pitch] = anglesOf(requireValue(v));
75
+ const local = (((yaw - placement.yaw) % 360) + 360) % 360;
76
+ const out = {
77
+ camera: placement.yaw === 0 && typeof v.camera === "string" ? v.camera : { yaw: local, pitch },
78
+ pixelsPerMetre: loaded.scene.pixelsPerMetre,
79
+ };
80
+ const hide = (v.hide ?? [])
81
+ .filter((h) => h.startsWith(`${placement.id}/`))
82
+ .map((h) => h.slice(placement.id.length + 1));
83
+ if (hide.length)
84
+ out.hide = hide;
85
+ if (state !== "idle") {
86
+ const pose = requireValue(loaded.assets.get(placement.asset)).states[state];
87
+ if (!pose)
88
+ throw new Error(`asset ${placement.asset} has no state ${state}`);
89
+ out.pose = pose;
90
+ }
91
+ return out;
92
+ }
93
+ /** The states an asset offers, idle first. */
94
+ export const statesOf = (loaded, ref) => [
95
+ "idle",
96
+ ...Object.keys(requireValue(loaded.assets.get(ref)).states),
97
+ ];
98
+ /** The `views` object an asset's sprite document must declare, and the cels it must contain. */
99
+ export function assetViews(loaded, ref) {
100
+ const views = Object.create(null);
101
+ const cels = [];
102
+ for (const p of loaded.placements.filter((q) => q.asset === ref))
103
+ for (const view of Object.keys(loaded.scene.views))
104
+ for (const state of statesOf(loaded, ref)) {
105
+ const name = assetViewName(view, state);
106
+ const v = placementView(loaded, p, view, state);
107
+ if (views[name] && !structurallyEqual(views[name], v))
108
+ throw new Error(`placements of ${ref} need different ${name} views; give them separate documents`);
109
+ if (!views[name])
110
+ cels.push(celIdFor(view, state));
111
+ views[name] = v;
112
+ }
113
+ return { views, cels };
114
+ }
115
+ const cutBy = (hide) => (inst) => hide.some((h) => {
116
+ const slash = h.indexOf("/");
117
+ return inst.id.startsWith(`${h.slice(0, slash)}/`) && inst.part === h.slice(slash + 1);
118
+ });
119
+ /** Scene instances for a view (cutaways applied), optionally only some placements, with states. */
120
+ export function sceneInstances(loaded, view, { only = null, states = {} } = {}) {
121
+ const placements = loaded.placements
122
+ .filter((p) => !only || only.includes(p.id))
123
+ .map((p) => {
124
+ const a = requireValue(loaded.assets.get(p.asset));
125
+ const state = states[p.id] ?? "idle";
126
+ const asset = state === "idle" ? a.compiled : posedAsset(a.compiled, a.motion, a.states[state]);
127
+ return { id: p.id, asset, position: p.position, yawDegrees: p.yaw, tint: p.tint };
128
+ });
129
+ const cut = cutBy(requireValue(loaded.scene.views[view]).hide ?? []);
130
+ return composePixelScene(placements).filter((inst) => !cut(inst));
131
+ }
132
+ /**
133
+ * The scene's pixel grid in a view: canvas size and the world position of its
134
+ * top-left corner along camera right and up, fitted to every placement at rest.
135
+ */
136
+ export function sceneFrame(loaded, view, margin = 6) {
137
+ const camera = cameraOf(requireValue(loaded.scene.views[view]).camera);
138
+ const ppm = loaded.scene.pixelsPerMetre;
139
+ if (!Number.isFinite(ppm) || ppm <= 0 || !Number.isSafeInteger(margin) || margin < 0)
140
+ throw new Error("scene frame needs positive finite pixelsPerMetre and a non-negative safe integer margin");
141
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
142
+ for (const { mesh } of sceneInstances(loaded, view))
143
+ for (let v = 0; v < mesh.positions.length; v += 3) {
144
+ const p = [mesh.positions[v], mesh.positions[v + 1], mesh.positions[v + 2]];
145
+ minX = Math.min(minX, dot(p, camera.right));
146
+ maxX = Math.max(maxX, dot(p, camera.right));
147
+ minY = Math.min(minY, dot(p, camera.up));
148
+ maxY = Math.max(maxY, dot(p, camera.up));
149
+ }
150
+ if (minX === Infinity)
151
+ throw new Error(`scene view ${view} has an empty projection`);
152
+ const width = Math.ceil((maxX - minX) * ppm) + 2 * margin;
153
+ const height = Math.ceil((maxY - minY) * ppm) + 2 * margin;
154
+ checkFrameSize(width, height);
155
+ const originX = minX - Math.floor((width - (maxX - minX) * ppm) / 2) / ppm;
156
+ const originY = maxY + Math.floor((height - (maxY - minY) * ppm) / 2) / ppm;
157
+ const cx = originX + width / 2 / ppm, cy = originY - height / 2 / ppm;
158
+ const center = [0, 1, 2].map((i) => requireValue(camera.right[i]) * cx + requireValue(camera.up[i]) * cy);
159
+ return { camera, ppm, width, height, originX, originY, frame: { center, width, height } };
160
+ }
161
+ /** 3D reference render (arcade profile) of the scene, or of some placements, on the scene grid. */
162
+ export function sceneRender(loaded, view, { only = null, states = {}, background = null, } = {}) {
163
+ const f = sceneFrame(loaded, view);
164
+ return renderPixelArt(sceneInstances(loaded, view, { only, states }), {
165
+ camera: f.camera,
166
+ pixelsPerMetre: f.ppm,
167
+ frame: f.frame,
168
+ profile: PIXEL_PROFILES.arcade,
169
+ background,
170
+ });
171
+ }
172
+ /** Screen offset (pixels, unrounded) of a placement's asset-local origin on the scene grid. */
173
+ export function placementOffset(loaded, placement, view) {
174
+ const f = sceneFrame(loaded, view);
175
+ return {
176
+ x: (dot(placement.position, f.camera.right) - f.originX) * f.ppm,
177
+ y: (f.originY - dot(placement.position, f.camera.up)) * f.ppm,
178
+ depth: dot(placement.position, f.camera.back),
179
+ };
180
+ }
181
+ // ---------- shared palette ----------
182
+ const srgbToLinear = (v) => ((v /= 255) <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
183
+ const linearOf = (hex) => [1, 3, 5].map((i) => srgbToLinear(parseInt(hex.slice(i, i + 2), 16)));
184
+ const labDistance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
185
+ /** Materials closer than this in OKLab share one ramp. */
186
+ export const MERGE_DISTANCE = 0.015;
187
+ /**
188
+ * One locked palette for the whole scene: four tones (deep, shadow, mid,
189
+ * light) per material colour as placed (tints applied), from the /pixel arcade
190
+ * ramp, with near-identical materials merged, plus the shared outline `line`.
191
+ * Returns the palette, and per asset the ramp of each material.
192
+ */
193
+ export function scenePalette(loaded) {
194
+ const entries = []; // { ref, material, name, linear, lab }
195
+ const tintOf = new Map();
196
+ for (const p of loaded.placements) {
197
+ const prev = tintOf.get(p.asset);
198
+ if (prev !== undefined && prev !== p.tint)
199
+ throw new Error(`placements of ${p.asset} use different tints; one sprite document cannot serve both`);
200
+ tintOf.set(p.asset, p.tint);
201
+ }
202
+ for (const [ref, a] of loaded.assets) {
203
+ const tint = tintOf.get(ref);
204
+ for (const [material, m] of Object.entries(a.compiled.materials)) {
205
+ let lin = linearOf(m.color);
206
+ if (m.tintable && tint)
207
+ lin = lin.map((v, i) => v * requireValue(linearOf(tint)[i]));
208
+ entries.push({ ref, material, linear: lin, lab: linearToOklab(lin) });
209
+ }
210
+ }
211
+ const clusters = [];
212
+ for (const e of entries) {
213
+ const c = clusters.find((k) => labDistance(k.lab, e.lab) < MERGE_DISTANCE);
214
+ if (c)
215
+ c.members.push(e);
216
+ else
217
+ clusters.push({ lab: e.lab, linear: e.linear, members: [e] });
218
+ }
219
+ const ramps = Object.create(null);
220
+ const byAsset = Object.create(null);
221
+ const taken = new Map();
222
+ for (const c of clusters) {
223
+ const base = colourName(c.lab);
224
+ let name = base;
225
+ for (let k = 2; taken.has(name); k++)
226
+ name = `${base}-${k}`;
227
+ taken.set(name, c);
228
+ ramps[name] = pixelRamp(c.linear, PIXEL_PROFILES.arcade).slice(1);
229
+ for (const m of c.members)
230
+ (byAsset[m.ref] ??= {})[m.material] = name;
231
+ }
232
+ return {
233
+ palette: { lock: true, colors: { line: loaded.scene.outline }, ramps },
234
+ materials: byAsset,
235
+ };
236
+ }
237
+ /**
238
+ * A short descriptive name for a colour from its OKLab value ("pale-teal",
239
+ * "dark-red", "cream", "charcoal"), so ramps read as what they look like.
240
+ */
241
+ export function colourName([L, a, b]) {
242
+ const C = Math.hypot(a, b);
243
+ const h = ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360;
244
+ if (C < 0.025)
245
+ return L > 0.9 ? "white" : L > 0.75 ? "light-grey" : L > 0.45 ? "grey" : "charcoal";
246
+ if (h >= 40 && h < 105 && C < 0.1)
247
+ return L > 0.85 ? "cream" : L > 0.68 ? "sand" : L > 0.5 ? "tan" : L > 0.38 ? "brown" : "dark-brown";
248
+ const hue = h < 15 || h >= 345
249
+ ? "pink"
250
+ : h < 40
251
+ ? "red"
252
+ : h < 75
253
+ ? "orange"
254
+ : h < 110
255
+ ? "yellow"
256
+ : h < 150
257
+ ? "olive"
258
+ : h < 175
259
+ ? "green"
260
+ : h < 215
261
+ ? "teal"
262
+ : h < 270
263
+ ? "blue"
264
+ : h < 310
265
+ ? "purple"
266
+ : "magenta";
267
+ return L > 0.85 ? `pale-${hue}` : L < 0.5 ? `dark-${hue}` : hue;
268
+ }
269
+ /** The palette subset an asset's document uses: its materials' ramps and the outline. */
270
+ export function assetPalette(shared, ref) {
271
+ const names = [...new Set(Object.values(requireValue(shared.materials[ref])))].sort();
272
+ return {
273
+ lock: true,
274
+ colors: { ...shared.palette.colors },
275
+ ramps: Object.fromEntries(names.map((n) => [n, requireValue(shared.palette.ramps[n])])),
276
+ };
277
+ }
278
+ /** Per part of an asset: its material and the scene ramp that material uses. */
279
+ export function partRamps(loaded, shared, ref) {
280
+ const spec = requireValue(loaded.assets.get(ref)).spec;
281
+ return Object.fromEntries(Object.entries(spec.parts ?? {}).map(([part, def]) => [
282
+ part,
283
+ { material: def.material, ramp: requireValue(shared.materials[ref])[def.material] ?? null },
284
+ ]));
285
+ }
286
+ /** Compiles one asset's document with the kitchen resolvers and checks it against the scene's contract. */
287
+ export function compileAssetSprite(loaded, shared, ref, input) {
288
+ const doc = input;
289
+ const result = compileSprite(doc, {
290
+ renderBase: spriteRenderResolver(requireValue(loaded.assets.get(ref)).compiled),
291
+ guide: spriteGuideResolver(requireValue(loaded.assets.get(ref)).compiled, {
292
+ motion: requireValue(loaded.assets.get(ref)).motion,
293
+ }),
294
+ });
295
+ const problems = [];
296
+ if (doc.asset !== ref)
297
+ problems.push(`asset must be ${ref}`);
298
+ const { views, cels } = assetViews(loaded, ref);
299
+ for (const [name, v] of Object.entries(views))
300
+ if (!structurallyEqual(doc.views?.[name], v))
301
+ problems.push(`views.${name} must be ${JSON.stringify(v)}`);
302
+ const requiredViews = new Map(Object.keys(loaded.scene.views).flatMap((view) => statesOf(loaded, ref).map((state) => [celIdFor(view, state), assetViewName(view, state)])));
303
+ for (const id of cels) {
304
+ const cel = result.cels.get(id);
305
+ if (!cel) {
306
+ problems.push(`missing cel ${id}`);
307
+ continue;
308
+ }
309
+ const name = requireValue(requiredViews.get(id));
310
+ const guide = result.guides.get(name);
311
+ if (cel.view !== name)
312
+ problems.push(`cel ${id} must be drawn in view ${name}`);
313
+ if (!guide)
314
+ problems.push(`cel ${id} needs a resolved ${name} guide`);
315
+ else if (cel.canvas.width !== guide.width || cel.canvas.height !== guide.height)
316
+ problems.push(`cel ${id} size must match the ${name} guide`);
317
+ }
318
+ const want = assetPalette(shared, ref);
319
+ const pal = doc.palette ?? {};
320
+ if (pal.lock !== true)
321
+ problems.push("palette.lock must be true");
322
+ if (!structurallyEqual(pal.colors, want.colors))
323
+ problems.push(`palette.colors must be ${JSON.stringify(want.colors)}`);
324
+ for (const [name, ramp] of Object.entries(pal.ramps ?? {}))
325
+ if (!structurallyEqual(shared.palette.ramps[name], ramp))
326
+ problems.push(`palette.ramps.${name} is not the scene ramp of that name`);
327
+ return { result, problems, cels };
328
+ }
329
+ /** Share of two placements' 3D overlap that must disagree before their draw order is reported as wrong. */
330
+ export const ORDER_SHARE = 0.25;
331
+ /** Depth toward the camera of a placement's bounds centre (posed bounds for a state). */
332
+ function centreDepth(loaded, p, camera, state) {
333
+ const a0 = requireValue(loaded.assets.get(p.asset));
334
+ const b = state === "idle" ? a0.compiled.bounds : posedAsset(a0.compiled, a0.motion, a0.states[state]).bounds;
335
+ const a = (p.yaw * Math.PI) / 180, c = Math.cos(a), s = Math.sin(a);
336
+ const m = [0, 1, 2].map((i) => (requireValue(b.min[i]) + requireValue(b.max[i])) / 2);
337
+ const centre = [
338
+ c * m[0] + s * m[2] + p.position[0],
339
+ m[1] + p.position[1],
340
+ -s * m[0] + c * m[2] + p.position[2],
341
+ ];
342
+ return dot(centre, camera.back);
343
+ }
344
+ /** Back-to-front order: "back" placements (the room) first, then by bounds-centre depth. */
345
+ export function drawOrder(loaded, view, states = {}) {
346
+ const f = sceneFrame(loaded, view);
347
+ const key = new Map(loaded.placements.map((p) => [p.id, centreDepth(loaded, p, f.camera, states[p.id] ?? "idle")]));
348
+ return [...loaded.placements].sort((p, q) => Number(q.order === "back") - Number(p.order === "back") ||
349
+ requireValue(key.get(p.id)) - requireValue(key.get(q.id)) ||
350
+ (p.id < q.id ? -1 : 1));
351
+ }
352
+ /**
353
+ * The layers of one view: per placement, its cel for the state in `states`
354
+ * (or, with `allStates`, one layer for idle and each state the placement
355
+ * offers in its `states` list), placed
356
+ * by its guide's origin, with a 3D depth per pixel. `depth: "parts"` (default)
357
+ * takes the depth of the guide's projected surface under each pixel (plus the
358
+ * placement's translation), else the mean depth of the part that owns it, else
359
+ * the nearest among its neighbours (outlines, details drawn beyond the
360
+ * projection), else the placement's centre. `depth: "placements"` gives every
361
+ * pixel the placement's centre depth. Placements without a compiled sprite are
362
+ * skipped and listed. The result can be composed many times, with different
363
+ * states, by `composeSpriteLayers` (for example in a browser).
364
+ */
365
+ export function spriteSceneLayers(loaded, view, sprites, { states = {}, depth = "parts", allStates = false } = {}) {
366
+ const f = sceneFrame(loaded, view);
367
+ const layers = [];
368
+ const skipped = [];
369
+ for (const p of loaded.placements) {
370
+ const s = sprites.get(p.asset);
371
+ let selectedAvailable = false;
372
+ // With allStates: idle plus the states this placement offers (its `states` list; none without one).
373
+ const offered = statesOf(loaded, p.asset);
374
+ const wanted = allStates
375
+ ? ["idle", ...(p.states ?? []).filter((st) => offered.includes(st))]
376
+ : [states[p.id] ?? "idle"];
377
+ for (const state of wanted) {
378
+ const cel = s?.cels.get(celIdFor(view, state));
379
+ const guide = s?.guides.get(state === "idle" ? view : `${view}-${state}`);
380
+ if (!s || !cel || !guide)
381
+ continue;
382
+ if (state === (states[p.id] ?? "idle"))
383
+ selectedAvailable = true;
384
+ const o = placementOffset(loaded, p, view);
385
+ // o is the placement's origin on the scene grid; the guide origin is the
386
+ // cel's top-left in the asset's own frame (metres along camera right/up).
387
+ const x0 = Math.round(o.x + requireValue(guide.originX) * f.ppm);
388
+ const y0 = Math.round(o.y - requireValue(guide.originY) * f.ppm);
389
+ const c = cel.canvas;
390
+ const n = c.width * c.height;
391
+ const z = new Float64Array(n).fill(NaN);
392
+ const back = p.order === "back";
393
+ const fallback = centreDepth(loaded, p, f.camera, state);
394
+ for (let i = 0; i < n; i++) {
395
+ if (!c.rgba[i * 4 + 3])
396
+ continue;
397
+ if (back || depth !== "parts") {
398
+ z[i] = back ? -Infinity : fallback;
399
+ continue;
400
+ }
401
+ // The 3D surface under this pixel decides; where the drawing goes beyond
402
+ // the projection, the part's mean depth; otherwise the neighbours'.
403
+ const gd = requireValue(guide.depth && c.width === guide.width && c.height === guide.height ? guide.depth[i] : NaN);
404
+ if (Number.isFinite(gd)) {
405
+ z[i] = gd + o.depth;
406
+ continue;
407
+ }
408
+ const name = s.parts[c.owners[i] ?? -1];
409
+ const g = name
410
+ ? Object.hasOwn(guide.parts, name)
411
+ ? guide.parts[name]
412
+ : guide.parts[name.split("/")[0] ?? ""]
413
+ : null;
414
+ if (g && Number.isFinite(g.z))
415
+ z[i] = requireValue(g.z) + o.depth;
416
+ }
417
+ for (let pass = 0; pass < 3; pass++) {
418
+ const next = Float64Array.from(z);
419
+ for (let i = 0; i < n; i++) {
420
+ if (!c.rgba[i * 4 + 3] || !Number.isNaN(z[i]))
421
+ continue;
422
+ const x = i % c.width, y = (i / c.width) | 0;
423
+ let best = NaN;
424
+ for (const [dx, dy] of [
425
+ [-1, 0],
426
+ [1, 0],
427
+ [0, -1],
428
+ [0, 1],
429
+ ]) {
430
+ const X = x + dx, Y = y + dy;
431
+ if (X < 0 || Y < 0 || X >= c.width || Y >= c.height)
432
+ continue;
433
+ const v = requireValue(z[Y * c.width + X]);
434
+ if (!Number.isNaN(v) && !(v <= best))
435
+ best = v;
436
+ }
437
+ next[i] = best;
438
+ }
439
+ z.set(next);
440
+ }
441
+ for (let i = 0; i < n; i++)
442
+ if (c.rgba[i * 4 + 3] && Number.isNaN(z[i]))
443
+ z[i] = fallback;
444
+ layers.push({
445
+ id: p.id,
446
+ asset: p.asset,
447
+ state,
448
+ cel: cel.id,
449
+ at: [x0, y0],
450
+ width: c.width,
451
+ height: c.height,
452
+ rgba: c.rgba,
453
+ owners: c.owners,
454
+ parts: s.parts,
455
+ depth: z,
456
+ back,
457
+ order: fallback,
458
+ });
459
+ }
460
+ if (!selectedAvailable)
461
+ skipped.push(p.id);
462
+ }
463
+ return { view, width: f.width, height: f.height, layers, skipped };
464
+ }
465
+ /**
466
+ * Composes layers (from `spriteSceneLayers`) with a depth buffer: per
467
+ * placement the layer for its state in `states` (default "idle"), drawn
468
+ * "back" placements first, then by the order key and id, keeping the nearest
469
+ * pixel (later layers win ties). Host independent and cheap enough to run on
470
+ * every toggle in a browser.
471
+ */
472
+ export function composeSpriteLayers(built, { states = {}, background = null } = {}) {
473
+ const W = built.width, H = built.height;
474
+ checkFrameSize(W, H);
475
+ const rgba = new Uint8Array(W * H * 4);
476
+ if (background) {
477
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(background.slice(i, i + 2), 16));
478
+ for (let i = 0; i < W * H; i++)
479
+ rgba.set([requireValue(r), requireValue(g), requireValue(b), 255], i * 4);
480
+ }
481
+ const owner = new Int32Array(W * H).fill(-1); // index into `placed`
482
+ const part = new Array(W * H).fill(null);
483
+ const zbuf = new Float64Array(W * H).fill(-Infinity);
484
+ const placed = [];
485
+ const chosen = built.layers
486
+ .filter((l) => l.state === (states[l.id] ?? "idle"))
487
+ .sort((p, q) => Number(q.back) - Number(p.back) || p.order - q.order || (p.id < q.id ? -1 : 1));
488
+ const drawn = new Set(chosen.map((l) => l.id));
489
+ const skipped = [
490
+ ...built.skipped.filter((id) => !drawn.has(id)),
491
+ ...[...new Set(built.layers.map((l) => l.id))].filter((id) => !drawn.has(id) && !built.skipped.includes(id)),
492
+ ];
493
+ for (const l of chosen) {
494
+ const index = placed.length;
495
+ const mask = [];
496
+ const [x0, y0] = l.at;
497
+ for (let y = 0; y < l.height; y++)
498
+ for (let x = 0; x < l.width; x++) {
499
+ const i = y * l.width + x;
500
+ const a = l.rgba[i * 4 + 3];
501
+ if (!a)
502
+ continue;
503
+ const X = x0 + x, Y = y0 + y;
504
+ if (X < 0 || Y < 0 || X >= W || Y >= H)
505
+ continue;
506
+ const q = Y * W + X;
507
+ mask.push(q);
508
+ const zi = requireValue(l.depth[i]);
509
+ if (zi < requireValue(zbuf[q]))
510
+ continue;
511
+ zbuf[q] = zi;
512
+ const src = i * 4;
513
+ if (a === 255)
514
+ rgba.set(l.rgba.subarray(src, src + 4), q * 4);
515
+ else {
516
+ const sourceAlpha = a / 255;
517
+ const destinationAlpha = requireValue(rgba[q * 4 + 3]) / 255;
518
+ const remaining = destinationAlpha * (1 - sourceAlpha);
519
+ const alpha = sourceAlpha + remaining;
520
+ for (let k = 0; k < 3; k++)
521
+ rgba[q * 4 + k] = Math.round((requireValue(l.rgba[src + k]) * sourceAlpha + requireValue(rgba[q * 4 + k]) * remaining) /
522
+ alpha);
523
+ rgba[q * 4 + 3] = Math.round(alpha * 255);
524
+ }
525
+ owner[q] = index;
526
+ part[q] = l.parts[l.owners[i] ?? -1] ?? null;
527
+ }
528
+ placed.push({
529
+ id: l.id,
530
+ asset: l.asset,
531
+ state: l.state,
532
+ cel: l.cel,
533
+ at: [x0, y0],
534
+ size: [l.width, l.height],
535
+ mask,
536
+ });
537
+ }
538
+ return { view: built.view, width: W, height: H, rgba, owner, part, placed, skipped };
539
+ }
540
+ /**
541
+ * Composes one view. `sprites` maps an asset ref to its compiled result;
542
+ * `states` maps a placement id to a state ("open"). Equivalent to
543
+ * `composeSpriteLayers(spriteSceneLayers(...))`: pixels sort by the 3D depth
544
+ * under them, so a non-convex object such as an open door sorts pixel by
545
+ * pixel; "back" placements are always behind; `depth: "placements"` is plain
546
+ * back-to-front drawing by bounds centre.
547
+ */
548
+ export function composeSpriteScene(loaded, view, sprites, { states = {}, background = null, depth = "parts" } = {}) {
549
+ return composeSpriteLayers(spriteSceneLayers(loaded, view, sprites, { states, depth }), {
550
+ states,
551
+ background,
552
+ });
553
+ }
554
+ /** Placement and part under a scene pixel, or null. */
555
+ export function pickScene(composed, x, y) {
556
+ if (!Number.isInteger(x) ||
557
+ !Number.isInteger(y) ||
558
+ x < 0 ||
559
+ y < 0 ||
560
+ x >= composed.width ||
561
+ y >= composed.height)
562
+ return null;
563
+ const q = y * composed.width + x;
564
+ const i = requireValue(composed.owner[q]);
565
+ return i < 0 ? null : { placement: requireValue(composed.placed[i]).id, part: composed.part[q] ?? null };
566
+ }
567
+ function maskOf(owners, pred) {
568
+ const m = new Set();
569
+ for (let q = 0; q < owners.length; q++)
570
+ if (requireValue(owners[q]) >= 0 && pred(requireValue(owners[q])))
571
+ m.add(q);
572
+ return m;
573
+ }
574
+ /**
575
+ * Footprint report: per placed sprite, overlap with the placement's own
576
+ * (unoccluded) 3D mask, the integer shift within ±3 px that maximises it, and
577
+ * draw-order disagreements with the 3D scene render.
578
+ */
579
+ export function spriteSceneFootprint(loaded, composed, { states = {} } = {}) {
580
+ const { view, width: W } = composed;
581
+ const full = sceneRender(loaded, view, { states });
582
+ const placementOfInstance = full.instances.map((i) => i.id.split("/")[0]);
583
+ const rows = [];
584
+ const masks3 = new Map();
585
+ for (const [index, pl] of composed.placed.entries()) {
586
+ const alone = sceneRender(loaded, view, { only: [pl.id], states });
587
+ const m3 = maskOf(alone.owners, () => true);
588
+ masks3.set(pl.id, m3);
589
+ const ms = new Set(pl.mask);
590
+ const iouAt = (dx, dy) => {
591
+ let inter = 0;
592
+ for (const q of ms) {
593
+ const x = (q % W) - dx, y = Math.floor(q / W) - dy;
594
+ if (m3.has(y * W + x))
595
+ inter++;
596
+ }
597
+ return inter / (ms.size + m3.size - inter || 1);
598
+ };
599
+ const iou = iouAt(0, 0);
600
+ let best = { dx: 0, dy: 0, iou };
601
+ for (let dy = -3; dy <= 3; dy++)
602
+ for (let dx = -3; dx <= 3; dx++) {
603
+ const v = iouAt(dx, dy);
604
+ if (v > best.iou + 1e-9)
605
+ best = { dx, dy, iou: v };
606
+ }
607
+ // A shift counts only if it improves the overlap noticeably.
608
+ const shift = best.iou - iou > 0.02 ? [best.dx, best.dy] : [0, 0];
609
+ rows.push({
610
+ placement: pl.id,
611
+ asset: pl.asset,
612
+ state: pl.state,
613
+ spritePixels: ms.size,
614
+ modelPixels: m3.size,
615
+ iou: +iou.toFixed(3),
616
+ bestShift: shift,
617
+ bestIou: +best.iou.toFixed(3),
618
+ misaligned: Math.max(Math.abs(requireValue(shift[0])), Math.abs(requireValue(shift[1]))) > 1,
619
+ index,
620
+ });
621
+ }
622
+ // Draw order: where both placements are present in 3D (inside both of their
623
+ // own masks) and both sprites are drawn, the sprite on top must be the one
624
+ // the 3D render shows in front. Outlines and exaggeration outside a
625
+ // placement's own mask are not order errors.
626
+ const spriteMasks = new Map(composed.placed.map((p) => [p.id, new Set(p.mask)]));
627
+ const pairs = new Map();
628
+ for (let q = 0; q < composed.owner.length; q++) {
629
+ const top = requireValue(composed.owner[q]);
630
+ const o3 = requireValue(full.owners[q]);
631
+ if (top < 0 || o3 < 0)
632
+ continue;
633
+ const front3 = placementOfInstance[o3];
634
+ const topId = requireValue(composed.placed[top]).id;
635
+ if (front3 === topId)
636
+ continue;
637
+ if (!masks3.get(topId)?.has(q) || !masks3.get(front3)?.has(q))
638
+ continue;
639
+ if (!spriteMasks.get(requireValue(front3))?.has(q))
640
+ continue;
641
+ const key = `${front3} over ${topId}`;
642
+ pairs.set(key, (pairs.get(key) ?? 0) + 1);
643
+ }
644
+ // A pair is reported with the share of its 3D overlap that disagrees; small
645
+ // shares are edge pixels whose majority owner changes with the background.
646
+ const order = [...pairs.entries()]
647
+ .map(([pair, pixels]) => {
648
+ const [front, top] = pair.split(" over ");
649
+ const a = masks3.get(front), b = masks3.get(top);
650
+ let both = 0;
651
+ for (const q of b)
652
+ if (a.has(q))
653
+ both++;
654
+ return { pair, pixels, share: +(pixels / (both || 1)).toFixed(3) };
655
+ })
656
+ .filter((p) => p.pixels >= 4)
657
+ .sort((a, b) => b.pixels - a.pixels);
658
+ return {
659
+ view,
660
+ placements: rows,
661
+ orderDisagreements: order,
662
+ wrongOrder: order.filter((p) => p.share > ORDER_SHARE && p.pixels >= 8),
663
+ };
664
+ }