castle-web-cli 0.4.115 → 0.4.117
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 +2 -2
- package/dist/agent.d.ts +9 -9
- package/dist/agent.js +590 -589
- package/dist/castleJson.d.ts +6 -0
- package/dist/castleJson.js +10 -0
- package/dist/editorConfig.js +27 -3
- package/dist/imports.d.ts +4 -0
- package/dist/imports.js +59 -10
- package/dist/init.d.ts +3 -0
- package/dist/init.js +103 -87
- package/dist/install.d.ts +1 -1
- package/dist/install.js +26 -24
- package/dist/shell/assets/{index-CUamb8rK.js → index-DiPlPGyg.js} +2 -2
- package/dist/shell/index.html +1 -1
- package/dist/vitePlugins.js +1 -1
- package/kits/physics-2d/CLAUDE.md +28 -16
- package/kits/physics-2d/behaviors/Joints.jsx +1 -1
- package/kits/physics-2d/behaviors/Sprite.jsx +17 -12
- package/kits/physics-2d/blueprints/ball.scene +1 -1
- package/kits/physics-2d/blueprints/block.scene +1 -1
- package/kits/physics-2d/blueprints/cauldron.scene +1 -1
- package/kits/physics-2d/blueprints/crate.scene +1 -1
- package/kits/physics-2d/castle.json +11 -3
- package/kits/physics-2d/docs/pxart-format.md +537 -51
- package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
- package/kits/physics-2d/editors/brushFit.js +535 -0
- package/kits/physics-2d/editors/brushShapes.js +140 -0
- package/kits/physics-2d/editors/mediaFile.js +12 -1
- package/kits/physics-2d/editors/pathOverlay.js +340 -0
- package/kits/physics-2d/editors/pathTools.js +1906 -0
- package/kits/physics-2d/editors/pixelCanvas.js +13 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
- package/kits/physics-2d/editors/pixelGeometry.js +4 -2
- package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
- package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
- package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
- package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
- package/kits/physics-2d/engine/assets.js +1 -1
- package/kits/physics-2d/engine/blueprint.js +3 -3
- package/kits/physics-2d/engine/files.js +3 -1
- package/kits/physics-2d/engine/liveReload.js +1 -1
- package/kits/physics-2d/engine/physics/jointArt.js +3 -3
- package/kits/physics-2d/engine/pxart.js +153 -35
- package/kits/physics-2d/engine/pxartPath.js +1356 -0
- package/kits/physics-2d/engine/pxartSmooth.js +276 -125
- package/kits/physics-2d/engine/ui.jsx +22 -1
- package/kits/physics-2d/engine/ui.module.css +36 -12
- package/kits/physics-2d/package-lock.json +1 -1
- package/kits/physics-2d/scripts/draw.mjs +7 -7
- package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
- package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
- package/package.json +1 -1
- /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
- /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
- /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
forEachCelPixel,
|
|
7
7
|
fromCells,
|
|
8
8
|
frameCount,
|
|
9
|
+
GRID_RENDERER,
|
|
10
|
+
isVectorRenderer,
|
|
9
11
|
paletteLookup,
|
|
10
12
|
parseFull,
|
|
11
13
|
RESOLUTION_MAX,
|
|
@@ -15,6 +17,7 @@ import {
|
|
|
15
17
|
serializeFull,
|
|
16
18
|
snapResolutionDim,
|
|
17
19
|
TRANSPARENT,
|
|
20
|
+
VECTOR_RENDERER,
|
|
18
21
|
} from '../engine/pxart';
|
|
19
22
|
import {
|
|
20
23
|
blankCells,
|
|
@@ -22,6 +25,13 @@ import {
|
|
|
22
25
|
EDITOR_PALETTE,
|
|
23
26
|
editorKeyForHex,
|
|
24
27
|
} from './pxArtTools';
|
|
28
|
+
import {
|
|
29
|
+
bakePathCell,
|
|
30
|
+
clonePathData,
|
|
31
|
+
emptyPathData,
|
|
32
|
+
isEmptyPathData,
|
|
33
|
+
translatePathData,
|
|
34
|
+
} from '../engine/pxartPath';
|
|
25
35
|
|
|
26
36
|
// ============================================================================
|
|
27
37
|
// Working-state helpers for the full Sprite the PxArt editor edits in memory.
|
|
@@ -78,17 +88,23 @@ function blankSprite() {
|
|
|
78
88
|
defaultDurationMs: DEFAULT_DURATION_MS,
|
|
79
89
|
tags: [],
|
|
80
90
|
defaultTag: undefined,
|
|
91
|
+
renderer: GRID_RENDERER,
|
|
81
92
|
cornerRadius: 0,
|
|
82
93
|
layers: [makeLayer('layer-0', 'Layer 1', [null])],
|
|
83
94
|
};
|
|
84
95
|
}
|
|
85
96
|
|
|
86
|
-
// Set the file-level
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
// Set the file-level finish: which renderer draws the sprite, and how much
|
|
98
|
+
// corner rounding the grid renderer applies. Either may be omitted to keep its
|
|
99
|
+
// current value. A no-op when nothing changes, so identical sprites don't
|
|
100
|
+
// produce spurious history entries.
|
|
101
|
+
export function setFinish(sprite, { renderer, cornerRadius } = {}) {
|
|
102
|
+
const nextRenderer = isVectorRenderer(renderer ?? sprite.renderer)
|
|
103
|
+
? VECTOR_RENDERER
|
|
104
|
+
: GRID_RENDERER;
|
|
105
|
+
const nextRadius = clampCornerRadius(cornerRadius ?? sprite.cornerRadius);
|
|
106
|
+
if (sprite.renderer === nextRenderer && sprite.cornerRadius === nextRadius) return sprite;
|
|
107
|
+
return { ...sprite, renderer: nextRenderer, cornerRadius: nextRadius };
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
function makeLayer(id, name, cells) {
|
|
@@ -119,7 +135,22 @@ function remapCell(cell, lookup) {
|
|
|
119
135
|
if (cell == null) return null;
|
|
120
136
|
if ('link' in cell) return { link: cell.link };
|
|
121
137
|
// Preserve the cel's offset while remapping its palette keys.
|
|
122
|
-
|
|
138
|
+
const next = gridCell(cell.grid.map((row) => remapRow(row, lookup)), cell.x ?? 0, cell.y ?? 0);
|
|
139
|
+
return cell.path ? { ...next, path: remapPathKeys(cell.path, lookup) } : next;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function remapPathKeys(path, lookup) {
|
|
143
|
+
const next = clonePathData(path);
|
|
144
|
+
for (const shape of next.shapes) {
|
|
145
|
+
if (shape.fill) shape.fill = remapKey(shape.fill, lookup);
|
|
146
|
+
if (shape.stroke) shape.stroke = remapKey(shape.stroke, lookup);
|
|
147
|
+
}
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function remapKey(key, lookup) {
|
|
152
|
+
const hex = colorForKeyV2(lookup, key);
|
|
153
|
+
return hex ? editorKeyForHex(hex) : key;
|
|
123
154
|
}
|
|
124
155
|
|
|
125
156
|
function remapRow(row, lookup) {
|
|
@@ -168,6 +199,13 @@ export function withCellsAt(sprite, layerIndex, frameIndex, cells) {
|
|
|
168
199
|
const layer = sprite.layers[layerIndex];
|
|
169
200
|
const target = writeTargetIndex(layer, frameIndex);
|
|
170
201
|
const prev = layer ? resolveCell(layer, target) : null;
|
|
202
|
+
// A path cel's grid is derived — it is whatever bakePathCell says. Writing
|
|
203
|
+
// pixels straight into it would either desync the grid from the source (the
|
|
204
|
+
// next path edit silently reverts the paint) or drop the source to keep them
|
|
205
|
+
// in step (silently destroying the vector art). The pixel tools are already
|
|
206
|
+
// gated off path layers in the UI, so this is unreachable today; it refuses
|
|
207
|
+
// rather than corrupting if a shared tool is ever wired up.
|
|
208
|
+
if (prev?.path) return sprite;
|
|
171
209
|
const merged = mergeWindowIntoCell(prev, cells, sprite.resolution);
|
|
172
210
|
return updateLayerCell(sprite, layerIndex, target, merged);
|
|
173
211
|
}
|
|
@@ -259,6 +297,21 @@ export function setCellOffset(sprite, layerIndex, frameIndex, x, y) {
|
|
|
259
297
|
const cell = layer.cells[target];
|
|
260
298
|
if (cell == null || 'link' in cell) return sprite;
|
|
261
299
|
if ((cell.x ?? 0) === x && (cell.y ?? 0) === y) return sprite;
|
|
300
|
+
if (cell.path) {
|
|
301
|
+
// A path cel has no offset to carry (§6.1.1): moving it means moving the
|
|
302
|
+
// source. Callers pass an ABSOLUTE offset computed against a gesture-start
|
|
303
|
+
// snapshot, and a path cel's offset is always 0, so the requested offset IS
|
|
304
|
+
// the delta. Snapping it keeps the translated points on the lattice even if
|
|
305
|
+
// the caller hands us a fractional drag.
|
|
306
|
+
const dx = x - (cell.x ?? 0);
|
|
307
|
+
const dy = y - (cell.y ?? 0);
|
|
308
|
+
const path = translatePathData(cell.path, dx, dy);
|
|
309
|
+
const { width, height } = sprite.resolution;
|
|
310
|
+
return updateLayerCell(sprite, layerIndex, target, {
|
|
311
|
+
grid: bakePathCell(path, width, height),
|
|
312
|
+
path,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
262
315
|
return updateLayerCell(sprite, layerIndex, target, gridCell(cell.grid, x, y));
|
|
263
316
|
}
|
|
264
317
|
|
|
@@ -295,6 +348,59 @@ export function cellKind(layer, frameIndex) {
|
|
|
295
348
|
return 'grid';
|
|
296
349
|
}
|
|
297
350
|
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// path layers
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
export function pathAt(sprite, layerIndex, frameIndex) {
|
|
356
|
+
const layer = sprite.layers[layerIndex];
|
|
357
|
+
const resolved = layer ? resolveCell(layer, frameIndex) : null;
|
|
358
|
+
return resolved?.path ?? emptyPathData();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Replace vector source and its baked pixel-grid atomically.
|
|
362
|
+
export function withPathAt(sprite, layerIndex, frameIndex, path) {
|
|
363
|
+
const layer = sprite.layers[layerIndex];
|
|
364
|
+
const target = writeTargetIndex(layer, frameIndex);
|
|
365
|
+
const prev = layer ? resolveCell(layer, target) : null;
|
|
366
|
+
const { width, height } = sprite.resolution;
|
|
367
|
+
const committed = clonePathData(path ?? emptyPathData());
|
|
368
|
+
const grid = bakePathCell(committed, width, height);
|
|
369
|
+
// The bake is canvas-framed, so the cel it produces sits at the origin — it
|
|
370
|
+
// does NOT inherit `prev`'s offset (§6.1.1). Inheriting would double-count the
|
|
371
|
+
// placement, since the points already carry it.
|
|
372
|
+
const cell = { grid };
|
|
373
|
+
const withPath = isEmptyPathData(committed) ? cell : { ...cell, path: committed };
|
|
374
|
+
return updateLayerCell(sprite, layerIndex, target, withPath);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Convert layer kind. Path conversion creates an EMPTY path source (no
|
|
378
|
+
// vectorization of existing pixels) and rebakes a transparent grid. Pixel
|
|
379
|
+
// conversion strips the vector source and keeps the baked grid as truth.
|
|
380
|
+
export function setLayerKind(sprite, layerIndex, kind) {
|
|
381
|
+
const nextKind = kind === 'path' ? 'path' : 'pixel';
|
|
382
|
+
let next = patchLayer(sprite, layerIndex, { kind: nextKind });
|
|
383
|
+
const layer = next.layers[layerIndex];
|
|
384
|
+
if (!layer) return next;
|
|
385
|
+
if (nextKind === 'path') {
|
|
386
|
+
const { width, height } = next.resolution;
|
|
387
|
+
const grid = bakePathCell(emptyPathData(), width, height);
|
|
388
|
+
const cells = layer.cells.map((cell) => {
|
|
389
|
+
if (cell && 'link' in cell) return cell;
|
|
390
|
+
const prev = cell && !('link' in cell) ? cell : null;
|
|
391
|
+
// Empty path source — omit `path` (same as withPathAt for empty).
|
|
392
|
+
return gridCell(grid, 0, 0);
|
|
393
|
+
});
|
|
394
|
+
return patchLayer(next, layerIndex, { cells });
|
|
395
|
+
}
|
|
396
|
+
const cells = layer.cells.map((cell) => {
|
|
397
|
+
if (!cell || 'link' in cell || !cell.path) return cell;
|
|
398
|
+
const { path: _path, ...rest } = cell;
|
|
399
|
+
return rest;
|
|
400
|
+
});
|
|
401
|
+
return patchLayer(next, layerIndex, { cells });
|
|
402
|
+
}
|
|
403
|
+
|
|
298
404
|
// ---------------------------------------------------------------------------
|
|
299
405
|
// linked cells
|
|
300
406
|
// ---------------------------------------------------------------------------
|
|
@@ -325,7 +431,8 @@ export function unlinkCell(sprite, layerIndex, frameIndex) {
|
|
|
325
431
|
const layer = sprite.layers[layerIndex];
|
|
326
432
|
const resolved = layer ? resolveCell(layer, frameIndex) : null;
|
|
327
433
|
// Copy the shared image AND its offset so the detached cel looks identical.
|
|
328
|
-
const
|
|
434
|
+
const grid = resolved ? gridCell(resolved.grid.slice(), resolved.x, resolved.y) : null;
|
|
435
|
+
const cell = grid && resolved.path ? { ...grid, path: clonePathData(resolved.path) } : grid;
|
|
329
436
|
return updateLayerCell(sprite, layerIndex, frameIndex, cell);
|
|
330
437
|
}
|
|
331
438
|
|
|
@@ -342,15 +449,40 @@ function uniqueLayerId(sprite) {
|
|
|
342
449
|
}
|
|
343
450
|
|
|
344
451
|
// 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
|
-
|
|
347
|
-
|
|
452
|
+
// layers on top; layer 0 is the bottom of the stack). `kind` is 'pixel' (default)
|
|
453
|
+
// or 'path' — a new path layer gets an empty path source and a transparent baked
|
|
454
|
+
// grid per frame (same empty-path treatment as setLayerKind → path).
|
|
455
|
+
export function addLayer(sprite, layerIndex, kind = 'pixel') {
|
|
456
|
+
const nextKind = kind === 'path' ? 'path' : 'pixel';
|
|
457
|
+
let cells = sprite.frames.map(() => null);
|
|
458
|
+
if (nextKind === 'path') {
|
|
459
|
+
const { width, height } = sprite.resolution;
|
|
460
|
+
const grid = bakePathCell(emptyPathData(), width, height);
|
|
461
|
+
cells = sprite.frames.map(() => gridCell(grid, 0, 0));
|
|
462
|
+
}
|
|
348
463
|
const layer = makeLayer(uniqueLayerId(sprite), `Layer ${sprite.layers.length + 1}`, cells);
|
|
464
|
+
layer.kind = nextKind;
|
|
349
465
|
const layers = sprite.layers.slice();
|
|
350
466
|
layers.splice(layerIndex + 1, 0, layer);
|
|
351
467
|
return { ...sprite, layers };
|
|
352
468
|
}
|
|
353
469
|
|
|
470
|
+
/** True when any frame of the layer has a non-transparent baked pixel. */
|
|
471
|
+
export function layerHasOpaquePixels(sprite, layerIndex) {
|
|
472
|
+
const layer = sprite.layers[layerIndex];
|
|
473
|
+
if (!layer) return false;
|
|
474
|
+
for (let f = 0; f < layer.cells.length; f++) {
|
|
475
|
+
const resolved = resolveCell(layer, f);
|
|
476
|
+
if (!resolved?.grid) continue;
|
|
477
|
+
for (const row of resolved.grid) {
|
|
478
|
+
for (const ch of row) {
|
|
479
|
+
if (ch !== TRANSPARENT) return true;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
|
|
354
486
|
export function deleteLayer(sprite, layerIndex) {
|
|
355
487
|
if (sprite.layers.length <= 1) return sprite;
|
|
356
488
|
const layers = sprite.layers.filter((_, i) => i !== layerIndex);
|
|
@@ -363,6 +495,7 @@ export function duplicateLayer(sprite, layerIndex) {
|
|
|
363
495
|
const copy = makeLayer(uniqueLayerId(sprite), `${src.name} copy`, src.cells.map(cloneCell));
|
|
364
496
|
copy.visible = src.visible;
|
|
365
497
|
copy.opacity = src.opacity;
|
|
498
|
+
copy.kind = src.kind;
|
|
366
499
|
const layers = sprite.layers.slice();
|
|
367
500
|
layers.splice(layerIndex + 1, 0, copy);
|
|
368
501
|
return { ...sprite, layers };
|
|
@@ -385,7 +518,8 @@ export function patchLayer(sprite, layerIndex, patch) {
|
|
|
385
518
|
function cloneCell(cell) {
|
|
386
519
|
if (cell == null) return null;
|
|
387
520
|
if ('link' in cell) return { link: cell.link };
|
|
388
|
-
|
|
521
|
+
const next = gridCell(cell.grid.slice(), cell.x ?? 0, cell.y ?? 0);
|
|
522
|
+
return cell.path ? { ...next, path: clonePathData(cell.path) } : next;
|
|
389
523
|
}
|
|
390
524
|
|
|
391
525
|
// ---------------------------------------------------------------------------
|
|
@@ -410,6 +544,7 @@ function layerSlots(layer) {
|
|
|
410
544
|
image: resolved?.grid ?? null,
|
|
411
545
|
x: resolved?.x ?? 0,
|
|
412
546
|
y: resolved?.y ?? 0,
|
|
547
|
+
path: resolved?.path,
|
|
413
548
|
wasLink: cell != null && 'link' in cell,
|
|
414
549
|
};
|
|
415
550
|
});
|
|
@@ -438,11 +573,16 @@ function relinkSlots(slots) {
|
|
|
438
573
|
slot.wasLink &&
|
|
439
574
|
gridsEqual(slots[i - 1].image, slot.image) &&
|
|
440
575
|
slots[i - 1].x === slot.x &&
|
|
441
|
-
slots[i - 1].y === slot.y
|
|
576
|
+
slots[i - 1].y === slot.y &&
|
|
577
|
+
slots[i - 1].path === slot.path
|
|
442
578
|
) {
|
|
443
579
|
return { link: i - 1 };
|
|
444
580
|
}
|
|
445
|
-
|
|
581
|
+
const cell = gridCell(slot.image.slice(), slot.x, slot.y);
|
|
582
|
+
// Slots that resolved through a link chain share one source object, so a
|
|
583
|
+
// deep copy is what makes each materialized cel independent — otherwise two
|
|
584
|
+
// frames that merely LOOKED alike would keep pointing at a single path.
|
|
585
|
+
return slot.path ? { ...cell, path: clonePathData(slot.path) } : cell;
|
|
446
586
|
});
|
|
447
587
|
}
|
|
448
588
|
|
|
@@ -503,6 +643,7 @@ export function addFrame(sprite, frameIndex) {
|
|
|
503
643
|
image: resolved?.grid ?? null,
|
|
504
644
|
x: resolved?.x ?? 0,
|
|
505
645
|
y: resolved?.y ?? 0,
|
|
646
|
+
path: resolved?.path,
|
|
506
647
|
wasLink: resolved != null,
|
|
507
648
|
};
|
|
508
649
|
});
|
|
@@ -521,6 +662,7 @@ export function duplicateFrame(sprite, frameIndex) {
|
|
|
521
662
|
image: resolved ? resolved.grid.slice() : null,
|
|
522
663
|
x: resolved?.x ?? 0,
|
|
523
664
|
y: resolved?.y ?? 0,
|
|
665
|
+
path: resolved?.path ? clonePathData(resolved.path) : undefined,
|
|
524
666
|
wasLink: false,
|
|
525
667
|
};
|
|
526
668
|
});
|
|
@@ -625,13 +767,23 @@ export function resizeSprite(sprite, width, height) {
|
|
|
625
767
|
const dy = Math.floor((h - sprite.resolution.height) / 2);
|
|
626
768
|
const layers = sprite.layers.map((layer) => ({
|
|
627
769
|
...layer,
|
|
628
|
-
cells: layer.cells.map((cell) => shiftCellOffset(cell, dx, dy)),
|
|
770
|
+
cells: layer.cells.map((cell) => shiftCellOffset(cell, dx, dy, w, h)),
|
|
629
771
|
}));
|
|
630
772
|
return { ...sprite, resolution: { width: w, height: h }, layers };
|
|
631
773
|
}
|
|
632
774
|
|
|
633
|
-
|
|
775
|
+
// Reframe one cel by the centered delta. A pixel cel moves via its offset; a
|
|
776
|
+
// path cel cannot, because path points are canvas-framed and a path cell's x/y
|
|
777
|
+
// must stay 0 (§6.1.1). Shifting the offset instead would leave the vector
|
|
778
|
+
// source describing the OLD frame, so the grid and the source would disagree —
|
|
779
|
+
// invisible until the next edit re-baked the art back to where the points still
|
|
780
|
+
// said it was. Translating the source and re-baking keeps the two in step.
|
|
781
|
+
function shiftCellOffset(cell, dx, dy, width, height) {
|
|
634
782
|
if (cell == null || 'link' in cell) return cell;
|
|
783
|
+
if (cell.path) {
|
|
784
|
+
const path = translatePathData(cell.path, dx, dy);
|
|
785
|
+
return { grid: bakePathCell(path, width, height), path };
|
|
786
|
+
}
|
|
635
787
|
return gridCell(cell.grid, (cell.x ?? 0) + dx, (cell.y ?? 0) + dy);
|
|
636
788
|
}
|
|
637
789
|
|
|
@@ -647,6 +799,7 @@ function shiftCellOffset(cell, dx, dy) {
|
|
|
647
799
|
export function serializeModel(sprite) {
|
|
648
800
|
const compact = serializeCompact({
|
|
649
801
|
...fromCells(EDITOR_PALETTE, cellsAt(sprite, 0, 0)),
|
|
802
|
+
renderer: sprite.renderer,
|
|
650
803
|
cornerRadius: sprite.cornerRadius,
|
|
651
804
|
});
|
|
652
805
|
const roundTripped = parseFull(compact);
|
|
@@ -672,7 +825,10 @@ function losslesslyCompact(sprite, roundTripped) {
|
|
|
672
825
|
if (frameCount(sprite) !== frameCount(roundTripped)) return false;
|
|
673
826
|
if (sprite.tags.length !== roundTripped.tags.length) return false;
|
|
674
827
|
if ((sprite.defaultTag ?? null) !== (roundTripped.defaultTag ?? null)) return false;
|
|
828
|
+
// Both finish fields, so a compact form that dropped either falls through to
|
|
829
|
+
// the full form instead of silently reverting the sprite's look.
|
|
675
830
|
if (sprite.cornerRadius !== roundTripped.cornerRadius) return false;
|
|
831
|
+
if ((sprite.renderer ?? GRID_RENDERER) !== (roundTripped.renderer ?? GRID_RENDERER)) return false;
|
|
676
832
|
if (sprite.layers.length !== roundTripped.layers.length) return false;
|
|
677
833
|
|
|
678
834
|
const lookupA = paletteLookup(sprite.palette);
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import { faEye, faEyeSlash, faLink, faStopwatch } from '@fortawesome/free-solid-svg-icons';
|
|
3
|
-
import { colorForKeyV2, paletteLookup, resolveCell, resolveCellGrid } from '../engine/pxart';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { colorForKeyV2, displayFinish, paletteLookup, resolveCell, resolveCellGrid } from '../engine/pxart';
|
|
4
|
+
import { renderSmoothLayerCell } from '../engine/pxartSmooth';
|
|
5
|
+
import {
|
|
6
|
+
ConfirmDialog,
|
|
7
|
+
Icon,
|
|
8
|
+
IconButton,
|
|
9
|
+
SheetGrabHandle,
|
|
10
|
+
styles as ui,
|
|
11
|
+
useCompactViewport,
|
|
12
|
+
useMobileSheet,
|
|
13
|
+
} from '../engine/ui';
|
|
14
|
+
import {
|
|
15
|
+
cellKind,
|
|
16
|
+
forEachGridCell,
|
|
17
|
+
layerHasOpaquePixels,
|
|
18
|
+
} from './pxArtEditorModel';
|
|
6
19
|
import tl from './pxArtTimeline.module.css';
|
|
7
20
|
|
|
8
21
|
// Tags and per-frame duration are intentionally hidden for now — we plan to
|
|
@@ -131,7 +144,7 @@ function useResizableTimelineHeight() {
|
|
|
131
144
|
// the top, Aseprite-style) by frame COLUMNS, with thumbnails per cell, layer +
|
|
132
145
|
// frame controls, linked-cell affordances, onion-skin toggles, playback, and a
|
|
133
146
|
// tags editor. All mutation flows through `actions` (wired in PxArtEditor).
|
|
134
|
-
export function PxArtTimeline({ sprite, selection, playing, onion, actions }) {
|
|
147
|
+
export function PxArtTimeline({ sprite, text, selection, playing, onion, actions }) {
|
|
135
148
|
// Hooks run unconditionally (order-stable); only the render branches on viewport.
|
|
136
149
|
const compact = useCompactViewport();
|
|
137
150
|
const resizable = useResizableTimelineHeight();
|
|
@@ -144,7 +157,7 @@ export function PxArtTimeline({ sprite, selection, playing, onion, actions }) {
|
|
|
144
157
|
});
|
|
145
158
|
const grid = (
|
|
146
159
|
<div className={tl.scroll}>
|
|
147
|
-
<TimelineGrid sprite={sprite} selection={selection} actions={actions} />
|
|
160
|
+
<TimelineGrid sprite={sprite} text={text} selection={selection} actions={actions} />
|
|
148
161
|
</div>
|
|
149
162
|
);
|
|
150
163
|
if (compact) {
|
|
@@ -212,8 +225,16 @@ function TimelineBar({ sprite, selection, playing, onion, actions }) {
|
|
|
212
225
|
return (
|
|
213
226
|
<div className={tl.bar}>
|
|
214
227
|
<div className={tl.barGroup}>
|
|
215
|
-
<IconButton icon="
|
|
216
|
-
<span className={tl.barLabel}>
|
|
228
|
+
<IconButton icon="grid" label="Pixel" onClick={() => actions.addLayer(layerIndex)} />
|
|
229
|
+
<span className={tl.barLabel}>Pixel</span>
|
|
230
|
+
</div>
|
|
231
|
+
<div className={tl.barGroup}>
|
|
232
|
+
<IconButton
|
|
233
|
+
icon="draw-polygon"
|
|
234
|
+
label="Path"
|
|
235
|
+
onClick={() => actions.addLayer(layerIndex, 'path')}
|
|
236
|
+
/>
|
|
237
|
+
<span className={tl.barLabel}>Path</span>
|
|
217
238
|
</div>
|
|
218
239
|
<div className={tl.barGroup}>
|
|
219
240
|
<IconButton icon="film" label="Frame" onClick={() => actions.addFrame(frameIndex)} />
|
|
@@ -299,11 +320,12 @@ function OnionControls({ onion, actions }) {
|
|
|
299
320
|
);
|
|
300
321
|
}
|
|
301
322
|
|
|
302
|
-
function TimelineGrid({ sprite, selection, actions }) {
|
|
323
|
+
function TimelineGrid({ sprite, text, selection, actions }) {
|
|
303
324
|
const frames = sprite.frames;
|
|
304
325
|
// Render rows top-down: the last layer (top of the composite stack) first.
|
|
305
326
|
const rows = sprite.layers.map((_, i) => i).reverse();
|
|
306
327
|
const [menu, setMenu] = useState(null);
|
|
328
|
+
const [convertConfirm, setConvertConfirm] = useState(null);
|
|
307
329
|
const closeMenu = useCallback(() => setMenu(null), []);
|
|
308
330
|
|
|
309
331
|
// Each opener stops the browser's native menu, anchors at the cursor, and
|
|
@@ -359,6 +381,7 @@ function TimelineGrid({ sprite, selection, actions }) {
|
|
|
359
381
|
<CellThumb
|
|
360
382
|
key={f}
|
|
361
383
|
sprite={sprite}
|
|
384
|
+
text={text}
|
|
362
385
|
layerIndex={li}
|
|
363
386
|
frameIndex={f}
|
|
364
387
|
active={li === selection.layerIndex && f === selection.frameIndex}
|
|
@@ -372,10 +395,32 @@ function TimelineGrid({ sprite, selection, actions }) {
|
|
|
372
395
|
<ContextMenu
|
|
373
396
|
x={menu.x}
|
|
374
397
|
y={menu.y}
|
|
375
|
-
items={buildMenuItems(menu, sprite, actions
|
|
398
|
+
items={buildMenuItems(menu, sprite, actions, {
|
|
399
|
+
requestConvertToPath: (layerIndex) => {
|
|
400
|
+
const layer = sprite.layers[layerIndex];
|
|
401
|
+
const name = layer?.name || `Layer ${layerIndex + 1}`;
|
|
402
|
+
if (layerHasOpaquePixels(sprite, layerIndex)) {
|
|
403
|
+
setConvertConfirm({ layerIndex, name });
|
|
404
|
+
} else {
|
|
405
|
+
actions.setLayerKind(layerIndex, 'path');
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
})}
|
|
376
409
|
onClose={closeMenu}
|
|
377
410
|
/>
|
|
378
411
|
) : null}
|
|
412
|
+
{convertConfirm ? (
|
|
413
|
+
<ConfirmDialog
|
|
414
|
+
title={`Convert ${convertConfirm.name} to a path layer?`}
|
|
415
|
+
message={`${convertConfirm.name} has painted pixels. Converting to a path layer clears those pixels (no vectorization). This can't be undone except via Undo.`}
|
|
416
|
+
confirmLabel="Convert"
|
|
417
|
+
onCancel={() => setConvertConfirm(null)}
|
|
418
|
+
onConfirm={() => {
|
|
419
|
+
actions.setLayerKind(convertConfirm.layerIndex, 'path');
|
|
420
|
+
setConvertConfirm(null);
|
|
421
|
+
}}
|
|
422
|
+
/>
|
|
423
|
+
) : null}
|
|
379
424
|
</div>
|
|
380
425
|
);
|
|
381
426
|
}
|
|
@@ -441,16 +486,29 @@ function ContextMenu({ x, y, items, onClose }) {
|
|
|
441
486
|
);
|
|
442
487
|
}
|
|
443
488
|
|
|
444
|
-
function buildMenuItems(menu, sprite, actions) {
|
|
445
|
-
if (menu.kind === 'layer') return layerMenuItems(menu, sprite, actions);
|
|
489
|
+
function buildMenuItems(menu, sprite, actions, layerExtras = {}) {
|
|
490
|
+
if (menu.kind === 'layer') return layerMenuItems(menu, sprite, actions, layerExtras);
|
|
446
491
|
if (menu.kind === 'frame') return frameMenuItems(menu, sprite, actions);
|
|
447
492
|
return cellMenuItems(menu, sprite, actions);
|
|
448
493
|
}
|
|
449
494
|
|
|
450
|
-
function layerMenuItems(menu, sprite, actions) {
|
|
495
|
+
function layerMenuItems(menu, sprite, actions, { requestConvertToPath } = {}) {
|
|
451
496
|
const { layerIndex } = menu;
|
|
497
|
+
const isPath = sprite.layers[layerIndex]?.kind === 'path';
|
|
452
498
|
return [
|
|
453
499
|
{ key: 'duplicate', label: 'Duplicate', onClick: () => actions.duplicateLayer(layerIndex) },
|
|
500
|
+
{
|
|
501
|
+
key: 'toggle-kind',
|
|
502
|
+
label: isPath ? 'Convert to Pixel Layer' : 'Convert to Path Layer',
|
|
503
|
+
onClick: () => {
|
|
504
|
+
if (isPath) {
|
|
505
|
+
actions.setLayerKind(layerIndex, 'pixel');
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (requestConvertToPath) requestConvertToPath(layerIndex);
|
|
509
|
+
else actions.setLayerKind(layerIndex, 'path');
|
|
510
|
+
},
|
|
511
|
+
},
|
|
454
512
|
{
|
|
455
513
|
key: 'up',
|
|
456
514
|
label: 'Move up',
|
|
@@ -546,6 +604,15 @@ function FrameHeader({ frame, index, globalMs, active, onSelect, onDuration, onC
|
|
|
546
604
|
}
|
|
547
605
|
|
|
548
606
|
const btnReset = { background: 'none', border: 0, color: 'inherit', cursor: 'pointer', padding: 0 };
|
|
607
|
+
const kindBadgeStyle = {
|
|
608
|
+
flex: '0 0 auto',
|
|
609
|
+
display: 'inline-flex',
|
|
610
|
+
alignItems: 'center',
|
|
611
|
+
justifyContent: 'center',
|
|
612
|
+
color: 'var(--castle-muted, #888)',
|
|
613
|
+
fontSize: 11,
|
|
614
|
+
opacity: 0.85,
|
|
615
|
+
};
|
|
549
616
|
|
|
550
617
|
// A free-form text input that holds a local draft while focused and commits it
|
|
551
618
|
// on blur/Enter, re-syncing to `text` when idle. The caller's `onCommit(draft)`
|
|
@@ -621,6 +688,26 @@ function LayerHeader({ layer, index, active, onSelect, actions, onContextMenu })
|
|
|
621
688
|
onClick={onSelect}
|
|
622
689
|
onContextMenu={(event) => onContextMenu(event, index)}>
|
|
623
690
|
<div className={tl.layerTopRow}>
|
|
691
|
+
<span
|
|
692
|
+
style={kindBadgeStyle}
|
|
693
|
+
title={layer.kind === 'path' ? 'Path layer' : 'Pixel layer'}>
|
|
694
|
+
<Icon name={layer.kind === 'path' ? 'draw-polygon' : 'grid'} />
|
|
695
|
+
</span>
|
|
696
|
+
<span className={tl.name} title={layer.name}>
|
|
697
|
+
{layer.name}
|
|
698
|
+
</span>
|
|
699
|
+
</div>
|
|
700
|
+
<div className={tl.layerBottomRow}>
|
|
701
|
+
<input
|
|
702
|
+
className={tl.opacity}
|
|
703
|
+
type="range"
|
|
704
|
+
min="0"
|
|
705
|
+
max="100"
|
|
706
|
+
value={Math.round(layer.opacity * 100)}
|
|
707
|
+
title={`Opacity ${Math.round(layer.opacity * 100)}%`}
|
|
708
|
+
onClick={(event) => event.stopPropagation()}
|
|
709
|
+
onChange={(event) => actions.patchLayer(index, { opacity: Number(event.target.value) / 100 })}
|
|
710
|
+
/>
|
|
624
711
|
<button
|
|
625
712
|
type="button"
|
|
626
713
|
className={layer.visible ? tl.visBtn : tl.visBtn + ' ' + tl.visOff}
|
|
@@ -631,31 +718,26 @@ function LayerHeader({ layer, index, active, onSelect, actions, onContextMenu })
|
|
|
631
718
|
}}>
|
|
632
719
|
<FaGlyph icon={layer.visible ? faEye : faEyeSlash} />
|
|
633
720
|
</button>
|
|
634
|
-
<span className={tl.name} title={layer.name}>
|
|
635
|
-
{layer.name}
|
|
636
|
-
</span>
|
|
637
721
|
</div>
|
|
638
|
-
<input
|
|
639
|
-
className={tl.opacity}
|
|
640
|
-
type="range"
|
|
641
|
-
min="0"
|
|
642
|
-
max="100"
|
|
643
|
-
value={Math.round(layer.opacity * 100)}
|
|
644
|
-
title={`Opacity ${Math.round(layer.opacity * 100)}%`}
|
|
645
|
-
onClick={(event) => event.stopPropagation()}
|
|
646
|
-
onChange={(event) => actions.patchLayer(index, { opacity: Number(event.target.value) / 100 })}
|
|
647
|
-
/>
|
|
648
722
|
</div>
|
|
649
723
|
);
|
|
650
724
|
}
|
|
651
725
|
|
|
652
|
-
function CellThumb({ sprite, layerIndex, frameIndex, active, onSelect, onContextMenu }) {
|
|
726
|
+
function CellThumb({ sprite, text, layerIndex, frameIndex, active, onSelect, onContextMenu }) {
|
|
653
727
|
const canvasRef = useRef(null);
|
|
654
728
|
const layer = sprite.layers[layerIndex];
|
|
655
729
|
const kind = cellKind(layer, frameIndex);
|
|
656
|
-
|
|
730
|
+
const smoothThumb = displayFinish(sprite).smooth;
|
|
731
|
+
// Gated on `text` rather than `sprite`, which deriveSprite rebuilds every
|
|
732
|
+
// render. Without a dependency list every thumb redrew on every timeline
|
|
733
|
+
// render — including on every pointer move over the artboard — and at a
|
|
734
|
+
// rounded finish each redraw runs the corner kernel, so a many-cel sprite
|
|
735
|
+
// burned tens of milliseconds repainting thumbs that had not changed.
|
|
736
|
+
// Content is the only trigger needed: cells are a fixed 52px, so a thumb's
|
|
737
|
+
// size never changes after mount.
|
|
738
|
+
useLayoutEffect(() => {
|
|
657
739
|
if (canvasRef.current && kind !== 'empty') drawCellThumb(canvasRef.current, sprite, layerIndex, frameIndex);
|
|
658
|
-
});
|
|
740
|
+
}, [text, kind, layerIndex, frameIndex]);
|
|
659
741
|
return (
|
|
660
742
|
<button
|
|
661
743
|
type="button"
|
|
@@ -667,7 +749,10 @@ function CellThumb({ sprite, layerIndex, frameIndex, active, onSelect, onContext
|
|
|
667
749
|
<span className={tl.empty} />
|
|
668
750
|
) : (
|
|
669
751
|
<span className={kind === 'link' ? tl.thumb + ' ' + tl.linkRing : tl.thumb}>
|
|
670
|
-
<canvas
|
|
752
|
+
<canvas
|
|
753
|
+
ref={canvasRef}
|
|
754
|
+
className={smoothThumb ? tl.thumbCanvasSmooth : tl.thumbCanvas}
|
|
755
|
+
/>
|
|
671
756
|
</span>
|
|
672
757
|
)}
|
|
673
758
|
{kind === 'link' ? (
|
|
@@ -679,19 +764,46 @@ function CellThumb({ sprite, layerIndex, frameIndex, active, onSelect, onContext
|
|
|
679
764
|
);
|
|
680
765
|
}
|
|
681
766
|
|
|
682
|
-
// Paint a single (layer, frame) cell
|
|
683
|
-
//
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
767
|
+
// Paint a single (layer, frame) cell onto a display-resolution canvas (crisp at
|
|
768
|
+
// the timeline cell size). Offset-aware clipping matches renderSpriteFrame.
|
|
769
|
+
function setupThumbCanvas(canvas, resolution) {
|
|
770
|
+
const cssW = canvas.clientWidth;
|
|
771
|
+
const cssH = canvas.clientHeight;
|
|
772
|
+
if (!cssW || !cssH) return null;
|
|
773
|
+
const dpr = window.devicePixelRatio || 1;
|
|
774
|
+
const bw = Math.round(cssW * dpr);
|
|
775
|
+
const bh = Math.round(cssH * dpr);
|
|
776
|
+
if (canvas.width !== bw) canvas.width = bw;
|
|
777
|
+
if (canvas.height !== bh) canvas.height = bh;
|
|
778
|
+
const ctx = canvas.getContext('2d');
|
|
779
|
+
if (!ctx) return null;
|
|
780
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
781
|
+
ctx.clearRect(0, 0, bw, bh);
|
|
782
|
+
return { ctx, sx: bw / resolution.width, sy: bh / resolution.height };
|
|
783
|
+
}
|
|
784
|
+
|
|
689
785
|
function drawCellThumb(canvas, sprite, layerIndex, frameIndex) {
|
|
690
|
-
const
|
|
691
|
-
if (!
|
|
786
|
+
const setup = setupThumbCanvas(canvas, sprite.resolution);
|
|
787
|
+
if (!setup) return;
|
|
788
|
+
const { ctx, sx, sy } = setup;
|
|
789
|
+
const { width, height } = sprite.resolution;
|
|
790
|
+
|
|
791
|
+
// Thumbnails show the sprite's chosen finish, so a Smooth sprite reads as
|
|
792
|
+
// Smooth in the timeline. renderSmoothLayerCell handles the whole finish
|
|
793
|
+
// space: analytic shapes for path layers under the vector renderer, corner
|
|
794
|
+
// kernel otherwise.
|
|
795
|
+
if (displayFinish(sprite).smooth) {
|
|
796
|
+
const scale = (sx + sy) / 2;
|
|
797
|
+
const off = document.createElement('canvas');
|
|
798
|
+
renderSmoothLayerCell(sprite, layerIndex, frameIndex, off, { scale });
|
|
799
|
+
ctx.imageSmoothingEnabled = true;
|
|
800
|
+
ctx.drawImage(off, 0, 0, off.width, off.height, 0, 0, canvas.width, canvas.height);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
ctx.imageSmoothingEnabled = false;
|
|
692
805
|
const resolved = resolveCell(sprite.layers[layerIndex], frameIndex);
|
|
693
806
|
if (!resolved) return;
|
|
694
|
-
const { width, height } = sprite.resolution;
|
|
695
807
|
const { grid, x: offsetX, y: offsetY } = resolved;
|
|
696
808
|
const lookup = paletteLookup(sprite.palette);
|
|
697
809
|
forEachGridCell(grid, Infinity, Infinity, (gx, gy, key) => {
|
|
@@ -699,10 +811,18 @@ function drawCellThumb(canvas, sprite, layerIndex, frameIndex) {
|
|
|
699
811
|
const cy = gy + offsetY;
|
|
700
812
|
if (cx < 0 || cx >= width || cy < 0 || cy >= height) return;
|
|
701
813
|
const color = colorForKeyV2(lookup, key);
|
|
702
|
-
if (color)
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
814
|
+
if (!color) return;
|
|
815
|
+
// Snap every cell to whole device pixels. The scale here is fractional (a
|
|
816
|
+
// 16px sprite in a 43px cell is 5.375), and a fractional fillRect
|
|
817
|
+
// anti-aliases both sides of each shared edge; two half-covered columns
|
|
818
|
+
// composite to ~75% rather than 100%, which is the faint grid that shows
|
|
819
|
+
// over flat colour. Deriving both edges from the same rounded expression
|
|
820
|
+
// makes neighbours land on exactly the same boundary. The cost is that
|
|
821
|
+
// cells differ by up to one device pixel in size.
|
|
822
|
+
const x0 = Math.round(cx * sx);
|
|
823
|
+
const y0 = Math.round(cy * sy);
|
|
824
|
+
ctx.fillStyle = color;
|
|
825
|
+
ctx.fillRect(x0, y0, Math.round((cx + 1) * sx) - x0, Math.round((cy + 1) * sy) - y0);
|
|
706
826
|
});
|
|
707
827
|
}
|
|
708
828
|
|