castle-web-cli 0.4.129 → 0.4.131

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