castle-web-cli 0.4.72 → 0.4.74
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 +703 -222
- package/kits/basic-2d/editors/SelectionOverlay.jsx +818 -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 +169 -13
- 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/playConsole.js +66 -0
- 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 +1385 -332
- 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-vmdKwUE1.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,662 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { frameCount, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
|
|
3
|
+
import { basename } from '../engine/files';
|
|
4
|
+
import { EditorBody, styles } from '../engine/ui';
|
|
5
|
+
import { eventToCell } from './pixelCanvas';
|
|
6
|
+
import { forEachDab } from './pixelGeometry';
|
|
7
|
+
import { PixelArtboard, PixelEditorHeader, usePixelEditorShell } from './pixelEditorChrome';
|
|
8
|
+
import {
|
|
9
|
+
BRUSH_SIZES,
|
|
10
|
+
CanvasSizeBar,
|
|
11
|
+
PaintStrip,
|
|
12
|
+
PIXEL_TOOLS,
|
|
13
|
+
PixelToolStrip,
|
|
14
|
+
PxArtInspectorDock,
|
|
15
|
+
usePixelToolState,
|
|
16
|
+
} from './pixelInspector';
|
|
17
|
+
import { useArtboardFit, useEditorCompactLayout } from './useArtboardFit';
|
|
18
|
+
import {
|
|
19
|
+
EDITOR_KEYS,
|
|
20
|
+
EDITOR_PALETTE,
|
|
21
|
+
fillRegion,
|
|
22
|
+
paintLine,
|
|
23
|
+
sampleKey,
|
|
24
|
+
swapKey,
|
|
25
|
+
} from './pxArtTools';
|
|
26
|
+
import {
|
|
27
|
+
addFrame,
|
|
28
|
+
addLayer,
|
|
29
|
+
addTag,
|
|
30
|
+
cellOffsetAt,
|
|
31
|
+
cellsAt,
|
|
32
|
+
clampSelection,
|
|
33
|
+
deleteFrame,
|
|
34
|
+
deleteLayer,
|
|
35
|
+
deleteTag,
|
|
36
|
+
deriveSprite,
|
|
37
|
+
duplicateFrame,
|
|
38
|
+
duplicateLayer,
|
|
39
|
+
linkCell,
|
|
40
|
+
moveFrame,
|
|
41
|
+
moveLayer,
|
|
42
|
+
patchLayer,
|
|
43
|
+
patchTag,
|
|
44
|
+
resizeSprite,
|
|
45
|
+
serializeModel,
|
|
46
|
+
setCellOffset,
|
|
47
|
+
setDefaultDuration,
|
|
48
|
+
setDefaultTag,
|
|
49
|
+
setFrameDuration,
|
|
50
|
+
setupSpriteCanvas,
|
|
51
|
+
unlinkCell,
|
|
52
|
+
withCellsAt,
|
|
53
|
+
} from './pxArtEditorModel';
|
|
54
|
+
import { frameDurations, playbackOrder, resolveActiveTag, usePreviewPlayback } from './pxArtPlayback';
|
|
55
|
+
import { PxArtTimeline } from './pxArtTimeline';
|
|
56
|
+
import tl from './pxArtTimeline.module.css';
|
|
57
|
+
|
|
58
|
+
// Aseprite-style editor for the full .pxart sprite format: a full layers x frames
|
|
59
|
+
// timeline below a compositing artboard. The artboard composites the active
|
|
60
|
+
// frame's visible layers (renderSpriteFrame) so the artist sees the whole image
|
|
61
|
+
// while painting targets the active layer's cell, over a FIXED Endesga-64
|
|
62
|
+
// palette and power-of-two resolutions. Sprites that lose nothing in the compact
|
|
63
|
+
// form serialize to compact; anything richer serializes to the full form.
|
|
64
|
+
const ONION_WARM = '#ff7a3c';
|
|
65
|
+
const ONION_COOL = '#3ca0ff';
|
|
66
|
+
|
|
67
|
+
// Native CSS cursors over the artboard, keyed by the active tool. The transient
|
|
68
|
+
// eyedropper (picker mode) overrides any of these with 'crosshair'. Tools not
|
|
69
|
+
// listed fall back to the default cursor.
|
|
70
|
+
const TOOL_CURSORS = {
|
|
71
|
+
brush: 'cell',
|
|
72
|
+
erase: 'cell',
|
|
73
|
+
fill: 'crosshair',
|
|
74
|
+
'move-all': 'move',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
78
|
+
const canvasRef = useRef(null);
|
|
79
|
+
const strokeRef = useRef(null);
|
|
80
|
+
const [activeKey, setActiveKey] = useState(EDITOR_KEYS[1]);
|
|
81
|
+
const [rawSelection, setRawSelection] = useState({ layerIndex: 0, frameIndex: 0 });
|
|
82
|
+
const [playing, setPlaying] = useState(false);
|
|
83
|
+
const [onion, setOnion] = useState({ enabled: false, range: 1 });
|
|
84
|
+
const [hoverCell, setHoverCell] = useState(null);
|
|
85
|
+
const toolState = usePixelToolState();
|
|
86
|
+
const {
|
|
87
|
+
tool,
|
|
88
|
+
setTool,
|
|
89
|
+
picking,
|
|
90
|
+
setPicking,
|
|
91
|
+
eraseFillMode,
|
|
92
|
+
fillSwapMode,
|
|
93
|
+
brushSize,
|
|
94
|
+
setBrushSize,
|
|
95
|
+
eraseSize,
|
|
96
|
+
setEraseSize,
|
|
97
|
+
} = toolState;
|
|
98
|
+
const { history, headerShell } = usePixelEditorShell(text, onChange);
|
|
99
|
+
const canvasWrapRef = useRef(null);
|
|
100
|
+
const canvasSizeBarRef = useRef(null);
|
|
101
|
+
const colorButtonRef = useRef(null);
|
|
102
|
+
const editorBodyRef = useRef(null);
|
|
103
|
+
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
104
|
+
|
|
105
|
+
const sprite = deriveSprite(text);
|
|
106
|
+
const selection = sprite
|
|
107
|
+
? clampSelection(sprite, rawSelection.layerIndex, rawSelection.frameIndex)
|
|
108
|
+
: rawSelection;
|
|
109
|
+
|
|
110
|
+
const activeTag = sprite ? resolveActiveTag(sprite, sprite.defaultTag) : null;
|
|
111
|
+
const order = sprite ? playbackOrder(sprite, activeTag) : [0];
|
|
112
|
+
const durations = sprite ? frameDurations(sprite, order) : [100];
|
|
113
|
+
const repeat = activeTag ? activeTag.repeat : 0;
|
|
114
|
+
const previewIndex = usePreviewPlayback({ order, durations, repeat }, playing, selection.frameIndex);
|
|
115
|
+
|
|
116
|
+
useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing, {
|
|
117
|
+
selection,
|
|
118
|
+
hoverCell,
|
|
119
|
+
tool,
|
|
120
|
+
picking,
|
|
121
|
+
brushSize,
|
|
122
|
+
eraseSize,
|
|
123
|
+
activeKey,
|
|
124
|
+
});
|
|
125
|
+
useToolShortcuts({ tool, picking, setTool, setPicking, setBrushSize, setEraseSize });
|
|
126
|
+
const { canvasStyle } = useArtboardFit({
|
|
127
|
+
wrapRef: canvasWrapRef,
|
|
128
|
+
sizeBarRef: canvasSizeBarRef,
|
|
129
|
+
resolution: sprite?.resolution ?? { width: 16, height: 16 },
|
|
130
|
+
});
|
|
131
|
+
const isCompactLayout = useEditorCompactLayout(editorBodyRef);
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (!isCompactLayout) setPaletteOpen(false);
|
|
134
|
+
}, [isCompactLayout]);
|
|
135
|
+
|
|
136
|
+
if (!sprite) {
|
|
137
|
+
return (
|
|
138
|
+
<PxArtShell path={path} subtitle="invalid pixel art" shell={headerShell} chrome={chrome} editorBodyRef={editorBodyRef}>
|
|
139
|
+
<div className={styles.drawingToolHint}>This file is not valid pixel art.</div>
|
|
140
|
+
</PxArtShell>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { width, height } = sprite.resolution;
|
|
145
|
+
const cursor = picking ? 'crosshair' : (TOOL_CURSORS[tool] ?? 'default');
|
|
146
|
+
|
|
147
|
+
function togglePalette(next) {
|
|
148
|
+
setPaletteOpen((previous) => (typeof next === 'boolean' ? next : !previous));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Push a draft Sprite to the file (live, mid-stroke). Records ONE undo
|
|
152
|
+
// snapshot for the whole gesture and dedups by serialized text.
|
|
153
|
+
function liveSprite(nextSprite) {
|
|
154
|
+
const stroke = strokeRef.current;
|
|
155
|
+
if (!stroke) return;
|
|
156
|
+
const nextText = serializeModel(nextSprite);
|
|
157
|
+
if (nextText === stroke.draftText) return;
|
|
158
|
+
if (!stroke.recorded) {
|
|
159
|
+
history.recordSnapshot();
|
|
160
|
+
stroke.recorded = true;
|
|
161
|
+
}
|
|
162
|
+
stroke.draftText = nextText;
|
|
163
|
+
onChange(nextText);
|
|
164
|
+
}
|
|
165
|
+
// Paint/erase strokes accumulate in a canvas-window matrix; merge it back into
|
|
166
|
+
// the (possibly larger/offset) cel so off-canvas pixels are preserved.
|
|
167
|
+
function liveStroke(nextCells) {
|
|
168
|
+
const stroke = strokeRef.current;
|
|
169
|
+
if (!stroke || nextCells === stroke.draftCells) return;
|
|
170
|
+
stroke.draftCells = nextCells;
|
|
171
|
+
liveSprite(withCellsAt(stroke.base, stroke.layerIndex, stroke.frameIndex, nextCells));
|
|
172
|
+
}
|
|
173
|
+
function commitCells(nextCells) {
|
|
174
|
+
history.commit(serializeModel(withCellsAt(sprite, selection.layerIndex, selection.frameIndex, nextCells)));
|
|
175
|
+
}
|
|
176
|
+
function startTool(event) {
|
|
177
|
+
const point = eventToCell(event, width, height);
|
|
178
|
+
if (!point) return;
|
|
179
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
180
|
+
const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
|
|
181
|
+
// The eyedropper is a transient picker layered over the active tool: when
|
|
182
|
+
// engaged it intercepts the pick, leaves the underlying tool untouched, and
|
|
183
|
+
// exits picker mode so the previously selected tool resumes after one pick.
|
|
184
|
+
if (picking) {
|
|
185
|
+
setActiveKey(sampleKey(cells, point));
|
|
186
|
+
setPicking(false);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (tool === 'fill') {
|
|
190
|
+
const next = fillSwapMode ? swapKey(cells, point, activeKey) : fillRegion(cells, point, activeKey);
|
|
191
|
+
if (next !== cells) commitCells(next);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (tool === 'erase' && eraseFillMode) {
|
|
195
|
+
const next = fillRegion(cells, point, TRANSPARENT);
|
|
196
|
+
if (next !== cells) commitCells(next);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
strokeRef.current = {
|
|
200
|
+
tool,
|
|
201
|
+
startPoint: point,
|
|
202
|
+
lastPoint: point,
|
|
203
|
+
base: sprite,
|
|
204
|
+
layerIndex: selection.layerIndex,
|
|
205
|
+
frameIndex: selection.frameIndex,
|
|
206
|
+
// Offset of the cel at gesture start, so move applies an ABSOLUTE offset
|
|
207
|
+
// (start + total drag) and accumulates losslessly across gestures.
|
|
208
|
+
startOffset: cellOffsetAt(sprite, selection.layerIndex, selection.frameIndex),
|
|
209
|
+
draftCells: cells,
|
|
210
|
+
draftText: null,
|
|
211
|
+
recorded: false,
|
|
212
|
+
};
|
|
213
|
+
updateStroke(point);
|
|
214
|
+
}
|
|
215
|
+
function updateTool(event) {
|
|
216
|
+
const stroke = strokeRef.current;
|
|
217
|
+
if (!stroke || event.buttons !== 1) return;
|
|
218
|
+
const point = eventToCell(event, width, height);
|
|
219
|
+
if (point) updateStroke(point);
|
|
220
|
+
}
|
|
221
|
+
// Track the hovered cell for the non-destructive tool preview overlay. While a
|
|
222
|
+
// stroke is active the preview is hidden so it never fights the live paint.
|
|
223
|
+
function handlePointerMove(event) {
|
|
224
|
+
updateTool(event);
|
|
225
|
+
if (strokeRef.current) {
|
|
226
|
+
setHoverCell(null);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const point = eventToCell(event, width, height);
|
|
230
|
+
setHoverCell((prev) => (samePoint(prev, point) ? prev : point));
|
|
231
|
+
}
|
|
232
|
+
function clearHover() {
|
|
233
|
+
setHoverCell(null);
|
|
234
|
+
}
|
|
235
|
+
function updateStroke(point) {
|
|
236
|
+
const stroke = strokeRef.current;
|
|
237
|
+
if (!stroke) return;
|
|
238
|
+
if (stroke.tool === 'brush') {
|
|
239
|
+
liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, activeKey, brushSize));
|
|
240
|
+
stroke.lastPoint = point;
|
|
241
|
+
} else if (stroke.tool === 'erase') {
|
|
242
|
+
liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, TRANSPARENT, eraseSize));
|
|
243
|
+
stroke.lastPoint = point;
|
|
244
|
+
} else if (stroke.tool === 'move-all') {
|
|
245
|
+
// Translate the cel's OFFSET by the drag delta instead of shifting and
|
|
246
|
+
// clipping pixels — the full cel image is preserved, so pushing art off
|
|
247
|
+
// the canvas edge and back loses nothing (even across multiple gestures).
|
|
248
|
+
const x = stroke.startOffset.x + (point.x - stroke.startPoint.x);
|
|
249
|
+
const y = stroke.startOffset.y + (point.y - stroke.startPoint.y);
|
|
250
|
+
liveSprite(setCellOffset(stroke.base, stroke.layerIndex, stroke.frameIndex, x, y));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function finishTool() {
|
|
254
|
+
strokeRef.current = null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const actions = buildActions({
|
|
258
|
+
sprite,
|
|
259
|
+
selection,
|
|
260
|
+
history,
|
|
261
|
+
setSelection: setRawSelection,
|
|
262
|
+
setPlaying,
|
|
263
|
+
playing,
|
|
264
|
+
setOnion,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<PxArtShell path={path} subtitle={pxArtSubtitle(sprite)} shell={headerShell} chrome={chrome} editorBodyRef={editorBodyRef}>
|
|
269
|
+
<div className={styles.drawingEditor}>
|
|
270
|
+
<div className={tl.editorLeft}>
|
|
271
|
+
<div className={tl.artboardRegion}>
|
|
272
|
+
<div className={styles.artboardToolColumn}>
|
|
273
|
+
<PixelToolStrip
|
|
274
|
+
tools={PIXEL_TOOLS}
|
|
275
|
+
tool={tool}
|
|
276
|
+
onSelectTool={(id) => {
|
|
277
|
+
setPicking(false);
|
|
278
|
+
setTool(id);
|
|
279
|
+
}}
|
|
280
|
+
ariaLabel="Sprite tools"
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
<div ref={canvasWrapRef} className={styles.drawingCanvasWrap}>
|
|
284
|
+
<div className={styles.artboardStack}>
|
|
285
|
+
<div ref={canvasSizeBarRef}>
|
|
286
|
+
<CanvasSizeBar
|
|
287
|
+
width={width}
|
|
288
|
+
height={height}
|
|
289
|
+
onResize={(w, h) => history.commit(serializeModel(resizeSprite(sprite, w, h)))}
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
<PixelArtboard
|
|
293
|
+
canvasRef={canvasRef}
|
|
294
|
+
style={{ ...canvasStyle, cursor }}
|
|
295
|
+
handlers={{
|
|
296
|
+
onPointerDown: startTool,
|
|
297
|
+
onPointerMove: handlePointerMove,
|
|
298
|
+
onPointerUp: finishTool,
|
|
299
|
+
onPointerCancel: finishTool,
|
|
300
|
+
onPointerLeave: clearHover,
|
|
301
|
+
}}
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
<div className={styles.paintColumn}>
|
|
306
|
+
<PaintStrip
|
|
307
|
+
toolState={toolState}
|
|
308
|
+
activeKey={activeKey}
|
|
309
|
+
activeColor={EDITOR_PALETTE[activeKey]}
|
|
310
|
+
paletteOpen={paletteOpen}
|
|
311
|
+
onTogglePalette={togglePalette}
|
|
312
|
+
colorButtonRef={colorButtonRef}
|
|
313
|
+
onSelectKey={setActiveKey}
|
|
314
|
+
paletteKeys={EDITOR_KEYS}
|
|
315
|
+
palette={EDITOR_PALETTE}
|
|
316
|
+
/>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
<PxArtTimeline
|
|
320
|
+
sprite={sprite}
|
|
321
|
+
selection={selection}
|
|
322
|
+
playing={playing}
|
|
323
|
+
onion={onion}
|
|
324
|
+
actions={actions}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
<PxArtInspectorDock
|
|
328
|
+
toolState={toolState}
|
|
329
|
+
activeKey={activeKey}
|
|
330
|
+
onSelectKey={setActiveKey}
|
|
331
|
+
paletteKeys={EDITOR_KEYS}
|
|
332
|
+
palette={EDITOR_PALETTE}
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
</PxArtShell>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Build the timeline's mutation contract. Each action composes an immutable
|
|
340
|
+
// model op with a history commit, updating the (layer, frame) selection where a
|
|
341
|
+
// structural change moves it.
|
|
342
|
+
function buildActions({ sprite, selection, history, setSelection, setPlaying, playing, setOnion }) {
|
|
343
|
+
const { layerIndex, frameIndex } = selection;
|
|
344
|
+
const commit = (next) => history.commit(serializeModel(next));
|
|
345
|
+
return {
|
|
346
|
+
selectCell: (li, fi) => setSelection({ layerIndex: li, frameIndex: fi }),
|
|
347
|
+
addLayer: (li) => {
|
|
348
|
+
commit(addLayer(sprite, li));
|
|
349
|
+
setSelection({ layerIndex: li + 1, frameIndex });
|
|
350
|
+
},
|
|
351
|
+
deleteLayer: (li) => {
|
|
352
|
+
commit(deleteLayer(sprite, li));
|
|
353
|
+
setSelection({ layerIndex: Math.max(li - 1, 0), frameIndex });
|
|
354
|
+
},
|
|
355
|
+
duplicateLayer: (li) => {
|
|
356
|
+
commit(duplicateLayer(sprite, li));
|
|
357
|
+
setSelection({ layerIndex: li + 1, frameIndex });
|
|
358
|
+
},
|
|
359
|
+
moveLayer: (li, dir) => {
|
|
360
|
+
commit(moveLayer(sprite, li, dir));
|
|
361
|
+
setSelection({ layerIndex: li + dir, frameIndex });
|
|
362
|
+
},
|
|
363
|
+
patchLayer: (li, patch) => commit(patchLayer(sprite, li, patch)),
|
|
364
|
+
addFrame: (fi) => {
|
|
365
|
+
commit(addFrame(sprite, fi));
|
|
366
|
+
setSelection({ layerIndex, frameIndex: fi + 1 });
|
|
367
|
+
},
|
|
368
|
+
deleteFrame: (fi) => {
|
|
369
|
+
commit(deleteFrame(sprite, fi));
|
|
370
|
+
setSelection({ layerIndex, frameIndex: Math.max(fi - 1, 0) });
|
|
371
|
+
},
|
|
372
|
+
duplicateFrame: (fi) => {
|
|
373
|
+
commit(duplicateFrame(sprite, fi));
|
|
374
|
+
setSelection({ layerIndex, frameIndex: fi + 1 });
|
|
375
|
+
},
|
|
376
|
+
moveFrame: (fi, dir) => {
|
|
377
|
+
commit(moveFrame(sprite, fi, dir));
|
|
378
|
+
setSelection({ layerIndex, frameIndex: fi + dir });
|
|
379
|
+
},
|
|
380
|
+
setFrameDuration: (fi, ms) => commit(setFrameDuration(sprite, fi, ms)),
|
|
381
|
+
setDefaultDuration: (ms) => commit(setDefaultDuration(sprite, ms)),
|
|
382
|
+
linkCell: (src) => commit(linkCell(sprite, layerIndex, frameIndex, src)),
|
|
383
|
+
unlinkCell: () => commit(unlinkCell(sprite, layerIndex, frameIndex)),
|
|
384
|
+
addTag: () => commit(addTag(sprite)),
|
|
385
|
+
patchTag: (i, patch) => commit(patchTag(sprite, i, patch)),
|
|
386
|
+
deleteTag: (i) => commit(deleteTag(sprite, i)),
|
|
387
|
+
setDefaultTag: (name) => commit(setDefaultTag(sprite, name)),
|
|
388
|
+
togglePlay: () => setPlaying(!playing),
|
|
389
|
+
setOnionEnabled: (enabled) => setOnion((prev) => ({ ...prev, enabled })),
|
|
390
|
+
setOnionRange: (range) => setOnion((prev) => ({ ...prev, range: Math.max(1, range || 1) })),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function PxArtShell({ path, subtitle, shell, chrome, editorBodyRef, children }) {
|
|
395
|
+
return (
|
|
396
|
+
<>
|
|
397
|
+
<PixelEditorHeader title={basename(path)} subtitle={subtitle} shell={shell} chrome={chrome} />
|
|
398
|
+
<EditorBody ref={editorBodyRef}>{children}</EditorBody>
|
|
399
|
+
</>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function pxArtSubtitle(sprite) {
|
|
404
|
+
const { width, height } = sprite.resolution;
|
|
405
|
+
const layers = sprite.layers.length;
|
|
406
|
+
const frames = frameCount(sprite);
|
|
407
|
+
return `${width} x ${height} · ${layers} layer${layers === 1 ? '' : 's'} · ${frames} frame${frames === 1 ? '' : 's'}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// artboard rendering: composite active frame + onion skins
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
// Redraw the artboard whenever the sprite text or any preview input changes,
|
|
415
|
+
// layering the render-only tool ghost on top of the composited frame. Gated on
|
|
416
|
+
// `text` (deriveSprite returns a fresh object each render).
|
|
417
|
+
function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing, p) {
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
const canvas = canvasRef.current;
|
|
420
|
+
if (!canvas || !sprite) return;
|
|
421
|
+
renderArtboard(canvas, sprite, previewIndex, onion, playing, buildToolPreview({ sprite, playing, ...p }));
|
|
422
|
+
}, [
|
|
423
|
+
text,
|
|
424
|
+
previewIndex,
|
|
425
|
+
playing,
|
|
426
|
+
onion.enabled,
|
|
427
|
+
onion.range,
|
|
428
|
+
p.hoverCell,
|
|
429
|
+
p.tool,
|
|
430
|
+
p.picking,
|
|
431
|
+
p.brushSize,
|
|
432
|
+
p.eraseSize,
|
|
433
|
+
p.activeKey,
|
|
434
|
+
p.selection.layerIndex,
|
|
435
|
+
p.selection.frameIndex,
|
|
436
|
+
]);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Window-scoped tool keyboard shortcuts, mounted only while the editor is.
|
|
440
|
+
function useToolShortcuts(ctx) {
|
|
441
|
+
const { tool, picking, setTool, setPicking, setBrushSize, setEraseSize } = ctx;
|
|
442
|
+
useEffect(() => {
|
|
443
|
+
function onKeyDown(event) {
|
|
444
|
+
if (handleToolShortcut(event, ctx)) event.preventDefault();
|
|
445
|
+
}
|
|
446
|
+
window.addEventListener('keydown', onKeyDown);
|
|
447
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
448
|
+
}, [tool, picking, setTool, setPicking, setBrushSize, setEraseSize]);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
|
|
452
|
+
const ctx = setupSpriteCanvas(canvas, sprite.resolution);
|
|
453
|
+
if (!ctx) return;
|
|
454
|
+
if (onion.enabled && !playing) {
|
|
455
|
+
for (let d = onion.range; d >= 1; d--) {
|
|
456
|
+
blitOnion(ctx, sprite, frameIndex - d, ONION_WARM, d);
|
|
457
|
+
blitOnion(ctx, sprite, frameIndex + d, ONION_COOL, d);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
ctx.globalAlpha = 1;
|
|
461
|
+
ctx.drawImage(frameCanvas(sprite, frameIndex), 0, 0);
|
|
462
|
+
if (preview) drawToolPreview(ctx, canvas, preview);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function blitOnion(ctx, sprite, idx, color, dist) {
|
|
466
|
+
if (idx < 0 || idx >= frameCount(sprite)) return;
|
|
467
|
+
ctx.globalAlpha = Math.max(0.12, 0.5 / dist);
|
|
468
|
+
ctx.drawImage(onionTint(frameCanvas(sprite, idx), color), 0, 0);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function frameCanvas(sprite, idx) {
|
|
472
|
+
const canvas = document.createElement('canvas');
|
|
473
|
+
renderSpriteFrame(sprite, idx, canvas);
|
|
474
|
+
return canvas;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Colorize an already-rendered frame toward `color` over its own silhouette
|
|
478
|
+
// (source-atop), so onion ghosts read as warm (past) / cool (future) washes.
|
|
479
|
+
function onionTint(src, color) {
|
|
480
|
+
const canvas = document.createElement('canvas');
|
|
481
|
+
canvas.width = src.width;
|
|
482
|
+
canvas.height = src.height;
|
|
483
|
+
const ctx = canvas.getContext('2d');
|
|
484
|
+
if (!ctx) return src;
|
|
485
|
+
ctx.imageSmoothingEnabled = false;
|
|
486
|
+
ctx.drawImage(src, 0, 0);
|
|
487
|
+
ctx.globalCompositeOperation = 'source-atop';
|
|
488
|
+
ctx.globalAlpha = 0.55;
|
|
489
|
+
ctx.fillStyle = color;
|
|
490
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
491
|
+
return canvas;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// keyboard shortcuts: tool selection + active size step
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
// Single-letter tool picks. The eyedropper ('i') is handled separately because
|
|
499
|
+
// it toggles the transient picker rather than swapping the sidebar tool.
|
|
500
|
+
const SHORTCUT_TOOLS = { b: 'brush', g: 'fill', e: 'erase', v: 'move-all' };
|
|
501
|
+
|
|
502
|
+
// True when focus is on a text-entry control, so typing there isn't hijacked.
|
|
503
|
+
function isTypingTarget(node) {
|
|
504
|
+
if (!node || !node.tagName) return false;
|
|
505
|
+
const tag = node.tagName.toLowerCase();
|
|
506
|
+
return tag === 'input' || tag === 'textarea' || tag === 'select' || node.isContentEditable;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Step the active size through the SAME discrete BRUSH_SIZES the slider uses,
|
|
510
|
+
// clamped to its first/last entry (the size UI's min/max).
|
|
511
|
+
function stepBrushSize(current, delta) {
|
|
512
|
+
const index = Math.max(0, BRUSH_SIZES.indexOf(current));
|
|
513
|
+
const next = Math.min(BRUSH_SIZES.length - 1, Math.max(0, index + delta));
|
|
514
|
+
return BRUSH_SIZES[next];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Resize the active tool with [ / ]. Only brush/erase have a size; other tools
|
|
518
|
+
// make this a no-op (returns false so the caller leaves the key alone).
|
|
519
|
+
function adjustToolSize(delta, ctx) {
|
|
520
|
+
if (ctx.tool === 'brush') {
|
|
521
|
+
ctx.setBrushSize((prev) => stepBrushSize(prev, delta));
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
if (ctx.tool === 'erase') {
|
|
525
|
+
ctx.setEraseSize((prev) => stepBrushSize(prev, delta));
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Map a keydown to a tool action; returns true when it consumed the key. Bails
|
|
532
|
+
// on modifier combos (undo/redo, alt behaviors) and while typing in a field.
|
|
533
|
+
function handleToolShortcut(event, ctx) {
|
|
534
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
|
535
|
+
if (isTypingTarget(event.target || document.activeElement)) return false;
|
|
536
|
+
const key = event.key.toLowerCase();
|
|
537
|
+
const toolId = SHORTCUT_TOOLS[key];
|
|
538
|
+
if (toolId) {
|
|
539
|
+
ctx.setPicking(false);
|
|
540
|
+
ctx.setTool(toolId);
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
if (key === 'i') {
|
|
544
|
+
ctx.setPicking(!ctx.picking);
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
if (event.key === '[' || event.key === ']') {
|
|
548
|
+
return adjustToolSize(event.key === '[' ? -1 : 1, ctx);
|
|
549
|
+
}
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// tool hover preview: a render-only ghost of where the current tool would act
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
const PREVIEW_PAINT_ALPHA = 0.5;
|
|
558
|
+
const PREVIEW_WASH_ALPHA = 0.35;
|
|
559
|
+
const PREVIEW_HIGHLIGHT_ALPHA = 0.45;
|
|
560
|
+
// A reserved sentinel key (not a palette key) used to flood-mark the fill region
|
|
561
|
+
// for the region-accurate fill preview, via the editor's own fillRegion.
|
|
562
|
+
const FILL_PREVIEW_SENTINEL = '\u0000';
|
|
563
|
+
|
|
564
|
+
function samePoint(a, b) {
|
|
565
|
+
if (a === b) return true;
|
|
566
|
+
if (!a || !b) return false;
|
|
567
|
+
return a.x === b.x && a.y === b.y;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// The exact set of cells a fill at `start` would recolor, computed by reusing
|
|
571
|
+
// fillRegion with a sentinel key and collecting the marked cells.
|
|
572
|
+
function fillPreviewCells(cells, start) {
|
|
573
|
+
const filled = fillRegion(cells, start, FILL_PREVIEW_SENTINEL);
|
|
574
|
+
if (filled === cells) return [];
|
|
575
|
+
const out = [];
|
|
576
|
+
for (let y = 0; y < filled.length; y++) {
|
|
577
|
+
const row = filled[y];
|
|
578
|
+
for (let x = 0; x < row.length; x++) {
|
|
579
|
+
if (row[x] === FILL_PREVIEW_SENTINEL) out.push({ x, y });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return out;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Describe the overlay to draw for the hovered cell, or null when nothing should
|
|
586
|
+
// show (no hover, playback, or the move-all tool which has no footprint).
|
|
587
|
+
function buildToolPreview({ sprite, selection, hoverCell, tool, picking, brushSize, eraseSize, activeKey, playing }) {
|
|
588
|
+
if (!hoverCell || playing) return null;
|
|
589
|
+
const color = EDITOR_PALETTE[activeKey] || '#ffffff';
|
|
590
|
+
if (picking) return { kind: 'cell', cell: hoverCell };
|
|
591
|
+
if (tool === 'brush') return { kind: 'footprint', cell: hoverCell, size: brushSize, color };
|
|
592
|
+
if (tool === 'erase') return { kind: 'erase', cell: hoverCell, size: eraseSize };
|
|
593
|
+
if (tool === 'fill') {
|
|
594
|
+
const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
|
|
595
|
+
return { kind: 'region', cells: fillPreviewCells(cells, hoverCell), color };
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// One native canvas unit corresponds to (native / displayed) pixels; sizing
|
|
601
|
+
// outlines to that keeps them ~1 screen pixel regardless of the artboard zoom.
|
|
602
|
+
function previewLineWidth(canvas) {
|
|
603
|
+
return canvas.clientWidth ? canvas.width / canvas.clientWidth : 0.15;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Translucent fill of a brush/erase dab footprint, matching how paintLine lays
|
|
607
|
+
// down `size` cells around the center so the ghost lines up with real paint.
|
|
608
|
+
function fillDabCells(ctx, cell, size, color, alpha) {
|
|
609
|
+
ctx.globalAlpha = alpha;
|
|
610
|
+
ctx.fillStyle = color;
|
|
611
|
+
forEachDab(size, (dx, dy) => ctx.fillRect(cell.x + dx, cell.y + dy, 1, 1));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// A thin "cut" outline around a dab footprint's bounds, so erase reads as a
|
|
615
|
+
// removal region rather than a paint stroke.
|
|
616
|
+
function strokeDabBounds(ctx, cell, size, lineWidth) {
|
|
617
|
+
let minX = Infinity;
|
|
618
|
+
let minY = Infinity;
|
|
619
|
+
let maxX = -Infinity;
|
|
620
|
+
let maxY = -Infinity;
|
|
621
|
+
forEachDab(size, (dx, dy) => {
|
|
622
|
+
minX = Math.min(minX, dx);
|
|
623
|
+
minY = Math.min(minY, dy);
|
|
624
|
+
maxX = Math.max(maxX, dx);
|
|
625
|
+
maxY = Math.max(maxY, dy);
|
|
626
|
+
});
|
|
627
|
+
if (minX === Infinity) return;
|
|
628
|
+
ctx.globalAlpha = 0.85;
|
|
629
|
+
ctx.strokeStyle = '#1c1c1c';
|
|
630
|
+
ctx.lineWidth = lineWidth;
|
|
631
|
+
const half = lineWidth / 2;
|
|
632
|
+
ctx.strokeRect(cell.x + minX + half, cell.y + minY + half, maxX - minX + 1 - lineWidth, maxY - minY + 1 - lineWidth);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function fillCellList(ctx, cells, color, alpha) {
|
|
636
|
+
ctx.globalAlpha = alpha;
|
|
637
|
+
ctx.fillStyle = color;
|
|
638
|
+
for (const cell of cells) ctx.fillRect(cell.x, cell.y, 1, 1);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Render-only: paint the ghost overlay for the current tool onto the artboard
|
|
642
|
+
// after the sprite. Never mutates the sprite, history, or serialized output.
|
|
643
|
+
function drawToolPreview(ctx, canvas, preview) {
|
|
644
|
+
ctx.save();
|
|
645
|
+
const lineWidth = previewLineWidth(canvas);
|
|
646
|
+
if (preview.kind === 'footprint') {
|
|
647
|
+
fillDabCells(ctx, preview.cell, preview.size, preview.color, PREVIEW_PAINT_ALPHA);
|
|
648
|
+
} else if (preview.kind === 'erase') {
|
|
649
|
+
fillDabCells(ctx, preview.cell, preview.size, '#ffffff', PREVIEW_WASH_ALPHA);
|
|
650
|
+
strokeDabBounds(ctx, preview.cell, preview.size, lineWidth);
|
|
651
|
+
} else if (preview.kind === 'region') {
|
|
652
|
+
fillCellList(ctx, preview.cells, preview.color, PREVIEW_HIGHLIGHT_ALPHA);
|
|
653
|
+
} else if (preview.kind === 'cell') {
|
|
654
|
+
fillCellList(ctx, [preview.cell], '#ffffff', PREVIEW_HIGHLIGHT_ALPHA);
|
|
655
|
+
ctx.globalAlpha = 0.9;
|
|
656
|
+
ctx.strokeStyle = '#1c1c1c';
|
|
657
|
+
ctx.lineWidth = lineWidth;
|
|
658
|
+
const half = lineWidth / 2;
|
|
659
|
+
ctx.strokeRect(preview.cell.x + half, preview.cell.y + half, 1 - lineWidth, 1 - lineWidth);
|
|
660
|
+
}
|
|
661
|
+
ctx.restore();
|
|
662
|
+
}
|