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.
- package/dist/agent-prompts.js +22 -3
- package/dist/agent.js +731 -313
- package/dist/init.js +1 -1
- package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
- package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/dist/vitePlugins.js +3 -2
- package/kits/basic-2d/CLAUDE.md +29 -8
- package/kits/basic-2d/behaviors/Collider.jsx +6 -4
- package/kits/basic-2d/behaviors/Layout.jsx +2 -2
- package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
- package/kits/basic-2d/behaviors/tint.js +47 -0
- package/kits/basic-2d/docs/pxart-format.md +298 -0
- package/kits/basic-2d/drawings/pig.pxart +59 -0
- package/kits/basic-2d/editors/App.jsx +125 -76
- package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
- package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
- package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
- package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
- package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
- package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
- package/kits/basic-2d/editors/codeTheme.js +135 -0
- package/kits/basic-2d/editors/editorHistory.js +44 -17
- package/kits/basic-2d/editors/inspectorSheet.js +23 -0
- package/kits/basic-2d/editors/pixelCanvas.js +11 -0
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
- package/kits/basic-2d/editors/pixelGeometry.js +45 -0
- package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
- package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
- package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/basic-2d/editors/pxArtTools.js +124 -0
- package/kits/basic-2d/editors/useArtboardFit.js +102 -0
- package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
- package/kits/basic-2d/engine/SceneUI.jsx +3 -11
- package/kits/basic-2d/engine/assets.js +15 -0
- package/kits/basic-2d/engine/files.js +57 -2
- package/kits/basic-2d/engine/pxart.js +985 -0
- package/kits/basic-2d/engine/scene.js +222 -41
- package/kits/basic-2d/engine/ui.jsx +155 -26
- package/kits/basic-2d/engine/ui.module.css +1280 -344
- package/kits/basic-2d/eslint.config.js +21 -0
- package/kits/basic-2d/index.html +13 -0
- package/kits/basic-2d/package.json +1 -0
- package/kits/basic-2d/pnpm-lock.yaml +5 -5
- package/kits/basic-2d/scenes/main.scene +19 -26
- package/kits/basic-2d/scripts/draw.mjs +121 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
- package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-BY21Og40.js +0 -106
- package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
- package/kits/basic-2d/drawings/block.drawing +0 -70
- package/kits/basic-2d/drawings/default.drawing +0 -70
- 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
|
+
}
|