@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,786 @@
|
|
|
1
|
+
import { requireValue } from "./required.js";
|
|
2
|
+
// Lossless decompiler: existing pixel art (RGBA frames) to a structured
|
|
3
|
+
// pixel-sprite/0.1 document a model can read and edit. It finds the outline
|
|
4
|
+
// colour, groups colours into ramps (by hue and by which colours touch),
|
|
5
|
+
// splits the sprite into parts (connected regions of one ramp), fits exact
|
|
6
|
+
// shapes, explains shading with bevels where that saves pixels, and encodes
|
|
7
|
+
// animation frames as a shift plus per-part changes. Whatever the operations
|
|
8
|
+
// do not explain stays as literal pixels, so the result always compiles back
|
|
9
|
+
// to the input exactly; `decompile` verifies that before returning.
|
|
10
|
+
import { SPRITE_FORMAT, compileSprite, rgbToOklab, toHex } from "./sprite-compiler.js";
|
|
11
|
+
const N4 = [
|
|
12
|
+
[0, -1],
|
|
13
|
+
[-1, 0],
|
|
14
|
+
[1, 0],
|
|
15
|
+
[0, 1],
|
|
16
|
+
];
|
|
17
|
+
// Printable ASCII without '"', '\' and '.', most readable first.
|
|
18
|
+
const KEYS = [
|
|
19
|
+
..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&'()*+,-/:;<=>?@[]^_`{|}~!",
|
|
20
|
+
];
|
|
21
|
+
const hexAt = (rgba, p) => toHex([
|
|
22
|
+
requireValue(rgba[p * 4]),
|
|
23
|
+
requireValue(rgba[p * 4 + 1]),
|
|
24
|
+
requireValue(rgba[p * 4 + 2]),
|
|
25
|
+
requireValue(rgba[p * 4 + 3]),
|
|
26
|
+
]);
|
|
27
|
+
const lch = (hex) => {
|
|
28
|
+
const n = parseInt(hex.slice(1, 7), 16);
|
|
29
|
+
const [L, a, b] = rgbToOklab([(n >> 16) & 255, (n >> 8) & 255, n & 255]);
|
|
30
|
+
return { L, C: Math.hypot(a, b), h: ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360 };
|
|
31
|
+
};
|
|
32
|
+
const hueGap = (a, b) => Math.min(Math.abs(a - b), 360 - Math.abs(a - b));
|
|
33
|
+
function hueName({ L, C, h }) {
|
|
34
|
+
if (C < 0.035)
|
|
35
|
+
return L > 0.85 ? "white" : L < 0.3 ? "black" : "gray";
|
|
36
|
+
if (h >= 20 && h < 75 && L < 0.62)
|
|
37
|
+
return "brown";
|
|
38
|
+
const names = [
|
|
39
|
+
[40, "red"],
|
|
40
|
+
[75, "orange"],
|
|
41
|
+
[115, "yellow"],
|
|
42
|
+
[170, "green"],
|
|
43
|
+
[220, "teal"],
|
|
44
|
+
[285, "blue"],
|
|
45
|
+
[325, "purple"],
|
|
46
|
+
[360, "pink"],
|
|
47
|
+
];
|
|
48
|
+
return (names.find(([limit]) => h < limit) ?? [0, "red"])[1];
|
|
49
|
+
}
|
|
50
|
+
/** Colour statistics, outline colour and ramps for a set of frames. */
|
|
51
|
+
function analysePalette(frames) {
|
|
52
|
+
const count = new Map();
|
|
53
|
+
const silhouette = new Map();
|
|
54
|
+
const touch = new Map();
|
|
55
|
+
let silhouetteTotal = 0;
|
|
56
|
+
for (const f of frames) {
|
|
57
|
+
const { width: W, height: H, rgba } = f;
|
|
58
|
+
const op = (x, y) => x >= 0 && y >= 0 && x < W && y < H && requireValue(rgba[(y * W + x) * 4 + 3]) > 0;
|
|
59
|
+
for (let y = 0; y < H; y++)
|
|
60
|
+
for (let x = 0; x < W; x++) {
|
|
61
|
+
if (!op(x, y))
|
|
62
|
+
continue;
|
|
63
|
+
const c = hexAt(rgba, y * W + x);
|
|
64
|
+
count.set(c, (count.get(c) ?? 0) + 1);
|
|
65
|
+
if (N4.some(([dx, dy]) => !op(x + dx, y + dy))) {
|
|
66
|
+
silhouette.set(c, (silhouette.get(c) ?? 0) + 1);
|
|
67
|
+
silhouetteTotal++;
|
|
68
|
+
}
|
|
69
|
+
for (const [dx, dy] of [
|
|
70
|
+
[1, 0],
|
|
71
|
+
[0, 1],
|
|
72
|
+
])
|
|
73
|
+
if (op(x + dx, y + dy)) {
|
|
74
|
+
const n = hexAt(rgba, (y + dy) * W + x + dx);
|
|
75
|
+
if (n !== c) {
|
|
76
|
+
const k = c < n ? `${c}|${n}` : `${n}|${c}`;
|
|
77
|
+
touch.set(k, (touch.get(k) ?? 0) + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const colours = [...count.keys()].sort();
|
|
83
|
+
const info = new Map(colours.map((c) => [c, lch(c)]));
|
|
84
|
+
// Outline: the colour covering most of the silhouette, if it is one of the two darkest.
|
|
85
|
+
let outline = null;
|
|
86
|
+
const bySil = [...silhouette.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
|
|
87
|
+
const darkest = [...colours]
|
|
88
|
+
.sort((a, b) => requireValue(info.get(a)).L - requireValue(info.get(b)).L)
|
|
89
|
+
.slice(0, 2);
|
|
90
|
+
if (bySil.length &&
|
|
91
|
+
requireValue(bySil[0])[1] >= 0.5 * silhouetteTotal &&
|
|
92
|
+
darkest.includes(requireValue(bySil[0])[0]))
|
|
93
|
+
outline = requireValue(bySil[0])[0];
|
|
94
|
+
// Ramps: union colours that touch often and have compatible hue and chroma.
|
|
95
|
+
const parent = new Map(colours.map((c) => [c, c]));
|
|
96
|
+
const find = (c) => parent.get(c) === c ? c : (parent.set(c, find(requireValue(parent.get(c)))), requireValue(parent.get(c)));
|
|
97
|
+
const pairs = [...touch.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
|
|
98
|
+
for (const [k, n] of pairs) {
|
|
99
|
+
const [a0, b0] = k.split("|");
|
|
100
|
+
const a = requireValue(a0), b = requireValue(b0);
|
|
101
|
+
if (a === outline || b === outline)
|
|
102
|
+
continue;
|
|
103
|
+
const A = requireValue(info.get(a)), B = requireValue(info.get(b));
|
|
104
|
+
const small = Math.min(requireValue(count.get(requireValue(a))), requireValue(count.get(requireValue(b))));
|
|
105
|
+
if (n < Math.max(2, 0.05 * small))
|
|
106
|
+
continue;
|
|
107
|
+
const neutral = A.C < 0.035 && B.C < 0.035;
|
|
108
|
+
const compatible = neutral
|
|
109
|
+
? Math.abs(A.L - B.L) < 0.45
|
|
110
|
+
: Math.min(A.C, B.C) >= 0.02 && hueGap(A.h, B.h) < 38 && Math.abs(A.L - B.L) < 0.5;
|
|
111
|
+
if (compatible)
|
|
112
|
+
parent.set(find(requireValue(a)), find(requireValue(b)));
|
|
113
|
+
}
|
|
114
|
+
const groups = new Map();
|
|
115
|
+
for (const c of colours) {
|
|
116
|
+
if (c === outline)
|
|
117
|
+
continue;
|
|
118
|
+
const r = find(c);
|
|
119
|
+
if (!groups.has(r))
|
|
120
|
+
groups.set(r, []);
|
|
121
|
+
requireValue(groups.get(r)).push(c);
|
|
122
|
+
}
|
|
123
|
+
const ramps = [];
|
|
124
|
+
const used = new Map();
|
|
125
|
+
for (const members of [...groups.values()].sort((a, b) => b.reduce((s, c) => s + requireValue(count.get(c)), 0) -
|
|
126
|
+
a.reduce((s, c) => s + requireValue(count.get(c)), 0))) {
|
|
127
|
+
members.sort((a, b) => requireValue(info.get(a)).L - requireValue(info.get(b)).L);
|
|
128
|
+
const mode = [...members].sort((a, b) => requireValue(count.get(b)) - requireValue(count.get(a)))[0];
|
|
129
|
+
const base = hueName(requireValue(info.get(requireValue(mode))));
|
|
130
|
+
const k = (used.get(base) ?? 0) + 1;
|
|
131
|
+
used.set(base, k);
|
|
132
|
+
ramps.push({ name: k === 1 ? base : `${base}${k}`, colours: members });
|
|
133
|
+
}
|
|
134
|
+
const rampOf = new Map();
|
|
135
|
+
for (const r of ramps)
|
|
136
|
+
r.colours.forEach((c, i) => rampOf.set(c, { ramp: r.name, step: i }));
|
|
137
|
+
return { count, outline, ramps, rampOf, info };
|
|
138
|
+
}
|
|
139
|
+
/** Pixel set predicted by the compiler's ellipse rule on a bounding box. */
|
|
140
|
+
function ellipseSet(x, y, w, h) {
|
|
141
|
+
const s = new Set();
|
|
142
|
+
for (let j = 0; j < h; j++)
|
|
143
|
+
for (let i = 0; i < w; i++) {
|
|
144
|
+
const u = (i + 0.5 - w / 2) / (w / 2), v = (j + 0.5 - h / 2) / (h / 2);
|
|
145
|
+
if (u * u + v * v <= 1)
|
|
146
|
+
s.add(`${x + i},${y + j}`);
|
|
147
|
+
}
|
|
148
|
+
return s;
|
|
149
|
+
}
|
|
150
|
+
/** Splits one frame into parts: 4-connected regions of one ramp; tiny enclosed regions become details of their neighbour. */
|
|
151
|
+
function regions(frame, pal) {
|
|
152
|
+
const { width: W, height: H, rgba } = frame;
|
|
153
|
+
const label = new Int32Array(W * H).fill(-1);
|
|
154
|
+
const list = [];
|
|
155
|
+
for (let p = 0; p < W * H; p++) {
|
|
156
|
+
if (!rgba[p * 4 + 3] || requireValue(label[p]) >= 0)
|
|
157
|
+
continue;
|
|
158
|
+
const c = hexAt(rgba, p);
|
|
159
|
+
if (c === pal.outline)
|
|
160
|
+
continue;
|
|
161
|
+
const ramp = requireValue(pal.rampOf.get(c)).ramp;
|
|
162
|
+
const idx = list.length;
|
|
163
|
+
const pix = [];
|
|
164
|
+
const stack = [p];
|
|
165
|
+
label[p] = idx;
|
|
166
|
+
while (stack.length) {
|
|
167
|
+
const q = requireValue(stack.pop());
|
|
168
|
+
pix.push(requireValue(q));
|
|
169
|
+
const x = q % W, y = Math.floor(q / W);
|
|
170
|
+
for (const [dx, dy] of N4) {
|
|
171
|
+
const nx = x + dx, ny = y + dy, n = ny * W + nx;
|
|
172
|
+
if (nx < 0 || ny < 0 || nx >= W || ny >= H || requireValue(label[n]) >= 0 || !rgba[n * 4 + 3])
|
|
173
|
+
continue;
|
|
174
|
+
const nc = hexAt(rgba, n);
|
|
175
|
+
if (nc === pal.outline || requireValue(pal.rampOf.get(nc)).ramp !== ramp)
|
|
176
|
+
continue;
|
|
177
|
+
label[n] = idx;
|
|
178
|
+
stack.push(n);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
list.push({ ramp, pix, absorbed: false });
|
|
182
|
+
}
|
|
183
|
+
// Absorb regions of at most two pixels that touch exactly one other region (and nothing else but outline).
|
|
184
|
+
for (const [i, r] of list.entries()) {
|
|
185
|
+
if (r.pix.length > 2)
|
|
186
|
+
continue;
|
|
187
|
+
const touching = new Set();
|
|
188
|
+
let open = false;
|
|
189
|
+
for (const q of r.pix) {
|
|
190
|
+
const x = q % W, y = Math.floor(q / W);
|
|
191
|
+
for (const [dx, dy] of N4) {
|
|
192
|
+
const nx = x + dx, ny = y + dy, n = ny * W + nx;
|
|
193
|
+
if (nx < 0 || ny < 0 || nx >= W || ny >= H || !rgba[n * 4 + 3])
|
|
194
|
+
open = true;
|
|
195
|
+
else if (requireValue(label[n]) >= 0 && label[n] !== i)
|
|
196
|
+
touching.add(requireValue(label[n]));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!open && touching.size === 1) {
|
|
200
|
+
const host = requireValue([...touching][0]);
|
|
201
|
+
if (requireValue(list[host]).pix.length > 2) {
|
|
202
|
+
for (const q of r.pix)
|
|
203
|
+
label[q] = host;
|
|
204
|
+
requireValue(list[host]).pix.push(...r.pix);
|
|
205
|
+
r.absorbed = true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
let kept = splitBySeeds(frame, list.filter((r) => !r.absorbed));
|
|
210
|
+
// Very busy images (sheets, previews): one part per ramp keeps the document within budget.
|
|
211
|
+
if (kept.length > MAX_REGIONS) {
|
|
212
|
+
const byRamp = new Map();
|
|
213
|
+
for (const r of kept) {
|
|
214
|
+
if (!byRamp.has(r.ramp))
|
|
215
|
+
byRamp.set(r.ramp, { ramp: r.ramp, pix: [], absorbed: false });
|
|
216
|
+
for (const q of r.pix)
|
|
217
|
+
requireValue(byRamp.get(r.ramp)).pix.push(q);
|
|
218
|
+
}
|
|
219
|
+
kept = [...byRamp.values()];
|
|
220
|
+
}
|
|
221
|
+
return { label, list: kept, all: list };
|
|
222
|
+
}
|
|
223
|
+
/** Above this many regions, regions merge per ramp. */
|
|
224
|
+
const MAX_REGIONS = 150;
|
|
225
|
+
/** Frame-0 layers allowed before falling back to literal rows (the compiler accepts 512). */
|
|
226
|
+
const LAYER_BUDGET = 480;
|
|
227
|
+
/**
|
|
228
|
+
* Splits a ramp region where two or more thick single-colour areas each cover
|
|
229
|
+
* a substantial share of it (a light frame around a darker panel, say). Other
|
|
230
|
+
* pixels join the nearest area by breadth-first distance inside the region.
|
|
231
|
+
* One-pixel lines such as highlights never start a split.
|
|
232
|
+
*/
|
|
233
|
+
function splitBySeeds(frame, list) {
|
|
234
|
+
const { width: W, rgba } = frame;
|
|
235
|
+
const out = [];
|
|
236
|
+
for (const r of list) {
|
|
237
|
+
const set = new Set(r.pix);
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
const seeds = [];
|
|
240
|
+
for (const p of r.pix) {
|
|
241
|
+
if (seen.has(p))
|
|
242
|
+
continue;
|
|
243
|
+
const c = hexAt(rgba, p);
|
|
244
|
+
const comp = [];
|
|
245
|
+
const stack = [p];
|
|
246
|
+
seen.add(p);
|
|
247
|
+
while (stack.length) {
|
|
248
|
+
const q = requireValue(stack.pop());
|
|
249
|
+
comp.push(q);
|
|
250
|
+
for (const [dx, dy] of N4) {
|
|
251
|
+
const x = (q % W) + dx, n = q + dy * W + dx;
|
|
252
|
+
if (x < 0 || x >= W || !set.has(n) || seen.has(n) || hexAt(rgba, n) !== c)
|
|
253
|
+
continue;
|
|
254
|
+
seen.add(n);
|
|
255
|
+
stack.push(n);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Thick: at least two pixels across in both directions, and filled
|
|
259
|
+
// (area of at least twice the longer side), so one-pixel lines never qualify.
|
|
260
|
+
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
|
|
261
|
+
for (const q of comp) {
|
|
262
|
+
bx0 = Math.min(bx0, q % W);
|
|
263
|
+
bx1 = Math.max(bx1, q % W);
|
|
264
|
+
by0 = Math.min(by0, Math.floor(q / W));
|
|
265
|
+
by1 = Math.max(by1, Math.floor(q / W));
|
|
266
|
+
}
|
|
267
|
+
const bw = bx1 - bx0 + 1, bh = by1 - by0 + 1;
|
|
268
|
+
const thick = Math.min(bw, bh) >= 2 && comp.length >= 2 * Math.max(bw, bh);
|
|
269
|
+
if (thick && comp.length >= Math.max(12, 0.15 * r.pix.length))
|
|
270
|
+
seeds.push(comp);
|
|
271
|
+
}
|
|
272
|
+
const colours = new Set(seeds.map((c) => hexAt(rgba, requireValue(c[0]))));
|
|
273
|
+
if (seeds.length < 2 || colours.size < 2) {
|
|
274
|
+
out.push(r);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const owner = new Map();
|
|
278
|
+
let frontier = [];
|
|
279
|
+
seeds.forEach((c, i) => c.forEach((q) => (owner.set(q, i), frontier.push(q))));
|
|
280
|
+
while (frontier.length) {
|
|
281
|
+
const next = [];
|
|
282
|
+
for (const q of frontier)
|
|
283
|
+
for (const [dx, dy] of N4) {
|
|
284
|
+
const x = (q % W) + dx, n = q + dy * W + dx;
|
|
285
|
+
if (x < 0 || x >= W || !set.has(n) || owner.has(n))
|
|
286
|
+
continue;
|
|
287
|
+
owner.set(n, requireValue(owner.get(q)));
|
|
288
|
+
next.push(n);
|
|
289
|
+
}
|
|
290
|
+
frontier = next;
|
|
291
|
+
}
|
|
292
|
+
const parts = seeds.map(() => []);
|
|
293
|
+
for (const p of r.pix)
|
|
294
|
+
requireValue(parts[owner.get(p) ?? 0]).push(p);
|
|
295
|
+
for (const pix of parts)
|
|
296
|
+
if (pix.length)
|
|
297
|
+
out.push({ ramp: r.ramp, pix, absorbed: false });
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
/** Best bevel for a region: the variant that leaves the fewest residual pixels, or null if none helps. */
|
|
302
|
+
function bestBevel(frame, set, mode, rampColours, W) {
|
|
303
|
+
const { rgba } = frame;
|
|
304
|
+
const H = frame.height;
|
|
305
|
+
const inSet = (x, y) => x >= 0 && y >= 0 && x < W && y < H && set.has(y * W + x);
|
|
306
|
+
const modeIdx = rampColours.indexOf(mode);
|
|
307
|
+
const lighter = rampColours.slice(modeIdx + 1);
|
|
308
|
+
const darker = rampColours.slice(0, modeIdx);
|
|
309
|
+
const base = [...set].filter((p) => hexAt(rgba, p) !== mode).length;
|
|
310
|
+
let best = { residual: base, variant: null };
|
|
311
|
+
for (const [dir, [dx, dy]] of [
|
|
312
|
+
["upper-left", [-1, -1]],
|
|
313
|
+
["upper-right", [1, -1]],
|
|
314
|
+
])
|
|
315
|
+
for (const light of [null, ...lighter])
|
|
316
|
+
for (const shadow of [null, ...darker]) {
|
|
317
|
+
if (!light && !shadow)
|
|
318
|
+
continue;
|
|
319
|
+
let residual = 0;
|
|
320
|
+
for (const p of set) {
|
|
321
|
+
const x = p % W, y = Math.floor(p / W);
|
|
322
|
+
let side = null;
|
|
323
|
+
if (!inSet(x, y + dy))
|
|
324
|
+
side = "light";
|
|
325
|
+
else if (!inSet(x, y - dy))
|
|
326
|
+
side = "shadow";
|
|
327
|
+
else if (!inSet(x + dx, y))
|
|
328
|
+
side = "light";
|
|
329
|
+
else if (!inSet(x - dx, y))
|
|
330
|
+
side = "shadow";
|
|
331
|
+
const tone = side === "light" ? light : side === "shadow" ? shadow : null;
|
|
332
|
+
if ((tone ?? mode) !== hexAt(rgba, p))
|
|
333
|
+
residual++;
|
|
334
|
+
}
|
|
335
|
+
if (residual < best.residual)
|
|
336
|
+
best = { residual, variant: { dir, light, shadow } };
|
|
337
|
+
}
|
|
338
|
+
return best.variant ? { residual: best.residual, variant: best.variant } : null;
|
|
339
|
+
}
|
|
340
|
+
/** Rows of keys for a sparse set of pixels: [x0, y0, rows]. */
|
|
341
|
+
function patchRows(points, keyOf, skip) {
|
|
342
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
343
|
+
for (const [x, y] of points) {
|
|
344
|
+
x0 = Math.min(x0, x);
|
|
345
|
+
y0 = Math.min(y0, y);
|
|
346
|
+
x1 = Math.max(x1, x);
|
|
347
|
+
y1 = Math.max(y1, y);
|
|
348
|
+
}
|
|
349
|
+
const grid = Array.from({ length: y1 - y0 + 1 }, () => Array(x1 - x0 + 1).fill(skip));
|
|
350
|
+
for (const [x, y, c] of points)
|
|
351
|
+
requireValue(grid[y - y0])[x - x0] = keyOf(c);
|
|
352
|
+
return { at: [x0, y0], rows: grid.map((r) => r.join("")) };
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Decompiles frames of equal size. Returns { doc, summary, verified }. The
|
|
356
|
+
* document reproduces every frame exactly; `verified` is the compile check.
|
|
357
|
+
*/
|
|
358
|
+
export function decompileSprite(input, { id = "decompiled", animation = null, ms = 100 } = {}) {
|
|
359
|
+
if (!Array.isArray(input) || !input.length)
|
|
360
|
+
throw new RangeError("decompileSprite requires non-empty frames");
|
|
361
|
+
const first = requireValue(input[0]);
|
|
362
|
+
for (const f of input) {
|
|
363
|
+
if (!f ||
|
|
364
|
+
!Number.isInteger(f.width) ||
|
|
365
|
+
!Number.isInteger(f.height) ||
|
|
366
|
+
f.width < 1 ||
|
|
367
|
+
f.height < 1 ||
|
|
368
|
+
f.width > 1024 ||
|
|
369
|
+
f.height > 1024 ||
|
|
370
|
+
f.width !== first.width ||
|
|
371
|
+
f.height !== first.height ||
|
|
372
|
+
!(f.rgba instanceof Uint8Array) ||
|
|
373
|
+
f.rgba.length !== f.width * f.height * 4)
|
|
374
|
+
throw new RangeError("decompileSprite frames must have equal sizes within 1..1024 and complete RGBA data");
|
|
375
|
+
}
|
|
376
|
+
// The contract is exact for normalised RGBA: fully transparent pixels are 0,0,0,0.
|
|
377
|
+
let normalisedTransparent = 0;
|
|
378
|
+
const frames = input.map((f) => {
|
|
379
|
+
const rgba = new Uint8Array(f.rgba);
|
|
380
|
+
for (let i = 3; i < rgba.length; i += 4)
|
|
381
|
+
if (rgba[i] === 0 && (rgba[i - 1] || rgba[i - 2] || rgba[i - 3])) {
|
|
382
|
+
rgba.fill(0, i - 3, i + 1);
|
|
383
|
+
normalisedTransparent++;
|
|
384
|
+
}
|
|
385
|
+
return { width: f.width, height: f.height, rgba };
|
|
386
|
+
});
|
|
387
|
+
const W = requireValue(frames[0]).width, H = requireValue(frames[0]).height;
|
|
388
|
+
const pal = analysePalette(frames);
|
|
389
|
+
// Keys: ramp colours first (mnemonic where possible), then the outline, then "clear".
|
|
390
|
+
const keyOf = new Map();
|
|
391
|
+
const needed = pal.ramps.reduce((n, r) => n + r.colours.length, 0) + (pal.outline ? 1 : 0) + 1;
|
|
392
|
+
const keyWidth = needed <= KEYS.length ? 1 : 2;
|
|
393
|
+
const pool = keyWidth === 1 ? KEYS : KEYS.flatMap((a) => KEYS.map((b) => a + b));
|
|
394
|
+
const skip = ".".repeat(keyWidth);
|
|
395
|
+
const taken = new Set([skip]);
|
|
396
|
+
const take = (preferred) => {
|
|
397
|
+
const wanted = keyWidth === 1 ? [preferred, preferred?.toUpperCase()] : [];
|
|
398
|
+
const k = requireValue([...wanted, ...pool].find((c) => c && !taken.has(c)), "sprite has too many colours");
|
|
399
|
+
taken.add(k);
|
|
400
|
+
return k;
|
|
401
|
+
};
|
|
402
|
+
const refOf = new Map();
|
|
403
|
+
if (pal.outline) {
|
|
404
|
+
keyOf.set(pal.outline, take("o"));
|
|
405
|
+
refOf.set(pal.outline, "line");
|
|
406
|
+
}
|
|
407
|
+
for (const r of pal.ramps)
|
|
408
|
+
r.colours.forEach((c, i) => {
|
|
409
|
+
keyOf.set(c, take(r.name[0]));
|
|
410
|
+
refOf.set(c, `${r.name}.${i}`);
|
|
411
|
+
});
|
|
412
|
+
const clearKey = take("_");
|
|
413
|
+
const keys = Object.fromEntries([...keyOf.entries()].map(([c, k]) => [k, refOf.get(c)]));
|
|
414
|
+
keys[clearKey] = "clear";
|
|
415
|
+
const key = (c) => (c === "clear" ? clearKey : requireValue(keyOf.get(c)));
|
|
416
|
+
const palette = {
|
|
417
|
+
...(keyWidth > 1 ? { keyWidth } : {}),
|
|
418
|
+
...(pal.outline ? { colors: { line: pal.outline } } : {}),
|
|
419
|
+
ramps: Object.fromEntries(pal.ramps.map((r) => [r.name, r.colours])),
|
|
420
|
+
keys,
|
|
421
|
+
};
|
|
422
|
+
const summary = {
|
|
423
|
+
size: [W, H],
|
|
424
|
+
frames: frames.length,
|
|
425
|
+
outline: pal.outline,
|
|
426
|
+
ramps: [],
|
|
427
|
+
parts: [],
|
|
428
|
+
frameChanges: [],
|
|
429
|
+
normalisedTransparent,
|
|
430
|
+
fallback: null,
|
|
431
|
+
};
|
|
432
|
+
for (const r of pal.ramps)
|
|
433
|
+
summary.ramps.push({
|
|
434
|
+
name: r.name,
|
|
435
|
+
steps: r.colours.map((c) => ({ colour: c, pixels: requireValue(pal.count.get(c)) })),
|
|
436
|
+
});
|
|
437
|
+
// ---- frame 0: structured layers ----
|
|
438
|
+
const f0 = requireValue(frames[0]);
|
|
439
|
+
const reg = regions(requireValue(f0), pal);
|
|
440
|
+
const nameCount = new Map();
|
|
441
|
+
for (const r of reg.list)
|
|
442
|
+
nameCount.set(r.ramp, (nameCount.get(r.ramp) ?? 0) + 1);
|
|
443
|
+
const seen = new Map();
|
|
444
|
+
const ordered = [...reg.list].sort((a, b) => b.pix.length - a.pix.length || requireValue(a.pix[0]) - requireValue(b.pix[0]));
|
|
445
|
+
for (const r of ordered) {
|
|
446
|
+
const k = (seen.get(r.ramp) ?? 0) + 1;
|
|
447
|
+
seen.set(r.ramp, k);
|
|
448
|
+
r.name = nameCount.get(r.ramp) === 1 ? r.ramp : `${r.ramp}-${k}`;
|
|
449
|
+
}
|
|
450
|
+
const base = [], shading = [], fixes = [];
|
|
451
|
+
const predicted = new Map(); // pixel -> colour the structured layers produce
|
|
452
|
+
for (const r of ordered) {
|
|
453
|
+
const set = new Set(r.pix);
|
|
454
|
+
const colours = r.pix.map((p) => hexAt(f0.rgba, p));
|
|
455
|
+
const tally = new Map();
|
|
456
|
+
for (const c of colours)
|
|
457
|
+
tally.set(c, (tally.get(c) ?? 0) + 1);
|
|
458
|
+
const mode = requireValue([...tally.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))[0])[0];
|
|
459
|
+
const rampColours = requireValue(pal.ramps.find((x) => x.name === requireValue(pal.rampOf.get(mode)).ramp)).colours;
|
|
460
|
+
let x0 = W, y0 = H, x1 = -1, y1 = -1;
|
|
461
|
+
for (const p of r.pix) {
|
|
462
|
+
x0 = Math.min(x0, p % W);
|
|
463
|
+
y0 = Math.min(y0, Math.floor(p / W));
|
|
464
|
+
x1 = Math.max(x1, p % W);
|
|
465
|
+
y1 = Math.max(y1, Math.floor(p / W));
|
|
466
|
+
}
|
|
467
|
+
const bw = x1 - x0 + 1, bh = y1 - y0 + 1;
|
|
468
|
+
const keyset = new Set(r.pix.map((p) => `${p % W},${Math.floor(p / W)}`));
|
|
469
|
+
const sameSet = (s) => s.size === keyset.size && [...s].every((k) => keyset.has(k));
|
|
470
|
+
let shape = "mask";
|
|
471
|
+
const rectSet = (round) => {
|
|
472
|
+
const s = new Set();
|
|
473
|
+
for (let j = 0; j < bh; j++)
|
|
474
|
+
for (let i = 0; i < bw; i++)
|
|
475
|
+
if (!(round && (i === 0 || i === bw - 1) && (j === 0 || j === bh - 1)))
|
|
476
|
+
s.add(`${x0 + i},${y0 + j}`);
|
|
477
|
+
return s;
|
|
478
|
+
};
|
|
479
|
+
const fill = requireValue(refOf.get(mode));
|
|
480
|
+
if (sameSet(rectSet(false))) {
|
|
481
|
+
shape = "rect";
|
|
482
|
+
base.push({ part: r.name, rect: [x0, y0, bw, bh], fill });
|
|
483
|
+
}
|
|
484
|
+
else if (bw >= 3 && bh >= 3 && sameSet(rectSet(true))) {
|
|
485
|
+
shape = "rounded rect";
|
|
486
|
+
base.push({ part: r.name, rect: [x0, y0, bw, bh], fill, round: 1 });
|
|
487
|
+
}
|
|
488
|
+
else if (bw >= 3 && bh >= 3 && sameSet(ellipseSet(x0, y0, bw, bh))) {
|
|
489
|
+
shape = "ellipse";
|
|
490
|
+
base.push({ part: r.name, ellipse: [x0, y0, bw, bh], fill });
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const pr = patchRows(r.pix.map((p) => [p % W, Math.floor(p / W), mode]), key, skip);
|
|
494
|
+
base.push({ part: r.name, at: pr.at, pixels: pr.rows });
|
|
495
|
+
}
|
|
496
|
+
for (const p of r.pix)
|
|
497
|
+
predicted.set(p, mode);
|
|
498
|
+
const bev = rampColours.length > 1 ? bestBevel(f0, set, mode, rampColours, W) : null;
|
|
499
|
+
if (bev) {
|
|
500
|
+
const v = bev.variant;
|
|
501
|
+
const layer = { bevel: {}, parts: [r.name] };
|
|
502
|
+
if (v.light)
|
|
503
|
+
layer.bevel.light = refOf.get(v.light);
|
|
504
|
+
if (v.shadow)
|
|
505
|
+
layer.bevel.shadow = refOf.get(v.shadow);
|
|
506
|
+
if (v.dir !== "upper-left")
|
|
507
|
+
layer.dir = v.dir;
|
|
508
|
+
shading.push(layer);
|
|
509
|
+
// Recompute what the bevel paints.
|
|
510
|
+
const inSet = (x, y) => x >= 0 && y >= 0 && x < W && y < H && set.has(y * W + x);
|
|
511
|
+
const [dx, dy] = v.dir === "upper-left" ? [-1, -1] : [1, -1];
|
|
512
|
+
for (const p of r.pix) {
|
|
513
|
+
const x = p % W, y = Math.floor(p / W);
|
|
514
|
+
let side = null;
|
|
515
|
+
if (!inSet(x, y + dy))
|
|
516
|
+
side = "light";
|
|
517
|
+
else if (!inSet(x, y - dy))
|
|
518
|
+
side = "shadow";
|
|
519
|
+
else if (!inSet(x + dx, y))
|
|
520
|
+
side = "light";
|
|
521
|
+
else if (!inSet(x - dx, y))
|
|
522
|
+
side = "shadow";
|
|
523
|
+
const tone = side === "light" ? v.light : side === "shadow" ? v.shadow : null;
|
|
524
|
+
if (tone)
|
|
525
|
+
predicted.set(p, tone);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const residual = r.pix.filter((p) => predicted.get(p) !== hexAt(f0.rgba, p));
|
|
529
|
+
if (residual.length) {
|
|
530
|
+
const pr = patchRows(residual.map((p) => [p % W, Math.floor(p / W), hexAt(f0.rgba, p)]), key, skip);
|
|
531
|
+
fixes.push({ part: r.name, at: pr.at, pixels: pr.rows });
|
|
532
|
+
}
|
|
533
|
+
summary.parts.push({
|
|
534
|
+
part: requireValue(r.name),
|
|
535
|
+
ramp: r.ramp,
|
|
536
|
+
bbox: [x0, y0, bw, bh],
|
|
537
|
+
pixels: r.pix.length,
|
|
538
|
+
shape,
|
|
539
|
+
fill,
|
|
540
|
+
bevel: bev
|
|
541
|
+
? {
|
|
542
|
+
dir: bev.variant.dir,
|
|
543
|
+
light: bev.variant.light ? (refOf.get(bev.variant.light) ?? null) : null,
|
|
544
|
+
shadow: bev.variant.shadow ? (refOf.get(bev.variant.shadow) ?? null) : null,
|
|
545
|
+
}
|
|
546
|
+
: null,
|
|
547
|
+
residualPixels: residual.length,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
// Outline: an outside 4-neighbour outline of the filled regions, corrected by a patch.
|
|
551
|
+
const outlineLayers = [];
|
|
552
|
+
let outlineStats = null;
|
|
553
|
+
if (pal.outline) {
|
|
554
|
+
const filled = (x, y) => x >= 0 && y >= 0 && x < W && y < H && predicted.has(y * W + x);
|
|
555
|
+
const ring = new Set();
|
|
556
|
+
for (let y = 0; y < H; y++)
|
|
557
|
+
for (let x = 0; x < W; x++)
|
|
558
|
+
if (!filled(x, y) && N4.some(([dx, dy]) => filled(x + dx, y + dy)))
|
|
559
|
+
ring.add(y * W + x);
|
|
560
|
+
const actual = new Set();
|
|
561
|
+
for (let p = 0; p < W * H; p++)
|
|
562
|
+
if (f0.rgba[p * 4 + 3] && hexAt(f0.rgba, p) === pal.outline)
|
|
563
|
+
actual.add(p);
|
|
564
|
+
const missing = [...actual].filter((p) => !ring.has(p));
|
|
565
|
+
const extra = [...ring].filter((p) => !actual.has(p) && !predicted.has(p));
|
|
566
|
+
const useOp = actual.size && missing.length + extra.length < actual.size;
|
|
567
|
+
if (useOp) {
|
|
568
|
+
outlineLayers.push({ outline: "line" });
|
|
569
|
+
for (const p of ring)
|
|
570
|
+
if (!predicted.has(p))
|
|
571
|
+
predicted.set(p, pal.outline);
|
|
572
|
+
if (extra.length) {
|
|
573
|
+
const pr = patchRows(extra.map((p) => [p % W, Math.floor(p / W), "clear"]), key, skip);
|
|
574
|
+
outlineLayers.push({ at: pr.at, pixels: pr.rows });
|
|
575
|
+
for (const p of extra)
|
|
576
|
+
predicted.delete(p);
|
|
577
|
+
}
|
|
578
|
+
if (missing.length) {
|
|
579
|
+
const pr = patchRows(missing.map((p) => [p % W, Math.floor(p / W), requireValue(pal.outline)]), key, skip);
|
|
580
|
+
outlineLayers.push({ part: "line", at: pr.at, pixels: pr.rows });
|
|
581
|
+
for (const p of missing)
|
|
582
|
+
predicted.set(p, pal.outline);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
else if (actual.size) {
|
|
586
|
+
const pr = patchRows([...actual].map((p) => [p % W, Math.floor(p / W), requireValue(pal.outline)]), key, skip);
|
|
587
|
+
outlineLayers.push({ part: "line", at: pr.at, pixels: pr.rows });
|
|
588
|
+
for (const p of actual)
|
|
589
|
+
predicted.set(p, pal.outline);
|
|
590
|
+
}
|
|
591
|
+
outlineStats = {
|
|
592
|
+
pixels: actual.size,
|
|
593
|
+
operation: Boolean(useOp),
|
|
594
|
+
added: missing.length,
|
|
595
|
+
removed: extra.length,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
summary.outlineStats = outlineStats;
|
|
599
|
+
const celName = (i) => (animation ? `${animation}/${i}` : `frame/${i}`);
|
|
600
|
+
let layers0 = [...base, ...shading, ...outlineLayers, ...fixes];
|
|
601
|
+
// Too many layers for the compiler's budget: state frame 0 as literal rows in one part.
|
|
602
|
+
if (layers0.length > LAYER_BUDGET) {
|
|
603
|
+
const pts = [];
|
|
604
|
+
for (let p = 0; p < W * H; p++)
|
|
605
|
+
if (f0.rgba[p * 4 + 3])
|
|
606
|
+
pts.push([p % W, Math.floor(p / W), hexAt(f0.rgba, p)]);
|
|
607
|
+
const pr = patchRows(pts, key, skip);
|
|
608
|
+
summary.fallback = `literal: ${layers0.length} structured layers exceed ${LAYER_BUDGET}`;
|
|
609
|
+
layers0 = [{ part: "pixels", at: pr.at, pixels: pr.rows }];
|
|
610
|
+
summary.parts = [
|
|
611
|
+
{
|
|
612
|
+
part: "pixels",
|
|
613
|
+
ramp: null,
|
|
614
|
+
bbox: [pr.at[0], pr.at[1], requireValue(pr.rows[0]).length / skip.length, pr.rows.length],
|
|
615
|
+
pixels: pts.length,
|
|
616
|
+
shape: "literal",
|
|
617
|
+
fill: null,
|
|
618
|
+
bevel: null,
|
|
619
|
+
residualPixels: 0,
|
|
620
|
+
},
|
|
621
|
+
];
|
|
622
|
+
if (summary.outlineStats)
|
|
623
|
+
summary.outlineStats = { ...summary.outlineStats, operation: false, added: 0, removed: 0 };
|
|
624
|
+
}
|
|
625
|
+
const cels = { [celName(0)]: { size: [W, H], layers: layers0 } };
|
|
626
|
+
// ---- later frames: best whole-frame shift plus per-part changes ----
|
|
627
|
+
const doc = {
|
|
628
|
+
format: SPRITE_FORMAT,
|
|
629
|
+
id,
|
|
630
|
+
palette,
|
|
631
|
+
cels,
|
|
632
|
+
};
|
|
633
|
+
let compiled = compileSprite(doc);
|
|
634
|
+
let prev = requireValue(compiled.cels.get(celName(0)));
|
|
635
|
+
for (let i = 1; i < frames.length; i++) {
|
|
636
|
+
const f = requireValue(frames[i]), a = requireValue(frames[i - 1]);
|
|
637
|
+
const px = (img, x, y) => x < 0 || y < 0 || x >= W || y >= H ? null : img.rgba.subarray((y * W + x) * 4, (y * W + x) * 4 + 4);
|
|
638
|
+
let best = { n: Infinity, dx: 0, dy: 0 };
|
|
639
|
+
for (let dy = -3; dy <= 3; dy++)
|
|
640
|
+
for (let dx = -3; dx <= 3; dx++) {
|
|
641
|
+
let n = 0;
|
|
642
|
+
for (let y = 0; y < H; y++)
|
|
643
|
+
for (let x = 0; x < W; x++) {
|
|
644
|
+
const s = px(a, x - dx, y - dy), t = requireValue(px(f, x, y));
|
|
645
|
+
const sa = s ? s[3] : 0;
|
|
646
|
+
if (sa !== t[3] || (sa && (s?.[0] !== t[0] || s?.[1] !== t[1] || s?.[2] !== t[2])))
|
|
647
|
+
n++;
|
|
648
|
+
}
|
|
649
|
+
// Prefer no shift on ties, then the smallest shift.
|
|
650
|
+
const cost = n * 100 + Math.abs(dx) + Math.abs(dy);
|
|
651
|
+
if (cost < best.n)
|
|
652
|
+
best = { n: cost, dx, dy };
|
|
653
|
+
}
|
|
654
|
+
const layers = [];
|
|
655
|
+
// A shift is only worth a layer if it explains pixels and stays on the canvas.
|
|
656
|
+
let shifted = false;
|
|
657
|
+
if ((best.dx || best.dy) && prev) {
|
|
658
|
+
const names = [...new Set([...prev.canvas.owners].filter((o) => o >= 0))];
|
|
659
|
+
let fits = true;
|
|
660
|
+
for (let p = 0; p < W * H; p++)
|
|
661
|
+
if (prev.canvas.rgba[p * 4 + 3] && requireValue(prev.canvas.owners[p]) >= 0) {
|
|
662
|
+
const x = (p % W) + best.dx, y = Math.floor(p / W) + best.dy;
|
|
663
|
+
if (x < 0 || y < 0 || x >= W || y >= H)
|
|
664
|
+
fits = false;
|
|
665
|
+
}
|
|
666
|
+
const unowned = [...prev.canvas.owners].some((o, p) => o < 0 && requireValue(prev).canvas.rgba[p * 4 + 3]);
|
|
667
|
+
if (fits && names.length && !unowned) {
|
|
668
|
+
layers.push({ move: [best.dx, best.dy], parts: names.map((o) => compiled.parts[o]) });
|
|
669
|
+
shifted = true;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Pixels still differing after the shift, grouped by the part that now owns the neighbourhood.
|
|
673
|
+
const tmp = { ...doc, cels: { ...doc.cels, __probe: { base: celName(i - 1), layers } } };
|
|
674
|
+
const probe = compileSprite(tmp);
|
|
675
|
+
const after = requireValue(probe.cels.get("__probe")).canvas;
|
|
676
|
+
const partsList = probe.parts;
|
|
677
|
+
const groups = new Map();
|
|
678
|
+
for (let y = 0; y < H; y++)
|
|
679
|
+
for (let x = 0; x < W; x++) {
|
|
680
|
+
const p = y * W + x;
|
|
681
|
+
const t = f.rgba.subarray(p * 4, p * 4 + 4);
|
|
682
|
+
const s = after.rgba.subarray(p * 4, p * 4 + 4);
|
|
683
|
+
if (t[0] === s[0] && t[1] === s[1] && t[2] === s[2] && t[3] === s[3])
|
|
684
|
+
continue;
|
|
685
|
+
let part = "";
|
|
686
|
+
if (t[3]) {
|
|
687
|
+
part = "detail";
|
|
688
|
+
const own = requireValue(after.owners[p]);
|
|
689
|
+
if (own >= 0 && after.rgba[p * 4 + 3])
|
|
690
|
+
part = requireValue(partsList[own]);
|
|
691
|
+
else {
|
|
692
|
+
for (const [dx, dy] of N4) {
|
|
693
|
+
const nx = x + dx, ny = y + dy;
|
|
694
|
+
if (nx < 0 || ny < 0 || nx >= W || ny >= H)
|
|
695
|
+
continue;
|
|
696
|
+
const o = requireValue(after.owners[ny * W + nx]);
|
|
697
|
+
if (o >= 0 && after.rgba[(ny * W + nx) * 4 + 3]) {
|
|
698
|
+
part = requireValue(partsList[o]);
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const c = t[3] ? hexAt(f.rgba, p) : "clear";
|
|
705
|
+
if (!groups.has(part))
|
|
706
|
+
groups.set(part, []);
|
|
707
|
+
requireValue(groups.get(part)).push([x, y, c]);
|
|
708
|
+
}
|
|
709
|
+
for (const [part, pts] of [...groups.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
710
|
+
const pr = patchRows(pts, key, skip);
|
|
711
|
+
layers.push({ ...(part ? { part } : {}), at: pr.at, pixels: pr.rows });
|
|
712
|
+
}
|
|
713
|
+
cels[celName(i)] = layers.length ? { base: celName(i - 1), layers } : { base: celName(i - 1) };
|
|
714
|
+
summary.frameChanges.push({
|
|
715
|
+
frame: i,
|
|
716
|
+
shift: shifted ? [best.dx, best.dy] : [0, 0],
|
|
717
|
+
changedPixels: [...groups.values()].reduce((s, g) => s + g.length, 0),
|
|
718
|
+
partsChanged: [...groups.keys()].filter(Boolean),
|
|
719
|
+
});
|
|
720
|
+
compiled = compileSprite(doc);
|
|
721
|
+
prev = requireValue(compiled.cels.get(celName(i)));
|
|
722
|
+
}
|
|
723
|
+
if (animation)
|
|
724
|
+
doc.animations = { [animation]: { cels: frames.map((_, i) => celName(i)), ms } };
|
|
725
|
+
// Symmetry of frame 0 about its best vertical axis (share of opaque pixels whose mirror matches).
|
|
726
|
+
let sym = { axis: null, share: 0 };
|
|
727
|
+
for (let twice = 0; twice <= 2 * W - 2; twice++) {
|
|
728
|
+
let match = 0, total = 0;
|
|
729
|
+
for (let y = 0; y < H; y++)
|
|
730
|
+
for (let x = 0; x < W; x++) {
|
|
731
|
+
const p = y * W + x;
|
|
732
|
+
if (!f0.rgba[p * 4 + 3])
|
|
733
|
+
continue;
|
|
734
|
+
total++;
|
|
735
|
+
const mx = twice - x;
|
|
736
|
+
if (mx < 0 || mx >= W)
|
|
737
|
+
continue;
|
|
738
|
+
const q = y * W + mx;
|
|
739
|
+
if (f0.rgba[q * 4 + 3] && hexAt(f0.rgba, q) === hexAt(f0.rgba, p))
|
|
740
|
+
match++;
|
|
741
|
+
}
|
|
742
|
+
if (total && match / total > sym.share)
|
|
743
|
+
sym = { axis: twice / 2, share: match / total };
|
|
744
|
+
}
|
|
745
|
+
summary.symmetry = sym;
|
|
746
|
+
// Verify.
|
|
747
|
+
const check = compileSprite(doc);
|
|
748
|
+
let verified = check.ok;
|
|
749
|
+
frames.forEach((f, i) => {
|
|
750
|
+
const c = check.cels.get(celName(i));
|
|
751
|
+
if (!c || c.canvas.rgba.some((v, j) => v !== f.rgba[j]))
|
|
752
|
+
verified = false;
|
|
753
|
+
});
|
|
754
|
+
return { doc, summary, verified, diagnostics: check.diagnostics };
|
|
755
|
+
}
|
|
756
|
+
/** Markdown description of a decompiled sprite for a model to read. */
|
|
757
|
+
export function describeSprite(summary, doc) {
|
|
758
|
+
const lines = [];
|
|
759
|
+
lines.push(`# ${doc.id ?? "sprite"}`, "");
|
|
760
|
+
lines.push(`Canvas ${summary.size[0]}×${summary.size[1]}, ${summary.frames} frame(s). Coordinates are [x, y] from the top-left; boxes are [x, y, w, h].`, "");
|
|
761
|
+
if (summary.outline) {
|
|
762
|
+
const o = requireValue(summary.outlineStats);
|
|
763
|
+
lines.push(`Outline colour \`line\` = ${summary.outline} (${o.pixels} px). ${o.operation ? `Drawn by one \`outline\` operation, then ${o.added} px added and ${o.removed} px removed by patches.` : "Not a plain outside outline; kept as literal pixels in part `line`."}`, "");
|
|
764
|
+
}
|
|
765
|
+
else
|
|
766
|
+
lines.push("No outline colour detected.", "");
|
|
767
|
+
lines.push("## Ramps (darkest step first)", "");
|
|
768
|
+
for (const r of summary.ramps)
|
|
769
|
+
lines.push(`- \`${r.name}\`: ${r.steps.map((s, i) => `${r.name}.${i} ${s.colour} (${s.pixels} px)`).join(", ")}`);
|
|
770
|
+
lines.push("", "## Parts (frame 0, largest first)", "");
|
|
771
|
+
lines.push("| part | box | px | shape | fill | shading | literal fixes |", "| --- | --- | ---: | --- | --- | --- | ---: |");
|
|
772
|
+
for (const p of summary.parts)
|
|
773
|
+
lines.push(`| \`${p.part}\` | ${JSON.stringify(p.bbox)} | ${p.pixels} | ${p.shape} | ${p.fill ?? "–"} | ${p.bevel ? `bevel ${p.bevel.dir}: light ${p.bevel.light ?? "–"}, shadow ${p.bevel.shadow ?? "–"}` : "flat"} | ${p.residualPixels} |`);
|
|
774
|
+
lines.push("");
|
|
775
|
+
const s = summary.symmetry ?? { axis: null, share: 0 };
|
|
776
|
+
if (s.axis !== null)
|
|
777
|
+
lines.push(`Mirror symmetry: ${(s.share * 100).toFixed(0)}% of opaque pixels match their mirror about x = ${s.axis}.`, "");
|
|
778
|
+
if (summary.frameChanges.length) {
|
|
779
|
+
lines.push("## Frames", "");
|
|
780
|
+
for (const f of summary.frameChanges)
|
|
781
|
+
lines.push(`- frame ${f.frame}: shift ${JSON.stringify(f.shift)}, ${f.changedPixels} px changed${f.partsChanged.length ? ` (${f.partsChanged.join(", ")})` : ""}`);
|
|
782
|
+
lines.push("");
|
|
783
|
+
}
|
|
784
|
+
lines.push("Part names are generic (ramp name and size order). Rename them to what they depict before editing.");
|
|
785
|
+
return `${lines.join("\n")}\n`;
|
|
786
|
+
}
|