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.
Files changed (72) hide show
  1. package/dist/agent-prompts.d.ts +4 -0
  2. package/dist/agent-prompts.js +20 -8
  3. package/dist/agent.js +301 -28
  4. package/dist/castle-host/host.js +59 -5
  5. package/dist/commonInstructions.d.ts +1 -1
  6. package/dist/commonInstructions.js +5 -1
  7. package/dist/ide.d.ts +1 -0
  8. package/dist/ide.js +250 -1
  9. package/dist/init.js +46 -4
  10. package/dist/save-deck.js +8 -1
  11. package/dist/serve.js +27 -1
  12. package/dist/shell/assets/index-DNWEQd4R.js +141 -0
  13. package/dist/shell/assets/{index-WNbOHPBj.css → index-DuKq-Grp.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/basic-2d/CLAUDE.md +12 -4
  16. package/kits/basic-2d/castle.json +15 -0
  17. package/kits/basic-2d/drawings/pig.pxart +22 -55
  18. package/kits/basic-2d/editors/PxArtEditor.jsx +237 -4
  19. package/kits/basic-2d/editors/SceneEditor.jsx +0 -3
  20. package/kits/basic-2d/editors/SingleEditor.jsx +6 -51
  21. package/kits/basic-2d/editors/pixelEditorChrome.jsx +17 -11
  22. package/kits/basic-2d/editors/pixelGeometry.js +95 -0
  23. package/kits/basic-2d/editors/pixelInspector.jsx +229 -51
  24. package/kits/basic-2d/editors/pxArtTools.js +109 -1
  25. package/kits/basic-2d/engine/ScenePlayer.jsx +11 -163
  26. package/kits/basic-2d/engine/ui.jsx +15 -0
  27. package/kits/basic-2d/engine/ui.module.css +44 -117
  28. package/kits/basic-2d/main.jsx +6 -10
  29. package/kits/basic-2d/pnpm-workspace.yaml +3 -0
  30. package/package.json +10 -1
  31. package/dist/shell/assets/index-Dfn29Bkt.js +0 -108
  32. package/kits/basic-2d/editors/App.jsx +0 -226
  33. package/kits/basic-2d/editors/CodeEditor.jsx +0 -79
  34. package/kits/basic-2d/editors/FileBrowser.jsx +0 -349
  35. package/kits/basic-2d/editors/codeTheme.js +0 -135
  36. package/kits/basic-2d/engine/TouchControls.jsx +0 -136
  37. package/kits/basic-2d/engine/playConsole.js +0 -66
  38. package/kits/basic-3d/.prettierrc +0 -8
  39. package/kits/basic-3d/CLAUDE.md +0 -162
  40. package/kits/basic-3d/behaviors/Camera.jsx +0 -56
  41. package/kits/basic-3d/behaviors/Collider.jsx +0 -78
  42. package/kits/basic-3d/behaviors/Mesh.jsx +0 -82
  43. package/kits/basic-3d/behaviors/Model.jsx +0 -61
  44. package/kits/basic-3d/behaviors/Transform.jsx +0 -35
  45. package/kits/basic-3d/editors/App.jsx +0 -147
  46. package/kits/basic-3d/editors/CodeEditor.jsx +0 -112
  47. package/kits/basic-3d/editors/FileBrowser.jsx +0 -143
  48. package/kits/basic-3d/editors/ModelEditor.jsx +0 -400
  49. package/kits/basic-3d/editors/PlayOnly.jsx +0 -22
  50. package/kits/basic-3d/editors/SceneEditor.jsx +0 -1087
  51. package/kits/basic-3d/editors/behaviorRegistry.js +0 -24
  52. package/kits/basic-3d/editors/editorHistory.js +0 -52
  53. package/kits/basic-3d/editors/viewportRig.js +0 -90
  54. package/kits/basic-3d/engine/ScenePlayer.jsx +0 -61
  55. package/kits/basic-3d/engine/SceneUI.jsx +0 -67
  56. package/kits/basic-3d/engine/SceneViewport.jsx +0 -102
  57. package/kits/basic-3d/engine/TouchControls.jsx +0 -136
  58. package/kits/basic-3d/engine/autoInspector.jsx +0 -51
  59. package/kits/basic-3d/engine/files.js +0 -73
  60. package/kits/basic-3d/engine/scene.js +0 -502
  61. package/kits/basic-3d/engine/threeUtil.js +0 -260
  62. package/kits/basic-3d/engine/ui.jsx +0 -352
  63. package/kits/basic-3d/engine/ui.module.css +0 -944
  64. package/kits/basic-3d/eslint.config.js +0 -51
  65. package/kits/basic-3d/index.html +0 -11
  66. package/kits/basic-3d/main.jsx +0 -10
  67. package/kits/basic-3d/models/block.model +0 -14
  68. package/kits/basic-3d/package-lock.json +0 -2713
  69. package/kits/basic-3d/package.json +0 -41
  70. package/kits/basic-3d/pnpm-lock.yaml +0 -1769
  71. package/kits/basic-3d/scenes/main.scene +0 -76
  72. 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
- const [activeKey, setActiveKey] = useState(EDITOR_KEYS[1]);
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 cursor = picking ? 'crosshair' : (TOOL_CURSORS[tool] ?? 'default');
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
- history.commit(serializeModel(withCellsAt(sprite, selection.layerIndex, selection.frameIndex, nextCells)));
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 + file-list mounts for the panel system. The shell (dockview)
2
- // opens each as its own iframe via the deck route in main.jsx:
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
- // ?panel=files -> <FileList> (postMessages the shell to open a file)
5
- // This is the "deck single-editor mount": the deck serves one deck-level editor
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
- export function PixelArtboard({ canvasRef, style, handlers }) {
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
- <canvas
44
- ref={canvasRef}
45
- className={styles.drawingCanvas}
46
- style={style}
47
- onPointerDown={handlers.onPointerDown}
48
- onPointerMove={handlers.onPointerMove}
49
- onPointerUp={handlers.onPointerUp}
50
- onPointerCancel={handlers.onPointerCancel}
51
- onPointerLeave={handlers.onPointerLeave}
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
+ }