@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.
- package/CHANGELOG.md +78 -0
- package/LICENSE +2 -0
- package/README.md +50 -2
- package/dist/bake.d.ts +80 -0
- package/dist/bake.js +214 -0
- package/dist/browser.d.ts +89 -0
- package/dist/browser.js +534 -0
- package/dist/compat.d.ts +17 -0
- package/dist/compat.js +153 -0
- package/dist/compiler.d.ts +18 -0
- package/dist/compiler.js +1264 -0
- package/dist/diagnostics.d.ts +5 -0
- package/dist/diagnostics.js +8 -0
- package/dist/export.d.ts +54 -0
- package/dist/export.js +272 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/legacy.d.ts +36 -0
- package/dist/legacy.js +180 -0
- package/dist/math.d.ts +16 -0
- package/dist/math.js +62 -0
- package/dist/mesh.d.ts +11 -0
- package/dist/mesh.js +153 -0
- package/dist/motion.d.ts +63 -0
- package/dist/motion.js +1476 -0
- package/dist/pixel.d.ts +268 -0
- package/dist/pixel.js +942 -0
- package/dist/required.d.ts +2 -0
- package/dist/required.js +6 -0
- package/dist/shapes.d.ts +71 -0
- package/dist/shapes.js +396 -0
- package/dist/sprite-brief.d.ts +5 -0
- package/dist/sprite-brief.js +47 -0
- package/dist/sprite-compiler.d.ts +62 -0
- package/dist/sprite-compiler.js +1745 -0
- package/dist/sprite-decompile.d.ts +68 -0
- package/dist/sprite-decompile.js +786 -0
- package/dist/sprite-format.d.ts +4 -0
- package/dist/sprite-format.js +155 -0
- package/dist/sprite-guide.d.ts +40 -0
- package/dist/sprite-guide.js +330 -0
- package/dist/sprite-scene.d.ts +277 -0
- package/dist/sprite-scene.js +664 -0
- package/dist/sprite-types.d.ts +110 -0
- package/dist/sprite-types.js +1 -0
- package/dist/sprite.d.ts +8 -0
- package/dist/sprite.js +8 -0
- package/dist/svg/schema.d.ts +71 -0
- package/dist/svg/schema.js +217 -0
- package/dist/svg/types.d.ts +148 -0
- package/dist/svg/types.js +1 -0
- package/dist/svg.d.ts +26 -0
- package/dist/svg.js +539 -0
- package/dist/text.d.ts +12 -0
- package/dist/text.js +75 -0
- package/dist/types.d.ts +478 -0
- package/dist/types.js +1 -0
- package/docs/svg/README.md +150 -0
- package/docs/svg/specification.md +108 -0
- package/examples/svg/arcane-shield.json +261 -0
- package/examples/svg/aurora-ribbon.json +158 -0
- package/examples/svg/bouncing-ember.json +76 -0
- package/examples/svg/ember-fountain.json +224 -0
- package/examples/svg/lantern-keeper.json +1159 -0
- package/examples/svg/night-workshop.json +1120 -0
- package/examples/svg/waygate-platform.json +64 -0
- package/examples/svg/waygate-spark.json +92 -0
- package/examples/svg/waygate-wisp.json +105 -0
- package/examples/svg/waygate.json +485 -0
- package/package.json +132 -4
- package/schemas/asset-assembly-0.1.schema.json +354 -0
- package/schemas/asset-assembly-0.2.schema.json +429 -0
- package/schemas/asset-assembly-0.3.schema.json +572 -0
- package/schemas/motion-set-0.1.schema.json +178 -0
- package/schemas/pixel-sprite-0.1.schema.json +892 -0
- package/schemas/pixel-sprite-scene-0.1.schema.json +149 -0
- package/schemas/svg-asset-0.1.schema.json +1157 -0
- 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
|
+
}
|