castle-web-cli 0.4.71 → 0.4.73

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 (57) hide show
  1. package/dist/agent-prompts.js +22 -3
  2. package/dist/agent.js +731 -313
  3. package/dist/init.js +1 -1
  4. package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
  5. package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
  6. package/dist/shell/index.html +2 -2
  7. package/dist/vitePlugins.js +3 -2
  8. package/kits/basic-2d/CLAUDE.md +29 -8
  9. package/kits/basic-2d/behaviors/Collider.jsx +6 -4
  10. package/kits/basic-2d/behaviors/Layout.jsx +2 -2
  11. package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
  12. package/kits/basic-2d/behaviors/tint.js +47 -0
  13. package/kits/basic-2d/docs/pxart-format.md +298 -0
  14. package/kits/basic-2d/drawings/pig.pxart +59 -0
  15. package/kits/basic-2d/editors/App.jsx +125 -76
  16. package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
  17. package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
  18. package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
  19. package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
  20. package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
  21. package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
  22. package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
  23. package/kits/basic-2d/editors/codeTheme.js +135 -0
  24. package/kits/basic-2d/editors/editorHistory.js +44 -17
  25. package/kits/basic-2d/editors/inspectorSheet.js +23 -0
  26. package/kits/basic-2d/editors/pixelCanvas.js +11 -0
  27. package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
  28. package/kits/basic-2d/editors/pixelGeometry.js +45 -0
  29. package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
  30. package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
  31. package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
  32. package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
  33. package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
  34. package/kits/basic-2d/editors/pxArtTools.js +124 -0
  35. package/kits/basic-2d/editors/useArtboardFit.js +102 -0
  36. package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
  37. package/kits/basic-2d/engine/SceneUI.jsx +3 -11
  38. package/kits/basic-2d/engine/assets.js +15 -0
  39. package/kits/basic-2d/engine/files.js +57 -2
  40. package/kits/basic-2d/engine/pxart.js +985 -0
  41. package/kits/basic-2d/engine/scene.js +222 -41
  42. package/kits/basic-2d/engine/ui.jsx +155 -26
  43. package/kits/basic-2d/engine/ui.module.css +1280 -344
  44. package/kits/basic-2d/eslint.config.js +21 -0
  45. package/kits/basic-2d/index.html +13 -0
  46. package/kits/basic-2d/package.json +1 -0
  47. package/kits/basic-2d/pnpm-lock.yaml +5 -5
  48. package/kits/basic-2d/scenes/main.scene +19 -26
  49. package/kits/basic-2d/scripts/draw.mjs +121 -0
  50. package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
  51. package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
  52. package/package.json +1 -1
  53. package/dist/shell/assets/index-BY21Og40.js +0 -106
  54. package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
  55. package/kits/basic-2d/drawings/block.drawing +0 -70
  56. package/kits/basic-2d/drawings/default.drawing +0 -70
  57. package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
@@ -0,0 +1,718 @@
1
+ import {
2
+ colorForKeyV2,
3
+ DEFAULT_DURATION_MS,
4
+ DEFAULT_RESOLUTION,
5
+ forEachCelPixel,
6
+ fromCells,
7
+ frameCount,
8
+ paletteLookup,
9
+ parseFull,
10
+ RESOLUTION_MAX,
11
+ resolveCell,
12
+ resolveCellGrid,
13
+ serializeCompact,
14
+ serializeFull,
15
+ snapResolutionDim,
16
+ TRANSPARENT,
17
+ } from '../engine/pxart';
18
+ import {
19
+ blankCells,
20
+ EDITOR_KEYS,
21
+ EDITOR_PALETTE,
22
+ editorKeyForHex,
23
+ } from './pxArtTools';
24
+
25
+ // ============================================================================
26
+ // Working-state helpers for the full Sprite the PxArt editor edits in memory.
27
+ //
28
+ // The editor always works on a Sprite normalized onto the FIXED Endesga-64
29
+ // palette, so painting + serialization stay consistent no matter what palette
30
+ // an externally-authored file arrived with. Every mutation is immutable
31
+ // (returns a fresh Sprite) so it flows straight into the text undo/redo history.
32
+ // ============================================================================
33
+
34
+ // The Endesga-64 palette as an ordered { key, hex } list (the full palette shape).
35
+ export const EDITOR_ORDERED = EDITOR_KEYS.map((key) => ({ key, hex: EDITOR_PALETTE[key] }));
36
+
37
+ // Size a canvas to a sprite resolution, disable smoothing, and clear it.
38
+ // Returns the 2D context (or null). Shared by the artboard + thumbnail renders.
39
+ export function setupSpriteCanvas(canvas, resolution) {
40
+ const { width, height } = resolution;
41
+ canvas.width = width;
42
+ canvas.height = height;
43
+ const ctx = canvas.getContext('2d');
44
+ if (!ctx) return null;
45
+ ctx.imageSmoothingEnabled = false;
46
+ ctx.clearRect(0, 0, width, height);
47
+ return ctx;
48
+ }
49
+
50
+ // Visit each in-bounds cell of a packed grid (rows of palette-key chars),
51
+ // clipped to width/height. `visit(x, y, key)` receives the char at that cell.
52
+ export function forEachGridCell(grid, width, height, visit) {
53
+ const rows = Math.min(grid.length, height);
54
+ for (let y = 0; y < rows; y++) {
55
+ const row = grid[y];
56
+ const cols = Math.min(row.length, width);
57
+ for (let x = 0; x < cols; x++) visit(x, y, row[x]);
58
+ }
59
+ }
60
+
61
+ // Parse file text into a palette-normalized working Sprite. Empty text yields a
62
+ // blank single-layer / single-frame sprite. Returns null when the text is not
63
+ // valid pixel art (so the caller can show an error state).
64
+ export function deriveSprite(text) {
65
+ if (!(text ?? '').trim()) return blankSprite();
66
+ const parsed = parseFull(text);
67
+ return parsed ? normalizeToPalette(parsed) : null;
68
+ }
69
+
70
+ function blankSprite() {
71
+ const { width, height } = DEFAULT_RESOLUTION;
72
+ return {
73
+ resolution: { width, height },
74
+ palette: EDITOR_ORDERED,
75
+ // The lone frame inherits the global duration (no per-frame override).
76
+ frames: [{}],
77
+ defaultDurationMs: DEFAULT_DURATION_MS,
78
+ tags: [],
79
+ defaultTag: undefined,
80
+ layers: [makeLayer('layer-0', 'Layer 1', [null])],
81
+ };
82
+ }
83
+
84
+ function makeLayer(id, name, cells) {
85
+ return { id, name, visible: true, opacity: 1, blendMode: 'normal', kind: 'pixel', cells };
86
+ }
87
+
88
+ // Build a { grid } cell, attaching the offset only when nonzero so a cel that
89
+ // sits flush at the canvas origin keeps the compact { grid } shape (and still
90
+ // serializes to the compact form when it's the only cel). x/y may be negative
91
+ // (off-canvas to the left/top). Mirrors the SDK's makeGridCell.
92
+ function gridCell(grid, x, y) {
93
+ return x || y ? { grid, x, y } : { grid };
94
+ }
95
+
96
+ // Remap every grid char onto the nearest Endesga-64 key and swap in the full
97
+ // palette, leaving links / null cells / structure untouched. Idempotent for an
98
+ // already-normalized sprite.
99
+ function normalizeToPalette(sprite) {
100
+ const lookup = paletteLookup(sprite.palette);
101
+ const layers = sprite.layers.map((layer) => ({
102
+ ...layer,
103
+ cells: layer.cells.map((cell) => remapCell(cell, lookup)),
104
+ }));
105
+ return { ...sprite, palette: EDITOR_ORDERED, layers };
106
+ }
107
+
108
+ function remapCell(cell, lookup) {
109
+ if (cell == null) return null;
110
+ if ('link' in cell) return { link: cell.link };
111
+ // Preserve the cel's offset while remapping its palette keys.
112
+ return gridCell(cell.grid.map((row) => remapRow(row, lookup)), cell.x ?? 0, cell.y ?? 0);
113
+ }
114
+
115
+ function remapRow(row, lookup) {
116
+ let out = '';
117
+ for (const ch of row) {
118
+ if (ch === TRANSPARENT) {
119
+ out += TRANSPARENT;
120
+ continue;
121
+ }
122
+ const hex = colorForKeyV2(lookup, ch);
123
+ out += hex ? editorKeyForHex(hex) : TRANSPARENT;
124
+ }
125
+ return out;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // cells <-> (layer, frame)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ // The CANVAS-WINDOW view of the cel at (layerIndex, frameIndex): the cel image
133
+ // composited at its (x, y) offset into a resolution-sized matrix, CLIPPED to the
134
+ // canvas. Follows same-layer links (shared image + offset). Pixels of the cel
135
+ // that lie outside the canvas window are not represented here (they live only in
136
+ // the stored cel grid); the paint tools edit this window and withCellsAt merges
137
+ // the result back without disturbing those off-window pixels.
138
+ export function cellsAt(sprite, layerIndex, frameIndex) {
139
+ const { width, height } = sprite.resolution;
140
+ const layer = sprite.layers[layerIndex];
141
+ const resolved = layer ? resolveCell(layer, frameIndex) : null;
142
+ const cells = blankCells(width, height);
143
+ if (resolved) {
144
+ forEachCelPixel(resolved, width, height, (cx, cy, key) => {
145
+ cells[cy][cx] = key || TRANSPARENT;
146
+ });
147
+ }
148
+ return cells;
149
+ }
150
+
151
+ // Merge a painted canvas-window matrix back into the cel at (layerIndex,
152
+ // frameIndex), following links so editing a linked cell edits the shared source.
153
+ // The window region [0,width) x [0,height) is AUTHORITATIVE (it carries the
154
+ // edit, including erases); cel pixels OUTSIDE that window are preserved verbatim.
155
+ // The merged cel covers the union of the canvas window and the prior cel rect,
156
+ // so off-canvas content is never dropped. Result is capped at 512/axis.
157
+ export function withCellsAt(sprite, layerIndex, frameIndex, cells) {
158
+ const layer = sprite.layers[layerIndex];
159
+ const target = writeTargetIndex(layer, frameIndex);
160
+ const prev = layer ? resolveCell(layer, target) : null;
161
+ const merged = mergeWindowIntoCell(prev, cells, sprite.resolution);
162
+ return updateLayerCell(sprite, layerIndex, target, merged);
163
+ }
164
+
165
+ // Composite the edited window matrix over the existing cel, in canvas space.
166
+ // Returns a { grid, x, y } cell (offset omitted when zero) sized to the union of
167
+ // the canvas window and the prior cel rect — never shrinking below the window,
168
+ // so a flush-at-origin cel keeps a resolution-sized grid (and stays
169
+ // compact-eligible) while off-canvas overhang is retained. All-transparent merges still keep the
170
+ // window-sized grid (matches the pre-offset "write the whole window" behavior).
171
+ function mergeWindowIntoCell(prev, cells, resolution) {
172
+ const { width: resW, height: resH } = resolution;
173
+ const px = prev ? prev.x : 0;
174
+ const py = prev ? prev.y : 0;
175
+ const pGrid = prev ? prev.grid : [];
176
+ const ph = pGrid.length;
177
+ const pw = ph ? Math.max(...pGrid.map((row) => row.length)) : 0;
178
+
179
+ let minX = 0;
180
+ let minY = 0;
181
+ let maxX = resW;
182
+ let maxY = resH;
183
+ if (ph) {
184
+ minX = Math.min(minX, px);
185
+ minY = Math.min(minY, py);
186
+ maxX = Math.max(maxX, px + pw);
187
+ maxY = Math.max(maxY, py + ph);
188
+ }
189
+
190
+ // The canvas window is authoritative within [0,resW) x [0,resH); elsewhere the
191
+ // prior cel pixel (off-canvas content) stands.
192
+ const sample = (cx, cy) => {
193
+ if (cx >= 0 && cx < resW && cy >= 0 && cy < resH) return cells[cy][cx];
194
+ const gx = cx - px;
195
+ const gy = cy - py;
196
+ if (gy >= 0 && gy < ph) return pGrid[gy][gx] ?? TRANSPARENT;
197
+ return TRANSPARENT;
198
+ };
199
+
200
+ // Cap the union to 512/axis, keeping the band that contains the canvas window.
201
+ [minX, maxX] = capAxisToCanvas(minX, maxX, resW);
202
+ [minY, maxY] = capAxisToCanvas(minY, maxY, resH);
203
+
204
+ const grid = [];
205
+ for (let cy = minY; cy < maxY; cy++) {
206
+ let row = '';
207
+ for (let cx = minX; cx < maxX; cx++) row += sample(cx, cy);
208
+ grid.push(row);
209
+ }
210
+ return gridCell(grid, minX, minY);
211
+ }
212
+
213
+ // Clamp a canvas-space axis range [lo, hi) so its span never exceeds 512, always
214
+ // keeping the canvas window [0, win) fully covered. When it must crop, it drops
215
+ // the band farthest from the canvas (the most off-canvas pixels) — the single
216
+ // place the "never discard off-canvas pixels" invariant can yield, and only for
217
+ // content spanning >512 on one axis (well beyond the move-off-and-back path).
218
+ function capAxisToCanvas(lo, hi, win) {
219
+ if (hi - lo <= RESOLUTION_MAX) return [lo, hi];
220
+ const slack = RESOLUTION_MAX - win; // win <= 512, so slack >= 0
221
+ let newLo = Math.max(lo, -Math.floor(slack / 2));
222
+ let newHi = newLo + RESOLUTION_MAX;
223
+ if (newHi > hi) {
224
+ newHi = hi;
225
+ newLo = hi - RESOLUTION_MAX;
226
+ }
227
+ // Guarantee the canvas window stays covered.
228
+ if (newLo > 0) newLo = 0;
229
+ if (newHi < win) newHi = win;
230
+ return [newLo, newHi];
231
+ }
232
+
233
+ // The cel's resolved offset at (layerIndex, frameIndex) — (0,0) when empty.
234
+ export function cellOffsetAt(sprite, layerIndex, frameIndex) {
235
+ const layer = sprite.layers[layerIndex];
236
+ const resolved = layer ? resolveCell(layer, frameIndex) : null;
237
+ return { x: resolved?.x ?? 0, y: resolved?.y ?? 0 };
238
+ }
239
+
240
+ // Set the ABSOLUTE offset of the cel at (layerIndex, frameIndex), following
241
+ // links to the shared source so all linked frames move together (shared offset).
242
+ // This is the move-all primitive: it translates the whole cel image without
243
+ // touching its pixels, so pushing art off the canvas edge and back is lossless.
244
+ // A no-op on an empty cell (nothing to move).
245
+ export function setCellOffset(sprite, layerIndex, frameIndex, x, y) {
246
+ const layer = sprite.layers[layerIndex];
247
+ if (!layer) return sprite;
248
+ const target = writeTargetIndex(layer, frameIndex);
249
+ const cell = layer.cells[target];
250
+ if (cell == null || 'link' in cell) return sprite;
251
+ if ((cell.x ?? 0) === x && (cell.y ?? 0) === y) return sprite;
252
+ return updateLayerCell(sprite, layerIndex, target, gridCell(cell.grid, x, y));
253
+ }
254
+
255
+ // Resolve the cell index a paint should write to: follow links to the cell that
256
+ // actually holds the { grid }; an empty / broken chain writes the active cell.
257
+ function writeTargetIndex(layer, frameIndex) {
258
+ if (!layer) return frameIndex;
259
+ const visited = new Set();
260
+ let idx = frameIndex;
261
+ while (idx >= 0 && idx < layer.cells.length && !visited.has(idx)) {
262
+ visited.add(idx);
263
+ const cell = layer.cells[idx];
264
+ if (cell == null || 'grid' in cell) return idx;
265
+ idx = cell.link;
266
+ }
267
+ return frameIndex;
268
+ }
269
+
270
+ function updateLayerCell(sprite, layerIndex, cellIndex, cell) {
271
+ const layers = sprite.layers.map((layer, i) => {
272
+ if (i !== layerIndex) return layer;
273
+ const cells = layer.cells.slice();
274
+ cells[cellIndex] = cell;
275
+ return { ...layer, cells };
276
+ });
277
+ return { ...sprite, layers };
278
+ }
279
+
280
+ // The display kind of a (layer, frame) cell: 'empty' | 'link' | 'grid'.
281
+ export function cellKind(layer, frameIndex) {
282
+ const cell = layer?.cells[frameIndex];
283
+ if (cell == null) return 'empty';
284
+ if ('link' in cell) return 'link';
285
+ return 'grid';
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // linked cells
290
+ // ---------------------------------------------------------------------------
291
+
292
+ // A cell is either LINKED or UNLINKED — a single bit. "Linked" means it shares
293
+ // its image with the PREVIOUS frame's cell in the same layer, so a linked cell's
294
+ // `link` ALWAYS points at `frameIndex - 1`. If that previous cell is itself a
295
+ // link, resolution (resolveCellGrid) chains transitively back to the nearest
296
+ // { grid } — the run's source — so a run of consecutive linked cells all share
297
+ // one image and editing any of them edits the whole run.
298
+ //
299
+ // Unavailable on frame 0 (no previous to link to), and a no-op when the previous
300
+ // frame resolves to nothing (linking to an empty cell would only blank this one,
301
+ // which is never what the menu offers). The 4th arg of the old arbitrary-source
302
+ // API is accepted-and-ignored so existing wiring keeps working.
303
+ export function linkCell(sprite, layerIndex, frameIndex) {
304
+ const layer = sprite.layers[layerIndex];
305
+ if (!layer || frameIndex <= 0) return sprite;
306
+ if (!resolveCellGrid(layer, frameIndex - 1)) return sprite;
307
+ return updateLayerCell(sprite, layerIndex, frameIndex, { link: frameIndex - 1 });
308
+ }
309
+
310
+ // Detach a linked cell into an INDEPENDENT copy of whatever it currently shows:
311
+ // resolve the chain to the source pixels and write them as this cell's own
312
+ // { grid } (or null when nothing resolves). It looks identical immediately, but
313
+ // edits no longer propagate to or from the rest of the run.
314
+ export function unlinkCell(sprite, layerIndex, frameIndex) {
315
+ const layer = sprite.layers[layerIndex];
316
+ const resolved = layer ? resolveCell(layer, frameIndex) : null;
317
+ // Copy the shared image AND its offset so the detached cel looks identical.
318
+ const cell = resolved ? gridCell(resolved.grid.slice(), resolved.x, resolved.y) : null;
319
+ return updateLayerCell(sprite, layerIndex, frameIndex, cell);
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // layer ops
324
+ // ---------------------------------------------------------------------------
325
+
326
+ function uniqueLayerId(sprite) {
327
+ const ids = new Set(sprite.layers.map((layer) => layer.id));
328
+ let n = sprite.layers.length;
329
+ let id = `layer-${n}`;
330
+ while (ids.has(id)) id = `layer-${++n}`;
331
+ return id;
332
+ }
333
+
334
+ // Insert a fresh empty layer directly ABOVE the active one (Aseprite stacks new
335
+ // layers on top; layer 0 is the bottom of the stack).
336
+ export function addLayer(sprite, layerIndex) {
337
+ const cells = sprite.frames.map(() => null);
338
+ const layer = makeLayer(uniqueLayerId(sprite), `Layer ${sprite.layers.length + 1}`, cells);
339
+ const layers = sprite.layers.slice();
340
+ layers.splice(layerIndex + 1, 0, layer);
341
+ return { ...sprite, layers };
342
+ }
343
+
344
+ export function deleteLayer(sprite, layerIndex) {
345
+ if (sprite.layers.length <= 1) return sprite;
346
+ const layers = sprite.layers.filter((_, i) => i !== layerIndex);
347
+ return { ...sprite, layers };
348
+ }
349
+
350
+ export function duplicateLayer(sprite, layerIndex) {
351
+ const src = sprite.layers[layerIndex];
352
+ if (!src) return sprite;
353
+ const copy = makeLayer(uniqueLayerId(sprite), `${src.name} copy`, src.cells.map(cloneCell));
354
+ copy.visible = src.visible;
355
+ copy.opacity = src.opacity;
356
+ const layers = sprite.layers.slice();
357
+ layers.splice(layerIndex + 1, 0, copy);
358
+ return { ...sprite, layers };
359
+ }
360
+
361
+ // Move a layer up (toward the top of the stack, higher index) or down.
362
+ export function moveLayer(sprite, layerIndex, dir) {
363
+ const target = layerIndex + dir;
364
+ if (target < 0 || target >= sprite.layers.length) return sprite;
365
+ const layers = sprite.layers.slice();
366
+ [layers[layerIndex], layers[target]] = [layers[target], layers[layerIndex]];
367
+ return { ...sprite, layers };
368
+ }
369
+
370
+ export function patchLayer(sprite, layerIndex, patch) {
371
+ const layers = sprite.layers.map((layer, i) => (i === layerIndex ? { ...layer, ...patch } : layer));
372
+ return { ...sprite, layers };
373
+ }
374
+
375
+ function cloneCell(cell) {
376
+ if (cell == null) return null;
377
+ if ('link' in cell) return { link: cell.link };
378
+ return gridCell(cell.grid.slice(), cell.x ?? 0, cell.y ?? 0);
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // frame ops
383
+ // ---------------------------------------------------------------------------
384
+
385
+ // Structural frame edits (insert/duplicate/delete/move) operate on per-cell
386
+ // SNAPSHOTS rather than raw links, then rebuild coherent links. Each snapshot is
387
+ // a slot's RESOLVED image (chain followed on the PRE-edit layer) plus whether it
388
+ // was a link. After reordering the snapshots, relinkSlots rebuilds cells so that
389
+ // (a) every slot keeps the exact pixels it showed before the edit, and (b) the
390
+ // only links that survive point at the immediate previous frame (link === i-1).
391
+ // This makes "linked = same as previous frame" hold after any reshuffle, with no
392
+ // forward or frame-skipping links left behind.
393
+
394
+ // Snapshot every cell of a layer as { image, x, y, wasLink }. The offset travels
395
+ // with the image so reordered/relinked frames keep their cel placement.
396
+ function layerSlots(layer) {
397
+ return layer.cells.map((cell, i) => {
398
+ const resolved = resolveCell(layer, i);
399
+ return {
400
+ image: resolved?.grid ?? null,
401
+ x: resolved?.x ?? 0,
402
+ y: resolved?.y ?? 0,
403
+ wasLink: cell != null && 'link' in cell,
404
+ };
405
+ });
406
+ }
407
+
408
+ // Value-compare two packed grids (decides whether a slot may re-link to its new
409
+ // previous neighbour or must materialize to preserve its appearance).
410
+ function gridsEqual(a, b) {
411
+ if (a === b) return true;
412
+ if (!a || !b || a.length !== b.length) return false;
413
+ for (let i = 0; i < a.length; i++) {
414
+ if (a[i] !== b[i]) return false;
415
+ }
416
+ return true;
417
+ }
418
+
419
+ // Rebuild a cells row from (reordered) snapshots. A slot stays a link to i-1 only
420
+ // when it WAS a link and the previous slot now shows an identical image AND the
421
+ // same offset (a link shares both); empty slots stay null; everything else
422
+ // materializes into its own { grid } copy, preserving its offset.
423
+ function relinkSlots(slots) {
424
+ return slots.map((slot, i) => {
425
+ if (slot.image == null) return null;
426
+ if (
427
+ i > 0 &&
428
+ slot.wasLink &&
429
+ gridsEqual(slots[i - 1].image, slot.image) &&
430
+ slots[i - 1].x === slot.x &&
431
+ slots[i - 1].y === slot.y
432
+ ) {
433
+ return { link: i - 1 };
434
+ }
435
+ return gridCell(slot.image.slice(), slot.x, slot.y);
436
+ });
437
+ }
438
+
439
+ // Snapshot each layer, let `transform(slots, layer)` reshuffle them, then rebuild
440
+ // coherent cells. Returns the new layers array.
441
+ function mapLayerSlots(sprite, transform) {
442
+ return sprite.layers.map((layer) => ({
443
+ ...layer,
444
+ cells: relinkSlots(transform(layerSlots(layer), layer)),
445
+ }));
446
+ }
447
+
448
+ function clampTag(tag, count) {
449
+ let from = clampIndex(tag.from, count);
450
+ let to = clampIndex(tag.to, count);
451
+ if (from > to) [from, to] = [to, from];
452
+ return { ...tag, from, to };
453
+ }
454
+
455
+ function clampIndex(value, count) {
456
+ if (!Number.isInteger(value)) return 0;
457
+ return Math.min(Math.max(value, 0), Math.max(count - 1, 0));
458
+ }
459
+
460
+ // Insert a frame column at `pos`, building each layer's new SLOT via makeSlot
461
+ // ({ image, wasLink }). `frame` is the new frame's timing record ({} = inherit
462
+ // the global duration, { durationMs } = explicit override). relinkSlots keeps a
463
+ // linked cell that now sits right after the inserted column pointed at its real
464
+ // previous neighbour (or materializes it when the insert changed what precedes
465
+ // it), so links never silently skip the new column.
466
+ function insertFrameAt(sprite, pos, frame, makeSlot) {
467
+ const frames = sprite.frames.slice();
468
+ frames.splice(pos, 0, frame);
469
+ const layers = mapLayerSlots(sprite, (slots, layer) => {
470
+ slots.splice(pos, 0, makeSlot(layer));
471
+ return slots;
472
+ });
473
+ // Tag ranges shift with a column insert too.
474
+ const tags = sprite.tags.map((tag) => ({
475
+ ...tag,
476
+ from: tag.from >= pos ? tag.from + 1 : tag.from,
477
+ to: tag.to >= pos ? tag.to + 1 : tag.to,
478
+ }));
479
+ return { ...sprite, frames, layers, tags };
480
+ }
481
+
482
+ // Add a frame directly AFTER the active one, inheriting the global duration (no
483
+ // override). The new frame starts LINKED to the previous frame: its snapshot
484
+ // mirrors the previous cel's resolved image + offset and is flagged wasLink, so
485
+ // relinkSlots links it to its immediate previous neighbour (link === i-1, chain
486
+ // aware). When the previous frame's cell resolves to nothing (same condition
487
+ // that disables "Link to previous frame"), the snapshot image is null and the
488
+ // new cell stays empty.
489
+ export function addFrame(sprite, frameIndex) {
490
+ return insertFrameAt(sprite, frameIndex + 1, {}, (layer) => {
491
+ const resolved = resolveCell(layer, frameIndex);
492
+ return {
493
+ image: resolved?.grid ?? null,
494
+ x: resolved?.x ?? 0,
495
+ y: resolved?.y ?? 0,
496
+ wasLink: resolved != null,
497
+ };
498
+ });
499
+ }
500
+
501
+ // Duplicate the active frame as an independent column right after it, preserving
502
+ // the source frame's override state: copy an explicit duration, otherwise keep
503
+ // inheriting the global (don't coalesce to the default). The new column is an
504
+ // independent { grid } copy of the source's resolved image.
505
+ export function duplicateFrame(sprite, frameIndex) {
506
+ const src = sprite.frames[frameIndex];
507
+ const frame = src && Number.isFinite(src.durationMs) ? { durationMs: src.durationMs } : {};
508
+ return insertFrameAt(sprite, frameIndex + 1, frame, (layer) => {
509
+ const resolved = resolveCell(layer, frameIndex);
510
+ return {
511
+ image: resolved ? resolved.grid.slice() : null,
512
+ x: resolved?.x ?? 0,
513
+ y: resolved?.y ?? 0,
514
+ wasLink: false,
515
+ };
516
+ });
517
+ }
518
+
519
+ // Remove a frame column. Snapshots are taken on the pre-delete layer, so any
520
+ // cell that linked (transitively) to the removed column keeps its pixels — it
521
+ // materializes when its source disappears, and otherwise re-links to its new
522
+ // immediate previous frame. Tags clamp into the shorter range.
523
+ export function deleteFrame(sprite, frameIndex) {
524
+ if (frameCount(sprite) <= 1) return sprite;
525
+ const layers = mapLayerSlots(sprite, (slots) => {
526
+ slots.splice(frameIndex, 1);
527
+ return slots;
528
+ });
529
+ const frames = sprite.frames.filter((_, i) => i !== frameIndex);
530
+ const tags = sprite.tags.map((tag) => clampTag(tag, frames.length));
531
+ return { ...sprite, frames, layers, tags };
532
+ }
533
+
534
+ // Swap the active frame with its neighbor (dir -1 / +1). Each frame's image
535
+ // travels with it (snapshots swap together); relinkSlots then re-derives links
536
+ // so neither frame ends up pointing forward or skipping — any link broken by the
537
+ // swap materializes to preserve appearance.
538
+ export function moveFrame(sprite, frameIndex, dir) {
539
+ const target = frameIndex + dir;
540
+ if (target < 0 || target >= frameCount(sprite)) return sprite;
541
+ const frames = sprite.frames.slice();
542
+ [frames[frameIndex], frames[target]] = [frames[target], frames[frameIndex]];
543
+ const layers = mapLayerSlots(sprite, (slots) => {
544
+ [slots[frameIndex], slots[target]] = [slots[target], slots[frameIndex]];
545
+ return slots;
546
+ });
547
+ return { ...sprite, frames, layers };
548
+ }
549
+
550
+ // Set or CLEAR a frame's duration override. A finite, positive `durationMs`
551
+ // sets an explicit override; null/undefined (or any non-positive/NaN value,
552
+ // e.g. an emptied input) clears the override so the frame inherits the global.
553
+ export function setFrameDuration(sprite, frameIndex, durationMs) {
554
+ const override = Number.isFinite(durationMs) && durationMs > 0;
555
+ const frames = sprite.frames.map((frame, i) =>
556
+ i === frameIndex ? (override ? { durationMs } : {}) : frame
557
+ );
558
+ return { ...sprite, frames };
559
+ }
560
+
561
+ // Set the sprite's GLOBAL frame duration (the value inherited by every frame
562
+ // without its own override). Ignores non-finite / non-positive input.
563
+ export function setDefaultDuration(sprite, durationMs) {
564
+ if (!(Number.isFinite(durationMs) && durationMs > 0)) return sprite;
565
+ return { ...sprite, defaultDurationMs: durationMs };
566
+ }
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // tags
570
+ // ---------------------------------------------------------------------------
571
+
572
+ export function addTag(sprite) {
573
+ const tag = {
574
+ name: `tag ${sprite.tags.length + 1}`,
575
+ from: 0,
576
+ to: Math.max(frameCount(sprite) - 1, 0),
577
+ direction: 'forward',
578
+ repeat: 0,
579
+ };
580
+ return { ...sprite, tags: [...sprite.tags, tag] };
581
+ }
582
+
583
+ export function patchTag(sprite, tagIndex, patch) {
584
+ const tags = sprite.tags.map((tag, i) =>
585
+ i === tagIndex ? clampTag({ ...tag, ...patch }, frameCount(sprite)) : tag
586
+ );
587
+ return { ...sprite, tags };
588
+ }
589
+
590
+ export function deleteTag(sprite, tagIndex) {
591
+ const removed = sprite.tags[tagIndex];
592
+ const tags = sprite.tags.filter((_, i) => i !== tagIndex);
593
+ const defaultTag = removed && sprite.defaultTag === removed.name ? undefined : sprite.defaultTag;
594
+ return { ...sprite, tags, defaultTag };
595
+ }
596
+
597
+ export function setDefaultTag(sprite, name) {
598
+ return { ...sprite, defaultTag: name || undefined };
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // resize (whole sprite) + serialization
603
+ // ---------------------------------------------------------------------------
604
+
605
+ // Resize the canvas resolution (snapped pow2 16..512). This REFRAMES, never
606
+ // crops: the canvas window changes and every cel's offset shifts by the centered
607
+ // delta (floor((new-old)/2) per axis) so existing art stays centered. Cel images
608
+ // are left fully intact — content that ends up outside the new (or old) canvas
609
+ // window is retained, not destroyed.
610
+ export function resizeSprite(sprite, width, height) {
611
+ const w = snapResolutionDim(width);
612
+ const h = snapResolutionDim(height);
613
+ if (w === sprite.resolution.width && h === sprite.resolution.height) return sprite;
614
+ const dx = Math.floor((w - sprite.resolution.width) / 2);
615
+ const dy = Math.floor((h - sprite.resolution.height) / 2);
616
+ const layers = sprite.layers.map((layer) => ({
617
+ ...layer,
618
+ cells: layer.cells.map((cell) => shiftCellOffset(cell, dx, dy)),
619
+ }));
620
+ return { ...sprite, resolution: { width: w, height: h }, layers };
621
+ }
622
+
623
+ function shiftCellOffset(cell, dx, dy) {
624
+ if (cell == null || 'link' in cell) return cell;
625
+ return gridCell(cell.grid, (cell.x ?? 0) + dx, (cell.y ?? 0) + dy);
626
+ }
627
+
628
+ // Serialize the working sprite to a `.pxart` string, choosing the SMALLEST form
629
+ // that loses nothing. The choice is STRUCTURAL, not a hand-maintained checklist:
630
+ // build the candidate compact form, RE-PARSE it back into a full model, and keep
631
+ // it only when that round-trip reproduces the sprite exactly. Any feature the
632
+ // compact form can't carry — extra layers/frames/tags, per-layer opacity or
633
+ // visibility, cel offsets, or off-canvas content — makes the round-trip differ,
634
+ // so the sprite falls through to the full form. Adding a new full-form feature
635
+ // later needs no change here: if it survives the round-trip it was already
636
+ // representable; if not, it correctly forces the full form.
637
+ export function serializeModel(sprite) {
638
+ const compact = serializeCompact(fromCells(EDITOR_PALETTE, cellsAt(sprite, 0, 0)));
639
+ const roundTripped = parseFull(compact);
640
+ if (roundTripped && losslesslyCompact(sprite, roundTripped)) return compact;
641
+ return serializeFull(sprite);
642
+ }
643
+
644
+ // True when `roundTripped` (the full model re-parsed from the candidate compact
645
+ // form) is EQUIVALENT to the original `sprite` — i.e. the compact form lost
646
+ // nothing. The compact form always re-parses to exactly one fully-opaque visible
647
+ // layer, one inheriting frame, and no tags, so any richer original differs here.
648
+ // Comparison is by RESOLVED CONTENT, normalizing representationally-irrelevant
649
+ // differences (palette ordering/unused keys, transparent padding, offset
650
+ // encoding) while catching every real one (pixels, opacity, visibility, frames,
651
+ // tags, off-canvas content).
652
+ function losslesslyCompact(sprite, roundTripped) {
653
+ if (
654
+ sprite.resolution.width !== roundTripped.resolution.width ||
655
+ sprite.resolution.height !== roundTripped.resolution.height
656
+ ) {
657
+ return false;
658
+ }
659
+ if (frameCount(sprite) !== frameCount(roundTripped)) return false;
660
+ if (sprite.tags.length !== roundTripped.tags.length) return false;
661
+ if ((sprite.defaultTag ?? null) !== (roundTripped.defaultTag ?? null)) return false;
662
+ if (sprite.layers.length !== roundTripped.layers.length) return false;
663
+
664
+ const lookupA = paletteLookup(sprite.palette);
665
+ const lookupB = paletteLookup(roundTripped.palette);
666
+ const frames = frameCount(sprite);
667
+ return sprite.layers.every((a, li) =>
668
+ layersEquivalent(a, roundTripped.layers[li], frames, lookupA, lookupB)
669
+ );
670
+ }
671
+
672
+ // True when two layers are equivalent for compact-eligibility: same visibility,
673
+ // opacity, blend/kind, and identical resolved cel content across every frame.
674
+ function layersEquivalent(a, b, frames, lookupA, lookupB) {
675
+ if (a.visible !== b.visible || a.opacity !== b.opacity) return false;
676
+ if (a.blendMode !== b.blendMode || a.kind !== b.kind) return false;
677
+ for (let f = 0; f < frames; f++) {
678
+ if (!celContentEqual(celColorMap(a, f, lookupA), celColorMap(b, f, lookupB))) {
679
+ return false;
680
+ }
681
+ }
682
+ return true;
683
+ }
684
+
685
+ // Map a (layer, frame) cel to its non-transparent pixels keyed by CANVAS
686
+ // coordinate ("x,y") -> resolved hex color. Built from the cel's grid plus its
687
+ // (x, y) offset, INCLUDING off-canvas pixels (negative / out-of-bounds coords),
688
+ // so off-canvas content the compact form would drop shows up as a difference.
689
+ function celColorMap(layer, frameIndex, lookup) {
690
+ const map = new Map();
691
+ const resolved = resolveCell(layer, frameIndex);
692
+ if (!resolved) return map;
693
+ const { grid, x: ox, y: oy } = resolved;
694
+ for (let gy = 0; gy < grid.length; gy++) {
695
+ const row = grid[gy];
696
+ for (let gx = 0; gx < row.length; gx++) {
697
+ const color = colorForKeyV2(lookup, row[gx]);
698
+ if (color) map.set(`${gx + ox},${gy + oy}`, color);
699
+ }
700
+ }
701
+ return map;
702
+ }
703
+
704
+ function celContentEqual(a, b) {
705
+ if (a.size !== b.size) return false;
706
+ for (const [key, color] of a) {
707
+ if (b.get(key) !== color) return false;
708
+ }
709
+ return true;
710
+ }
711
+
712
+ // Clamp a (layer, frame) selection into the sprite's current bounds.
713
+ export function clampSelection(sprite, layerIndex, frameIndex) {
714
+ return {
715
+ layerIndex: clampIndex(layerIndex, sprite.layers.length),
716
+ frameIndex: clampIndex(frameIndex, frameCount(sprite)),
717
+ };
718
+ }