castle-web-cli 0.4.74 → 0.4.76
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.d.ts +4 -0
- package/dist/agent-prompts.js +20 -8
- package/dist/agent.js +301 -28
- package/dist/castle-host/host.js +59 -5
- package/dist/commonInstructions.d.ts +1 -1
- package/dist/commonInstructions.js +5 -1
- package/dist/ide.d.ts +1 -0
- package/dist/ide.js +250 -1
- package/dist/init.js +46 -4
- package/dist/save-deck.js +8 -1
- package/dist/serve.js +27 -1
- package/dist/shell/assets/index-DNWEQd4R.js +141 -0
- package/dist/shell/assets/{index-WNbOHPBj.css → index-DuKq-Grp.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +12 -4
- package/kits/basic-2d/castle.json +15 -0
- package/kits/basic-2d/drawings/pig.pxart +22 -55
- package/kits/basic-2d/editors/PxArtEditor.jsx +237 -4
- package/kits/basic-2d/editors/SceneEditor.jsx +0 -3
- package/kits/basic-2d/editors/SingleEditor.jsx +6 -51
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +17 -11
- package/kits/basic-2d/editors/pixelGeometry.js +95 -0
- package/kits/basic-2d/editors/pixelInspector.jsx +229 -51
- package/kits/basic-2d/editors/pxArtTools.js +109 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +11 -163
- package/kits/basic-2d/engine/ui.jsx +15 -0
- package/kits/basic-2d/engine/ui.module.css +44 -117
- package/kits/basic-2d/main.jsx +6 -10
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/package.json +10 -1
- package/dist/shell/assets/index-Dfn29Bkt.js +0 -108
- package/kits/basic-2d/editors/App.jsx +0 -226
- package/kits/basic-2d/editors/CodeEditor.jsx +0 -79
- package/kits/basic-2d/editors/FileBrowser.jsx +0 -349
- package/kits/basic-2d/editors/codeTheme.js +0 -135
- package/kits/basic-2d/engine/TouchControls.jsx +0 -136
- package/kits/basic-2d/engine/playConsole.js +0 -66
- package/kits/basic-3d/.prettierrc +0 -8
- package/kits/basic-3d/CLAUDE.md +0 -162
- package/kits/basic-3d/behaviors/Camera.jsx +0 -56
- package/kits/basic-3d/behaviors/Collider.jsx +0 -78
- package/kits/basic-3d/behaviors/Mesh.jsx +0 -82
- package/kits/basic-3d/behaviors/Model.jsx +0 -61
- package/kits/basic-3d/behaviors/Transform.jsx +0 -35
- package/kits/basic-3d/editors/App.jsx +0 -147
- package/kits/basic-3d/editors/CodeEditor.jsx +0 -112
- package/kits/basic-3d/editors/FileBrowser.jsx +0 -143
- package/kits/basic-3d/editors/ModelEditor.jsx +0 -400
- package/kits/basic-3d/editors/PlayOnly.jsx +0 -22
- package/kits/basic-3d/editors/SceneEditor.jsx +0 -1087
- package/kits/basic-3d/editors/behaviorRegistry.js +0 -24
- package/kits/basic-3d/editors/editorHistory.js +0 -52
- package/kits/basic-3d/editors/viewportRig.js +0 -90
- package/kits/basic-3d/engine/ScenePlayer.jsx +0 -61
- package/kits/basic-3d/engine/SceneUI.jsx +0 -67
- package/kits/basic-3d/engine/SceneViewport.jsx +0 -102
- package/kits/basic-3d/engine/TouchControls.jsx +0 -136
- package/kits/basic-3d/engine/autoInspector.jsx +0 -51
- package/kits/basic-3d/engine/files.js +0 -73
- package/kits/basic-3d/engine/scene.js +0 -502
- package/kits/basic-3d/engine/threeUtil.js +0 -260
- package/kits/basic-3d/engine/ui.jsx +0 -352
- package/kits/basic-3d/engine/ui.module.css +0 -944
- package/kits/basic-3d/eslint.config.js +0 -51
- package/kits/basic-3d/index.html +0 -11
- package/kits/basic-3d/main.jsx +0 -10
- package/kits/basic-3d/models/block.model +0 -14
- package/kits/basic-3d/package-lock.json +0 -2713
- package/kits/basic-3d/package.json +0 -41
- package/kits/basic-3d/pnpm-lock.yaml +0 -1769
- package/kits/basic-3d/scenes/main.scene +0 -76
- package/kits/basic-3d/vite.config.js +0 -1
|
@@ -16,10 +16,17 @@ import {
|
|
|
16
16
|
} from './pixelInspector';
|
|
17
17
|
import { useArtboardFit, useEditorCompactLayout } from './useArtboardFit';
|
|
18
18
|
import {
|
|
19
|
+
DEFAULT_KEY,
|
|
19
20
|
EDITOR_KEYS,
|
|
20
21
|
EDITOR_PALETTE,
|
|
22
|
+
clearRegion,
|
|
21
23
|
fillRegion,
|
|
24
|
+
flipCellsH,
|
|
25
|
+
flipCellsV,
|
|
26
|
+
moveRegion,
|
|
22
27
|
paintLine,
|
|
28
|
+
paintShape,
|
|
29
|
+
rotateCellsCW,
|
|
23
30
|
sampleKey,
|
|
24
31
|
swapKey,
|
|
25
32
|
} from './pxArtTools';
|
|
@@ -71,17 +78,29 @@ const TOOL_CURSORS = {
|
|
|
71
78
|
brush: 'cell',
|
|
72
79
|
erase: 'cell',
|
|
73
80
|
fill: 'crosshair',
|
|
81
|
+
shape: 'crosshair',
|
|
82
|
+
select: 'crosshair',
|
|
74
83
|
'move-all': 'move',
|
|
75
84
|
};
|
|
76
85
|
|
|
77
86
|
export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
78
87
|
const canvasRef = useRef(null);
|
|
88
|
+
const overlayRef = useRef(null);
|
|
79
89
|
const strokeRef = useRef(null);
|
|
80
|
-
|
|
90
|
+
// The serialized text this editor last wrote itself. Lets us tell our own
|
|
91
|
+
// edits apart from external rewrites (undo/redo) so the marquee can be dropped
|
|
92
|
+
// when the art changes out from under it.
|
|
93
|
+
const lastSelfTextRef = useRef(text);
|
|
94
|
+
const [activeKey, setActiveKey] = useState(DEFAULT_KEY);
|
|
81
95
|
const [rawSelection, setRawSelection] = useState({ layerIndex: 0, frameIndex: 0 });
|
|
82
96
|
const [playing, setPlaying] = useState(false);
|
|
83
97
|
const [onion, setOnion] = useState({ enabled: false, range: 1 });
|
|
84
98
|
const [hoverCell, setHoverCell] = useState(null);
|
|
99
|
+
// The marquee selection rectangle { x, y, w, h } in canvas cells, or null. It
|
|
100
|
+
// is a MODE of the select tool: cleared when leaving the tool or changing the
|
|
101
|
+
// active cel. `antsOffset` ticks the marching-ants animation while it's shown.
|
|
102
|
+
const [marquee, setMarquee] = useState(null);
|
|
103
|
+
const [antsOffset, setAntsOffset] = useState(0);
|
|
85
104
|
const toolState = usePixelToolState();
|
|
86
105
|
const {
|
|
87
106
|
tool,
|
|
@@ -90,6 +109,8 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
90
109
|
setPicking,
|
|
91
110
|
eraseFillMode,
|
|
92
111
|
fillSwapMode,
|
|
112
|
+
shapeMode,
|
|
113
|
+
shapeFill,
|
|
93
114
|
brushSize,
|
|
94
115
|
setBrushSize,
|
|
95
116
|
eraseSize,
|
|
@@ -132,6 +153,54 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
132
153
|
useEffect(() => {
|
|
133
154
|
if (!isCompactLayout) setPaletteOpen(false);
|
|
134
155
|
}, [isCompactLayout]);
|
|
156
|
+
// The marquee is a mode of the select tool: drop it when switching to another
|
|
157
|
+
// tool or when the active cel (layer/frame) changes, so it never lingers over
|
|
158
|
+
// pixels it no longer refers to.
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (tool !== 'select') setMarquee(null);
|
|
161
|
+
}, [tool]);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
setMarquee(null);
|
|
164
|
+
}, [selection.layerIndex, selection.frameIndex]);
|
|
165
|
+
// Undo/redo (or any external rewrite) restores the art text but not the
|
|
166
|
+
// separate marquee state, which would strand the selection box over the wrong
|
|
167
|
+
// pixels. A text value this editor didn't produce means an external change, so
|
|
168
|
+
// drop the selection to keep it from ever desyncing from the art.
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (text === lastSelfTextRef.current) return;
|
|
171
|
+
lastSelfTextRef.current = text;
|
|
172
|
+
setMarquee(null);
|
|
173
|
+
}, [text]);
|
|
174
|
+
// March the ants only while a selection is visible.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (tool !== 'select' || !marquee) return undefined;
|
|
177
|
+
const id = setInterval(() => setAntsOffset((o) => (o + 1) % 1000), 80);
|
|
178
|
+
return () => clearInterval(id);
|
|
179
|
+
}, [tool, marquee]);
|
|
180
|
+
// Draw the marching-ants selection on a display-resolution overlay canvas so
|
|
181
|
+
// the outline stays crisp (drawing it on the upscaled native artboard renders
|
|
182
|
+
// the thin dashes sub-pixel and washed out).
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
renderMarqueeOverlay(overlayRef.current, sprite?.resolution, playing ? null : marquee, antsOffset);
|
|
185
|
+
}, [marquee, antsOffset, playing, canvasStyle, sprite?.resolution.width, sprite?.resolution.height]);
|
|
186
|
+
// Escape deselects; Delete/Backspace clears the selected pixels. Both only
|
|
187
|
+
// while the select tool is active and a marquee exists.
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (tool !== 'select' || !marquee) return undefined;
|
|
190
|
+
function onKeyDown(event) {
|
|
191
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
192
|
+
if (isTypingTarget(event.target || document.activeElement)) return;
|
|
193
|
+
if (event.key === 'Escape') {
|
|
194
|
+
setMarquee(null);
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
} else if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
197
|
+
deleteMarqueePixels();
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
window.addEventListener('keydown', onKeyDown);
|
|
202
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
203
|
+
}, [tool, marquee, text, selection.layerIndex, selection.frameIndex]);
|
|
135
204
|
|
|
136
205
|
if (!sprite) {
|
|
137
206
|
return (
|
|
@@ -142,7 +211,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
142
211
|
}
|
|
143
212
|
|
|
144
213
|
const { width, height } = sprite.resolution;
|
|
145
|
-
const
|
|
214
|
+
const overSelection = tool === 'select' && marquee && hoverCell && pointInRect(hoverCell, marquee);
|
|
215
|
+
const cursor = picking
|
|
216
|
+
? 'crosshair'
|
|
217
|
+
: overSelection
|
|
218
|
+
? 'move'
|
|
219
|
+
: (TOOL_CURSORS[tool] ?? 'default');
|
|
146
220
|
|
|
147
221
|
function togglePalette(next) {
|
|
148
222
|
setPaletteOpen((previous) => (typeof next === 'boolean' ? next : !previous));
|
|
@@ -160,6 +234,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
160
234
|
stroke.recorded = true;
|
|
161
235
|
}
|
|
162
236
|
stroke.draftText = nextText;
|
|
237
|
+
lastSelfTextRef.current = nextText;
|
|
163
238
|
onChange(nextText);
|
|
164
239
|
}
|
|
165
240
|
// Paint/erase strokes accumulate in a canvas-window matrix; merge it back into
|
|
@@ -171,8 +246,31 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
171
246
|
liveSprite(withCellsAt(stroke.base, stroke.layerIndex, stroke.frameIndex, nextCells));
|
|
172
247
|
}
|
|
173
248
|
function commitCells(nextCells) {
|
|
174
|
-
|
|
249
|
+
const nextText = serializeModel(withCellsAt(sprite, selection.layerIndex, selection.frameIndex, nextCells));
|
|
250
|
+
lastSelfTextRef.current = nextText;
|
|
251
|
+
history.commit(nextText);
|
|
175
252
|
}
|
|
253
|
+
// Transform the active cel's canvas-window pixels in one history step (the
|
|
254
|
+
// move tool's flip / rotate actions). A no-op transform commits nothing.
|
|
255
|
+
function transformActiveCel(transform) {
|
|
256
|
+
const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
|
|
257
|
+
const next = transform(cells);
|
|
258
|
+
commitCells(next);
|
|
259
|
+
}
|
|
260
|
+
const moveActions = {
|
|
261
|
+
flipH: () => transformActiveCel(flipCellsH),
|
|
262
|
+
flipV: () => transformActiveCel(flipCellsV),
|
|
263
|
+
rotateCW: () => transformActiveCel(rotateCellsCW),
|
|
264
|
+
};
|
|
265
|
+
// Clear the marquee region's pixels to transparent (keeps the selection rect).
|
|
266
|
+
function deleteMarqueePixels() {
|
|
267
|
+
if (!marquee) return;
|
|
268
|
+
transformActiveCel((cells) => clearRegion(cells, marquee));
|
|
269
|
+
}
|
|
270
|
+
const selectActions = {
|
|
271
|
+
hasSelection: !!marquee,
|
|
272
|
+
deletePixels: deleteMarqueePixels,
|
|
273
|
+
};
|
|
176
274
|
function startTool(event) {
|
|
177
275
|
const point = eventToCell(event, width, height);
|
|
178
276
|
if (!point) return;
|
|
@@ -186,6 +284,28 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
186
284
|
setPicking(false);
|
|
187
285
|
return;
|
|
188
286
|
}
|
|
287
|
+
if (tool === 'select') {
|
|
288
|
+
// Pointer down inside the current selection starts a move (lift & float);
|
|
289
|
+
// anywhere else begins a fresh marquee rectangle.
|
|
290
|
+
const moving = marquee && pointInRect(point, marquee);
|
|
291
|
+
strokeRef.current = {
|
|
292
|
+
tool: 'select',
|
|
293
|
+
mode: moving ? 'move' : 'new',
|
|
294
|
+
startPoint: point,
|
|
295
|
+
lastPoint: point,
|
|
296
|
+
base: sprite,
|
|
297
|
+
layerIndex: selection.layerIndex,
|
|
298
|
+
frameIndex: selection.frameIndex,
|
|
299
|
+
originCells: cells,
|
|
300
|
+
originRect: moving ? marquee : null,
|
|
301
|
+
draftCells: cells,
|
|
302
|
+
draftText: null,
|
|
303
|
+
recorded: false,
|
|
304
|
+
};
|
|
305
|
+
if (!moving) setMarquee({ x: point.x, y: point.y, w: 1, h: 1 });
|
|
306
|
+
updateStroke(point);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
189
309
|
if (tool === 'fill') {
|
|
190
310
|
const next = fillSwapMode ? swapKey(cells, point, activeKey) : fillRegion(cells, point, activeKey);
|
|
191
311
|
if (next !== cells) commitCells(next);
|
|
@@ -207,6 +327,9 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
207
327
|
// (start + total drag) and accumulates losslessly across gestures.
|
|
208
328
|
startOffset: cellOffsetAt(sprite, selection.layerIndex, selection.frameIndex),
|
|
209
329
|
draftCells: cells,
|
|
330
|
+
// The cells at gesture start, kept immutable so the shape tools recompute
|
|
331
|
+
// from a clean slate on each move instead of accumulating partial shapes.
|
|
332
|
+
originCells: cells,
|
|
210
333
|
draftText: null,
|
|
211
334
|
recorded: false,
|
|
212
335
|
};
|
|
@@ -235,12 +358,28 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
235
358
|
function updateStroke(point) {
|
|
236
359
|
const stroke = strokeRef.current;
|
|
237
360
|
if (!stroke) return;
|
|
361
|
+
if (stroke.tool === 'select') {
|
|
362
|
+
if (stroke.mode === 'new') {
|
|
363
|
+
setMarquee(rectFromPoints(stroke.startPoint, point, width, height));
|
|
364
|
+
} else {
|
|
365
|
+
const dx = point.x - stroke.startPoint.x;
|
|
366
|
+
const dy = point.y - stroke.startPoint.y;
|
|
367
|
+
liveStroke(moveRegion(stroke.originCells, stroke.originRect, dx, dy));
|
|
368
|
+
setMarquee({ ...stroke.originRect, x: stroke.originRect.x + dx, y: stroke.originRect.y + dy });
|
|
369
|
+
}
|
|
370
|
+
stroke.lastPoint = point;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
238
373
|
if (stroke.tool === 'brush') {
|
|
239
374
|
liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, activeKey, brushSize));
|
|
240
375
|
stroke.lastPoint = point;
|
|
241
376
|
} else if (stroke.tool === 'erase') {
|
|
242
377
|
liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, TRANSPARENT, eraseSize));
|
|
243
378
|
stroke.lastPoint = point;
|
|
379
|
+
} else if (stroke.tool === 'shape') {
|
|
380
|
+
// Recompute from the gesture-start snapshot so the shape rubber-bands to
|
|
381
|
+
// the current point rather than stacking each intermediate draft.
|
|
382
|
+
liveStroke(paintShape(stroke.originCells, stroke.startPoint, point, activeKey, shapeMode, shapeFill));
|
|
244
383
|
} else if (stroke.tool === 'move-all') {
|
|
245
384
|
// Translate the cel's OFFSET by the drag delta instead of shifting and
|
|
246
385
|
// clipping pixels — the full cel image is preserved, so pushing art off
|
|
@@ -251,6 +390,18 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
251
390
|
}
|
|
252
391
|
}
|
|
253
392
|
function finishTool() {
|
|
393
|
+
const stroke = strokeRef.current;
|
|
394
|
+
if (stroke && stroke.tool === 'select') {
|
|
395
|
+
if (stroke.mode === 'new' && samePoint(stroke.startPoint, stroke.lastPoint)) {
|
|
396
|
+
// A click with no drag deselects.
|
|
397
|
+
setMarquee(null);
|
|
398
|
+
} else if (stroke.mode === 'move') {
|
|
399
|
+
// Keep the ants box meaningful: clip the moved rect to the canvas (the
|
|
400
|
+
// pixels themselves were already clipped on commit), dropping it if it
|
|
401
|
+
// ended fully off-canvas.
|
|
402
|
+
setMarquee((rect) => clampRectToCanvas(rect, width, height));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
254
405
|
strokeRef.current = null;
|
|
255
406
|
}
|
|
256
407
|
|
|
@@ -291,6 +442,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
291
442
|
</div>
|
|
292
443
|
<PixelArtboard
|
|
293
444
|
canvasRef={canvasRef}
|
|
445
|
+
overlayRef={overlayRef}
|
|
294
446
|
style={{ ...canvasStyle, cursor }}
|
|
295
447
|
handlers={{
|
|
296
448
|
onPointerDown: startTool,
|
|
@@ -313,6 +465,8 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
313
465
|
onSelectKey={setActiveKey}
|
|
314
466
|
paletteKeys={EDITOR_KEYS}
|
|
315
467
|
palette={EDITOR_PALETTE}
|
|
468
|
+
moveActions={moveActions}
|
|
469
|
+
selectActions={selectActions}
|
|
316
470
|
/>
|
|
317
471
|
</div>
|
|
318
472
|
</div>
|
|
@@ -330,6 +484,8 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
330
484
|
onSelectKey={setActiveKey}
|
|
331
485
|
paletteKeys={EDITOR_KEYS}
|
|
332
486
|
palette={EDITOR_PALETTE}
|
|
487
|
+
moveActions={moveActions}
|
|
488
|
+
selectActions={selectActions}
|
|
333
489
|
/>
|
|
334
490
|
</div>
|
|
335
491
|
</PxArtShell>
|
|
@@ -462,6 +618,80 @@ function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
|
|
|
462
618
|
if (preview) drawToolPreview(ctx, canvas, preview);
|
|
463
619
|
}
|
|
464
620
|
|
|
621
|
+
// Render-only animated marching ants for the marquee selection, drawn on a
|
|
622
|
+
// display-resolution overlay canvas (1 device pixel per CSS pixel) so the dashed
|
|
623
|
+
// outline is crisp and bold rather than sub-pixel on the upscaled artboard.
|
|
624
|
+
// `rect` is in canvas cells; null clears the overlay.
|
|
625
|
+
function renderMarqueeOverlay(canvas, resolution, rect, antsOffset) {
|
|
626
|
+
if (!canvas || !resolution) return;
|
|
627
|
+
const ctx = canvas.getContext('2d');
|
|
628
|
+
if (!ctx) return;
|
|
629
|
+
const cssW = canvas.clientWidth;
|
|
630
|
+
const cssH = canvas.clientHeight;
|
|
631
|
+
if (!cssW || !cssH) return;
|
|
632
|
+
const dpr = window.devicePixelRatio || 1;
|
|
633
|
+
const bw = Math.round(cssW * dpr);
|
|
634
|
+
const bh = Math.round(cssH * dpr);
|
|
635
|
+
if (canvas.width !== bw) canvas.width = bw;
|
|
636
|
+
if (canvas.height !== bh) canvas.height = bh;
|
|
637
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
638
|
+
ctx.clearRect(0, 0, bw, bh);
|
|
639
|
+
if (!rect) return;
|
|
640
|
+
|
|
641
|
+
const sx = bw / resolution.width;
|
|
642
|
+
const sy = bh / resolution.height;
|
|
643
|
+
const lineWidth = Math.max(2, Math.round(2 * dpr));
|
|
644
|
+
const dash = Math.max(5, Math.round(5 * dpr));
|
|
645
|
+
// Advance ~2 device px per tick (not a whole dash) so the ants crawl smoothly
|
|
646
|
+
// instead of teleporting a full dash each frame.
|
|
647
|
+
const step = Math.max(1, Math.round(dpr));
|
|
648
|
+
const move = (antsOffset * step) % (dash * 2);
|
|
649
|
+
const half = lineWidth / 2;
|
|
650
|
+
const x = rect.x * sx + half;
|
|
651
|
+
const y = rect.y * sy + half;
|
|
652
|
+
const w = Math.max(lineWidth, rect.w * sx - lineWidth);
|
|
653
|
+
const h = Math.max(lineWidth, rect.h * sy - lineWidth);
|
|
654
|
+
|
|
655
|
+
ctx.lineWidth = lineWidth;
|
|
656
|
+
ctx.setLineDash([dash, dash]);
|
|
657
|
+
ctx.strokeStyle = '#000000';
|
|
658
|
+
ctx.lineDashOffset = -move;
|
|
659
|
+
ctx.strokeRect(x, y, w, h);
|
|
660
|
+
ctx.strokeStyle = '#ffffff';
|
|
661
|
+
ctx.lineDashOffset = -move + dash;
|
|
662
|
+
ctx.strokeRect(x, y, w, h);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Build a marquee rect { x, y, w, h } from two cell points, normalized and
|
|
666
|
+
// clamped to the canvas (min 1x1).
|
|
667
|
+
function rectFromPoints(a, b, width, height) {
|
|
668
|
+
const x0 = Math.max(0, Math.min(a.x, b.x));
|
|
669
|
+
const y0 = Math.max(0, Math.min(a.y, b.y));
|
|
670
|
+
const x1 = Math.min(width - 1, Math.max(a.x, b.x));
|
|
671
|
+
const y1 = Math.min(height - 1, Math.max(a.y, b.y));
|
|
672
|
+
return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function pointInRect(point, rect) {
|
|
676
|
+
return (
|
|
677
|
+
point.x >= rect.x &&
|
|
678
|
+
point.x < rect.x + rect.w &&
|
|
679
|
+
point.y >= rect.y &&
|
|
680
|
+
point.y < rect.y + rect.h
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Intersect a marquee rect with the canvas; null when it ends up fully off it.
|
|
685
|
+
function clampRectToCanvas(rect, width, height) {
|
|
686
|
+
if (!rect) return null;
|
|
687
|
+
const x0 = Math.max(0, rect.x);
|
|
688
|
+
const y0 = Math.max(0, rect.y);
|
|
689
|
+
const x1 = Math.min(width, rect.x + rect.w);
|
|
690
|
+
const y1 = Math.min(height, rect.y + rect.h);
|
|
691
|
+
if (x1 <= x0 || y1 <= y0) return null;
|
|
692
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
693
|
+
}
|
|
694
|
+
|
|
465
695
|
function blitOnion(ctx, sprite, idx, color, dist) {
|
|
466
696
|
if (idx < 0 || idx >= frameCount(sprite)) return;
|
|
467
697
|
ctx.globalAlpha = Math.max(0.12, 0.5 / dist);
|
|
@@ -497,7 +727,7 @@ function onionTint(src, color) {
|
|
|
497
727
|
|
|
498
728
|
// Single-letter tool picks. The eyedropper ('i') is handled separately because
|
|
499
729
|
// it toggles the transient picker rather than swapping the sidebar tool.
|
|
500
|
-
const SHORTCUT_TOOLS = { b: 'brush', g: 'fill', e: 'erase', v: 'move-all' };
|
|
730
|
+
const SHORTCUT_TOOLS = { b: 'brush', g: 'fill', s: 'shape', m: 'select', e: 'erase', v: 'move-all' };
|
|
501
731
|
|
|
502
732
|
// True when focus is on a text-entry control, so typing there isn't hijacked.
|
|
503
733
|
function isTypingTarget(node) {
|
|
@@ -594,6 +824,9 @@ function buildToolPreview({ sprite, selection, hoverCell, tool, picking, brushSi
|
|
|
594
824
|
const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
|
|
595
825
|
return { kind: 'region', cells: fillPreviewCells(cells, hoverCell), color };
|
|
596
826
|
}
|
|
827
|
+
// The shape tool needs two points to draw, so before a drag we just mark the
|
|
828
|
+
// hovered start cell; the live shape itself shows once the drag begins.
|
|
829
|
+
if (tool === 'shape') return { kind: 'cell', cell: hoverCell };
|
|
597
830
|
return null;
|
|
598
831
|
}
|
|
599
832
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import { renderSpriteFrame } from '../engine/pxart';
|
|
3
3
|
import { basename, formatJson, parseJsonFile } from '../engine/files';
|
|
4
|
-
import { TouchControls } from '../engine/TouchControls';
|
|
5
4
|
import {
|
|
6
5
|
addActor,
|
|
7
6
|
arrangeActors,
|
|
@@ -90,7 +89,6 @@ export function SceneEditor({
|
|
|
90
89
|
const showMulti = selectedActorIds.length > 1 || multiSelectMode;
|
|
91
90
|
const inspectorSheet = useSelectionInspectorSheet(true);
|
|
92
91
|
const { value: sceneData, error } = parseJsonFile(path, text);
|
|
93
|
-
const getRuntimeKeys = useCallback(() => runtimeRef.current?.keys ?? null, []);
|
|
94
92
|
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
95
93
|
useScenePlayLoop({
|
|
96
94
|
sceneData,
|
|
@@ -288,7 +286,6 @@ export function SceneEditor({
|
|
|
288
286
|
</aside>
|
|
289
287
|
</div>
|
|
290
288
|
</EditorBody>
|
|
291
|
-
<TouchControls getKeys={getRuntimeKeys} visible={isPlaying} />
|
|
292
289
|
</>
|
|
293
290
|
);
|
|
294
291
|
}
|
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
// Single-editor
|
|
2
|
-
//
|
|
1
|
+
// Single-editor mount for the panel system. The shell (dockview) opens this as
|
|
2
|
+
// its own iframe via the deck route in main.jsx:
|
|
3
3
|
// ?file=<path>[&editor=<id>] -> <SingleEditor> (one editor for one file)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// for a file, so each file is its own panel instead of one combined editor app.
|
|
4
|
+
// The shell only routes the kit's RICH file types here (scene / pxart); file
|
|
5
|
+
// browsing and the code/text editor are now builtin shell panels.
|
|
7
6
|
|
|
8
7
|
import React, { useEffect, useRef, useState } from 'react';
|
|
9
8
|
import { onBeforeRestart, writeFile } from 'castle-web-sdk';
|
|
10
9
|
import { getFileKind, initialFiles } from '../engine/files';
|
|
11
10
|
import { collectAssets } from '../engine/assets';
|
|
12
11
|
import { MainEditor, styles } from '../engine/ui';
|
|
13
|
-
import { CodeEditor } from './CodeEditor';
|
|
14
12
|
import { PxArtEditor } from './PxArtEditor';
|
|
15
|
-
import { FileBrowser } from './FileBrowser';
|
|
16
13
|
import { SceneEditor } from './SceneEditor';
|
|
17
14
|
|
|
18
15
|
// Debounced writeFile per path, flushed on reload -- the same save behavior the
|
|
@@ -89,54 +86,12 @@ export function SingleEditor({ path, editor }) {
|
|
|
89
86
|
);
|
|
90
87
|
} else if (kind === 'pxart') {
|
|
91
88
|
body = <PxArtEditor path={path} text={text} onChange={onChange} />;
|
|
92
|
-
} else {
|
|
93
|
-
// 'code' and 'text' both render raw. Note: do NOT run plain text through
|
|
94
|
-
// formatJson (JSON.stringify) -- getFileKind only routes non-JSON files
|
|
95
|
-
// here, so it would quote/escape the contents (a new file.txt would show
|
|
96
|
-
// and save back as "Hello I'm a file" with literal quotes).
|
|
97
|
-
body = <CodeEditor path={path} text={text} onChange={onChange} />;
|
|
98
89
|
}
|
|
90
|
+
// No `else`: code/text files are handled by the builtin shell code editor, so
|
|
91
|
+
// the shell never routes them here. `body` stays null for any other kind.
|
|
99
92
|
return (
|
|
100
93
|
<div className={styles.panelBare}>
|
|
101
94
|
<MainEditor>{body}</MainEditor>
|
|
102
95
|
</div>
|
|
103
96
|
);
|
|
104
97
|
}
|
|
105
|
-
|
|
106
|
-
// File panel: a read-only file browser (bare, no sidebar/sheet chrome).
|
|
107
|
-
// Clicking a file asks the shell to open / focus its editor panel. File
|
|
108
|
-
// mutation (add / duplicate / rename / delete) is intentionally NOT wired here:
|
|
109
|
-
// under the kit's static-glob content model those edits only persist within a
|
|
110
|
-
// session, so the browser is browse-and-open only until real persistence lands.
|
|
111
|
-
export function FileList({ selectedPath }) {
|
|
112
|
-
const [files] = useState(initialFiles);
|
|
113
|
-
const [selected, setSelected] = useState(selectedPath);
|
|
114
|
-
useEffect(() => {
|
|
115
|
-
setSelected(selectedPath);
|
|
116
|
-
}, [selectedPath]);
|
|
117
|
-
|
|
118
|
-
function open(path) {
|
|
119
|
-
setSelected(path);
|
|
120
|
-
try {
|
|
121
|
-
window.parent.postMessage({ type: 'castle-open-file', path }, '*');
|
|
122
|
-
} catch {
|
|
123
|
-
/* not embedded */
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
// Safari won't paint this static panel inside its composited dockview iframe
|
|
127
|
-
// until an invalidation is forced (the rows only appear once devtools opens).
|
|
128
|
-
// translateZ(0) promotes it to its own compositing layer so Safari paints and
|
|
129
|
-
// keeps it. The container sits at the iframe's viewport origin and isn't the
|
|
130
|
-
// scrollport's scrolling box, so it doesn't shift the fixed context menu.
|
|
131
|
-
return (
|
|
132
|
-
<div
|
|
133
|
-
style={{
|
|
134
|
-
height: '100vh',
|
|
135
|
-
overflow: 'auto',
|
|
136
|
-
background: 'var(--castle-sheet-bg, #0d0d0d)',
|
|
137
|
-
transform: 'translateZ(0)',
|
|
138
|
-
}}>
|
|
139
|
-
<FileBrowser bare files={files} selectedPath={selected} onSelect={open} />
|
|
140
|
-
</div>
|
|
141
|
-
);
|
|
142
|
-
}
|
|
@@ -37,19 +37,25 @@ export function PixelEditorHeader({ title, subtitle, shell, chrome }) {
|
|
|
37
37
|
|
|
38
38
|
// The native-resolution canvas inside its artboard frame. `style` sets the fitted
|
|
39
39
|
// display size; `handlers` are the pointer callbacks the editor wires for tools.
|
|
40
|
-
|
|
40
|
+
// `overlayRef` is a sibling canvas at display resolution used for crisp overlays
|
|
41
|
+
// (marching-ants selection) that would otherwise render sub-pixel on the upscaled
|
|
42
|
+
// native canvas.
|
|
43
|
+
export function PixelArtboard({ canvasRef, overlayRef, style, handlers }) {
|
|
41
44
|
return (
|
|
42
45
|
<div className={styles.drawingArtboard}>
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
<div className={styles.drawingArtboardFrame} style={style}>
|
|
47
|
+
<canvas
|
|
48
|
+
ref={canvasRef}
|
|
49
|
+
className={styles.drawingCanvas}
|
|
50
|
+
style={style}
|
|
51
|
+
onPointerDown={handlers.onPointerDown}
|
|
52
|
+
onPointerMove={handlers.onPointerMove}
|
|
53
|
+
onPointerUp={handlers.onPointerUp}
|
|
54
|
+
onPointerCancel={handlers.onPointerCancel}
|
|
55
|
+
onPointerLeave={handlers.onPointerLeave}
|
|
56
|
+
/>
|
|
57
|
+
<canvas ref={overlayRef} className={styles.drawingOverlay} aria-hidden="true" />
|
|
58
|
+
</div>
|
|
53
59
|
</div>
|
|
54
60
|
);
|
|
55
61
|
}
|
|
@@ -43,3 +43,98 @@ export function forEachLinePoint(from, to, visit) {
|
|
|
43
43
|
visit(x, y);
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
// The shape tools (square/circle/triangle) are bounded by the axis-aligned box
|
|
48
|
+
// the drag sweeps out, so they share this normalized corner unpacking.
|
|
49
|
+
function shapeBounds(from, to) {
|
|
50
|
+
return {
|
|
51
|
+
x0: Math.min(from.x, to.x),
|
|
52
|
+
x1: Math.max(from.x, to.x),
|
|
53
|
+
y0: Math.min(from.y, to.y),
|
|
54
|
+
y1: Math.max(from.y, to.y),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Visit the cells of the rectangle bounded by `from`/`to`. `filled` paints the
|
|
59
|
+
// solid box; otherwise only the 1px border. `visit(x, y)` receives cell coords.
|
|
60
|
+
export function forEachRectCells(from, to, filled, visit) {
|
|
61
|
+
const { x0, x1, y0, y1 } = shapeBounds(from, to);
|
|
62
|
+
for (let y = y0; y <= y1; y++) {
|
|
63
|
+
for (let x = x0; x <= x1; x++) {
|
|
64
|
+
if (filled || x === x0 || x === x1 || y === y0 || y === y1) visit(x, y);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Visit the cells of the ellipse inscribed in the `from`/`to` box. A cell is
|
|
70
|
+
// "inside" when its center falls within the normalized ellipse; the half-cell
|
|
71
|
+
// radius padding lets tiny drags still fill their box. The outline keeps only
|
|
72
|
+
// inside cells with at least one 4-neighbor outside, so it reads as a closed
|
|
73
|
+
// ring with no gaps.
|
|
74
|
+
export function forEachEllipseCells(from, to, filled, visit) {
|
|
75
|
+
const { x0, x1, y0, y1 } = shapeBounds(from, to);
|
|
76
|
+
const cx = (x0 + x1) / 2;
|
|
77
|
+
const cy = (y0 + y1) / 2;
|
|
78
|
+
const rx = (x1 - x0) / 2 + 0.5;
|
|
79
|
+
const ry = (y1 - y0) / 2 + 0.5;
|
|
80
|
+
const inside = (x, y) => {
|
|
81
|
+
const nx = (x - cx) / rx;
|
|
82
|
+
const ny = (y - cy) / ry;
|
|
83
|
+
return nx * nx + ny * ny <= 1;
|
|
84
|
+
};
|
|
85
|
+
for (let y = y0; y <= y1; y++) {
|
|
86
|
+
for (let x = x0; x <= x1; x++) {
|
|
87
|
+
if (!inside(x, y)) continue;
|
|
88
|
+
if (filled) {
|
|
89
|
+
visit(x, y);
|
|
90
|
+
} else if (
|
|
91
|
+
!inside(x - 1, y) ||
|
|
92
|
+
!inside(x + 1, y) ||
|
|
93
|
+
!inside(x, y - 1) ||
|
|
94
|
+
!inside(x, y + 1)
|
|
95
|
+
) {
|
|
96
|
+
visit(x, y);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Cross-product sign of point c relative to the directed edge a->b. Used both
|
|
103
|
+
// to build the triangle outline and to test point containment for the fill.
|
|
104
|
+
function edgeCross(a, b, cx, cy) {
|
|
105
|
+
return (b.x - a.x) * (cy - a.y) - (b.y - a.y) * (cx - a.x);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Visit the cells of an isosceles triangle inscribed in the `from`/`to` box:
|
|
109
|
+
// apex centered on the top edge, base spanning the bottom edge. `filled` uses a
|
|
110
|
+
// barycentric point-in-triangle test over the box; the outline walks the three
|
|
111
|
+
// edges. A shared `seen` set dedups cells shared by adjacent edges/scanlines.
|
|
112
|
+
export function forEachTriangleCells(from, to, filled, visit) {
|
|
113
|
+
const { x0, x1, y0, y1 } = shapeBounds(from, to);
|
|
114
|
+
const apex = { x: Math.round((x0 + x1) / 2), y: y0 };
|
|
115
|
+
const left = { x: x0, y: y1 };
|
|
116
|
+
const right = { x: x1, y: y1 };
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const emit = (x, y) => {
|
|
119
|
+
const key = `${x},${y}`;
|
|
120
|
+
if (seen.has(key)) return;
|
|
121
|
+
seen.add(key);
|
|
122
|
+
visit(x, y);
|
|
123
|
+
};
|
|
124
|
+
if (filled) {
|
|
125
|
+
for (let y = y0; y <= y1; y++) {
|
|
126
|
+
for (let x = x0; x <= x1; x++) {
|
|
127
|
+
const w0 = edgeCross(left, right, x, y);
|
|
128
|
+
const w1 = edgeCross(right, apex, x, y);
|
|
129
|
+
const w2 = edgeCross(apex, left, x, y);
|
|
130
|
+
const hasNeg = w0 < 0 || w1 < 0 || w2 < 0;
|
|
131
|
+
const hasPos = w0 > 0 || w1 > 0 || w2 > 0;
|
|
132
|
+
if (!(hasNeg && hasPos)) emit(x, y);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
forEachLinePoint(apex, left, emit);
|
|
138
|
+
forEachLinePoint(left, right, emit);
|
|
139
|
+
forEachLinePoint(right, apex, emit);
|
|
140
|
+
}
|