castle-web-cli 0.4.78 → 0.4.79

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 (46) hide show
  1. package/dist/agent-prompts.d.ts +4 -1
  2. package/dist/agent-prompts.js +28 -7
  3. package/dist/agent.d.ts +7 -2
  4. package/dist/agent.js +655 -51
  5. package/dist/native/loop.d.ts +2 -0
  6. package/dist/native/loop.js +698 -0
  7. package/dist/native/openrouter.d.ts +55 -0
  8. package/dist/native/openrouter.js +354 -0
  9. package/dist/native/playtest-browser.d.ts +34 -0
  10. package/dist/native/playtest-browser.js +354 -0
  11. package/dist/native/playtest-executor.d.ts +3 -0
  12. package/dist/native/playtest-executor.js +156 -0
  13. package/dist/native/playtest.d.ts +131 -0
  14. package/dist/native/playtest.js +314 -0
  15. package/dist/native/tools.d.ts +38 -0
  16. package/dist/native/tools.js +630 -0
  17. package/dist/native/types.d.ts +40 -0
  18. package/dist/native/types.js +41 -0
  19. package/dist/serve.js +12 -0
  20. package/dist/shell/assets/{index-yGdKhgfZ.js → index-CNT3KxJb.js} +37 -37
  21. package/dist/shell/assets/{index-WE24qX3d.css → index-RZrw5gQ2.css} +1 -1
  22. package/dist/shell/index.html +2 -2
  23. package/kits/basic-2d/CLAUDE.md +29 -3
  24. package/kits/basic-2d/behaviors/Layout.jsx +10 -0
  25. package/kits/basic-2d/behaviors/Sprite.jsx +1 -1
  26. package/kits/basic-2d/blueprints/cauldron.scene +22 -0
  27. package/kits/basic-2d/castle.json +5 -7
  28. package/kits/basic-2d/docs/pxart-format.md +4 -3
  29. package/kits/basic-2d/drawings/cauldron.pxart +113 -0
  30. package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
  31. package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
  32. package/kits/basic-2d/editors/SceneEditor.jsx +399 -411
  33. package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
  34. package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
  35. package/kits/basic-2d/editors/editorHistory.js +8 -2
  36. package/kits/basic-2d/editors/inspectorSheet.js +5 -19
  37. package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
  38. package/kits/basic-2d/engine/blueprint.js +423 -0
  39. package/kits/basic-2d/engine/files.js +1 -1
  40. package/kits/basic-2d/engine/scene.js +29 -29
  41. package/kits/basic-2d/engine/ui.jsx +160 -21
  42. package/kits/basic-2d/engine/ui.module.css +155 -13
  43. package/kits/basic-2d/pnpm-workspace.yaml +3 -0
  44. package/kits/basic-2d/scenes/main.scene +3 -13
  45. package/package.json +2 -1
  46. package/kits/basic-2d/drawings/pig.pxart +0 -26
@@ -48,10 +48,19 @@ const SCALE_HANDLES = [
48
48
  { id: 'w', x: -1, y: 0, cursor: 'ew-resize' },
49
49
  ];
50
50
 
51
+ // `sceneData` (raw, sparse) is the clone target for every write here --
52
+ // writes only ever add/replace the exact prop keys a gesture changed (x/y,
53
+ // rotation, width/height), preserving whatever sparse overrides already
54
+ // existed. `previewSceneData` (blueprint template merged with overrides) is
55
+ // the read side for every geometry computation (bounds, colliders, drag-start
56
+ // snapshots) -- an instance that inherits width/height from its blueprint has
57
+ // no `Layout.width` at all in `sceneData`, so reading raw here would produce
58
+ // NaN boxes. See engine/blueprint.js for the merge this mirrors.
51
59
  export function SelectionOverlay({
52
60
  canvasRef,
53
61
  editCameraRef,
54
62
  sceneData,
63
+ previewSceneData,
55
64
  selectedActorIds,
56
65
  snap,
57
66
  onArrange,
@@ -91,20 +100,20 @@ export function SelectionOverlay({
91
100
  setArrangeOpen(false);
92
101
  }, [selectionKey]);
93
102
 
94
- const frame = getSelectionFrame(sceneData, selectedActorIds, groupFrameRotation);
103
+ const frame = getSelectionFrame(previewSceneData, selectedActorIds, groupFrameRotation);
95
104
 
96
105
  const onMoveDown = usePointerDragHandle((event) => {
97
106
  const canvas = canvasRef.current;
98
107
  if (!canvas || !sceneData || selectedActorIds.length === 0) return null;
99
108
  const startPoint = screenToCard(canvas, event.clientX, event.clientY);
100
- const starts = collectLayoutStarts(sceneData, selectedActorIds);
109
+ const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
101
110
  const state = { recorded: false };
102
111
  return {
103
112
  onMove: (moveEvent) => {
104
113
  const point = screenToCard(canvas, moveEvent.clientX, moveEvent.clientY);
105
114
  const dx = snapDelta(point.x - startPoint.x, snap);
106
115
  const dy = snapDelta(point.y - startPoint.y, snap);
107
- const next = moveSelected(sceneData, selectedActorIds, starts, dx, dy);
116
+ const next = moveSelected(sceneData, previewSceneData, selectedActorIds, starts, dx, dy);
108
117
  applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
109
118
  },
110
119
  };
@@ -118,7 +127,7 @@ export function SelectionOverlay({
118
127
  let lastAngle = startAngle;
119
128
  let totalDelta = 0;
120
129
  const startFrameRotation = frame.rotation;
121
- const starts = collectLayoutStarts(sceneData, selectedActorIds);
130
+ const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
122
131
  const state = { recorded: false, lastDelta: 0 };
123
132
  return {
124
133
  onMove: (moveEvent) => {
@@ -138,7 +147,7 @@ export function SelectionOverlay({
138
147
  if (delta === state.lastDelta) return;
139
148
  state.lastDelta = delta;
140
149
  setGroupFrameRotation(normalizeAngle(startFrameRotation + delta));
141
- const next = rotateSelected(sceneData, selectedActorIds, starts, center, delta);
150
+ const next = rotateSelected(sceneData, previewSceneData, selectedActorIds, starts, center, delta);
142
151
  if (!state.recorded) {
143
152
  recordSnapshot();
144
153
  state.recorded = true;
@@ -155,7 +164,7 @@ export function SelectionOverlay({
155
164
  if (!handle || !canvas || !sceneData || !frame || selectedActorIds.length === 0) return null;
156
165
  const startTransform = makeBoundsTransform(frame, frame.rotation);
157
166
  const startPointer = pointerLocal(canvas, editCameraRef, event, startTransform);
158
- const starts = collectLayoutStarts(sceneData, selectedActorIds);
167
+ const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
159
168
  const state = { recorded: false };
160
169
  return {
161
170
  onMove: (moveEvent) => {
@@ -169,7 +178,7 @@ export function SelectionOverlay({
169
178
  dy,
170
179
  moveEvent.shiftKey
171
180
  );
172
- const next = scaleSelected(sceneData, selectedActorIds, starts, startTransform, nextBounds);
181
+ const next = scaleSelected(sceneData, previewSceneData, selectedActorIds, starts, startTransform, nextBounds);
173
182
  applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
174
183
  },
175
184
  };
@@ -181,7 +190,7 @@ export function SelectionOverlay({
181
190
  }
182
191
 
183
192
  const geometry = getOverlayGeometry(frame, box, camera);
184
- const colliderFrames = getSelectedColliderFrames(sceneData, selectedActorIds);
193
+ const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds);
185
194
 
186
195
  return (
187
196
  <div ref={rootRef} className={styles.selOverlayRoot}>
@@ -221,7 +230,7 @@ export function SelectionOverlay({
221
230
  transformOrigin: `${geometry.centerCardX}px ${geometry.centerCardY}px`,
222
231
  transform: `rotate(${geometry.rotation}deg)`,
223
232
  }}>
224
- <SelectionChrome frame={frame} stemLength={geometry.stemLength} onScaleDown={onScaleDown} />
233
+ <SelectionChrome frame={frame} stemLength={geometry.stemLength} />
225
234
  </div>
226
235
  </div>
227
236
  </div>
@@ -230,12 +239,56 @@ export function SelectionOverlay({
230
239
  readable. The rotate handle orbits via its anchor; clone/delete flip
231
240
  above/below to stay clear of it. */}
232
241
  <div className={styles.selButtonLayer}>
242
+ {/* Dashed bounding box + center pivot render here (un-scaled px layer)
243
+ rather than the chrome layer, so their stroke width, dash pattern,
244
+ and pivot size stay constant with zoom. Their px dimensions are
245
+ projected from the box's screen extents, so the box still traces the
246
+ actual bounds. */}
247
+ <div
248
+ className={styles.selBox}
249
+ style={{
250
+ left: geometry.centerX,
251
+ top: geometry.centerY,
252
+ width: geometry.boxWidthPx,
253
+ height: geometry.boxHeightPx,
254
+ transform: `translate(-50%, -50%) rotate(${geometry.rotation}deg)`,
255
+ }}
256
+ />
257
+ <div className={styles.selPivot} style={{ left: geometry.centerX, top: geometry.centerY }} />
258
+ {/* Scale handles live here (un-scaled px layer), not in the chrome layer:
259
+ projecting them to screen px keeps them crisp and a constant size at
260
+ any zoom, instead of being rasterized small then stretched by the
261
+ layer's `scale()`. Each is rotated so edge handles hug the box side. */}
262
+ {geometry.scaleHandles.map((handle) => (
263
+ <div
264
+ key={handle.id}
265
+ role="button"
266
+ aria-label={`Scale ${handle.id}`}
267
+ title={`Scale ${handle.id}`}
268
+ className={cx(
269
+ styles.selScaleHandle,
270
+ handle.corner ? styles.selScaleCorner : styles.selScaleEdge
271
+ )}
272
+ data-handle={handle.id}
273
+ style={{
274
+ left: handle.x,
275
+ top: handle.y,
276
+ cursor: handle.cursor,
277
+ transform: `translate(-50%, -50%) rotate(${geometry.rotation}deg)`,
278
+ }}
279
+ onPointerDown={(event) => {
280
+ event.stopPropagation();
281
+ onScaleDown(event);
282
+ }}
283
+ />
284
+ ))}
233
285
  <Floating x={geometry.cloneAnchor.x} y={geometry.cloneAnchor.y}>
234
286
  <div className={styles.selBtnGroup}>
235
287
  <div className={styles.selArrangeWrap}>
236
288
  <OverlayButton
237
289
  label="Arrange"
238
290
  icon="layer-group"
291
+ disabled={!onArrange}
239
292
  onActivate={() => setArrangeOpen((open) => !open)}
240
293
  />
241
294
  {arrangeOpen ? (
@@ -261,8 +314,8 @@ export function SelectionOverlay({
261
314
  </div>
262
315
  ) : null}
263
316
  </div>
264
- <OverlayButton label="Clone" icon="clone" onActivate={onClone} />
265
- <OverlayButton label="Delete" icon="trash" onActivate={onDelete} />
317
+ <OverlayButton label="Clone" icon="clone" disabled={!onClone} onActivate={onClone} />
318
+ <OverlayButton label="Delete" icon="trash" disabled={!onDelete} onActivate={onDelete} />
266
319
  </div>
267
320
  </Floating>
268
321
  <Floating x={geometry.rotateAnchor.x} y={geometry.rotateAnchor.y}>
@@ -282,49 +335,21 @@ export function SelectionOverlay({
282
335
  // Chrome in card units: dashed bounding box, scale handles, connector stem, and
283
336
  // center pivot dot. The layer itself ignores pointer events; only the handles
284
337
  // opt back in so the canvas still receives ordinary selection gestures.
285
- function SelectionChrome({ frame, stemLength, onScaleDown }) {
338
+ function SelectionChrome({ frame, stemLength }) {
286
339
  const left = frame.x;
287
340
  const top = frame.y;
288
341
  const width = frame.width;
289
342
  const height = frame.height;
290
- const centerX = left + width / 2;
291
343
  const centerY = top + height / 2;
292
- const handlePosition = (handle) => ({
293
- left: centerX + (handle.x * width) / 2,
294
- top: centerY + (handle.y * height) / 2,
295
- cursor: handle.cursor,
296
- });
297
- return (
298
- <>
299
- {/* Connector stem from the right-center edge out to the rotate handle.
300
- Lives in the rotated chrome, so it swings with the actor. */}
301
- {stemLength > 0 ? (
302
- <div className={styles.selStem} style={{ left: left + width, top: centerY, width: stemLength }} />
303
- ) : null}
304
- <div className={styles.selBox} style={{ left, top, width, height }} />
305
- {SCALE_HANDLES.map((handle) => (
306
- <div
307
- key={handle.id}
308
- role="button"
309
- aria-label={`Scale ${handle.id}`}
310
- title={`Scale ${handle.id}`}
311
- className={cx(
312
- styles.selScaleHandle,
313
- handle.corner ? styles.selScaleCorner : styles.selScaleEdge
314
- )}
315
- data-handle={handle.id}
316
- style={handlePosition(handle)}
317
- onPointerDown={(event) => {
318
- event.stopPropagation();
319
- onScaleDown(event);
320
- }}
321
- />
322
- ))}
323
- <div className={styles.selPivot} style={{ left: centerX, top: centerY }} />
324
- </>
325
- );
344
+ if (stemLength <= 0) return null;
345
+ // Connector stem from the right-center edge out to the rotate handle. Lives in
346
+ // the rotated chrome so it swings with the actor. (The box + pivot render in
347
+ // the un-scaled px layer so their stroke/size stay constant with zoom.)
348
+ return <div className={styles.selStem} style={{ left: left + width, top: centerY, width: stemLength }} />;
326
349
  }
327
350
 
351
+ // `sceneData` here is always the MERGED preview scene (see the note on
352
+ // `SelectionOverlay` above) -- every Layout read in this file is.
328
353
  function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
329
354
  if (!sceneData || !actorIds || actorIds.length === 0) return null;
330
355
  const wanted = new Set(actorIds);
@@ -370,9 +395,35 @@ function getOverlayGeometry(frame, box, camera) {
370
395
  rotateAnchor,
371
396
  cloneAnchor: getActionAnchor({ centerX, centerY, halfH, rotateAnchor }),
372
397
  stemLength: HANDLE_OFFSET + GAP_PX / sx,
398
+ boxWidthPx: frame.width * sx,
399
+ boxHeightPx: frame.height * sy,
400
+ scaleHandles: getScaleHandleAnchors({
401
+ centerX,
402
+ centerY,
403
+ halfWpx: (frame.width / 2) * sx,
404
+ halfHpx: (frame.height / 2) * sy,
405
+ rotation,
406
+ }),
373
407
  };
374
408
  }
375
409
 
410
+ // Project the 8 scale handles from the box's rotated corners/edges into screen
411
+ // px so they can render in the un-scaled button layer at a constant size.
412
+ function getScaleHandleAnchors({ centerX, centerY, halfWpx, halfHpx, rotation }) {
413
+ const rad = (rotation * Math.PI) / 180;
414
+ const cosR = Math.cos(rad);
415
+ const sinR = Math.sin(rad);
416
+ return SCALE_HANDLES.map((handle) => {
417
+ const lx = handle.x * halfWpx;
418
+ const ly = handle.y * halfHpx;
419
+ return {
420
+ ...handle,
421
+ x: centerX + lx * cosR - ly * sinR,
422
+ y: centerY + lx * sinR + ly * cosR,
423
+ };
424
+ });
425
+ }
426
+
376
427
  function getRotateAnchor({ centerX, centerY, halfW, rotation }) {
377
428
  const rad = (rotation * Math.PI) / 180;
378
429
  const cosR = Math.cos(rad);
@@ -492,19 +543,22 @@ function Floating({ x, y, children }) {
492
543
  );
493
544
  }
494
545
 
495
- function OverlayButton({ label, icon, onActivate, onPointerDown, extraClass }) {
546
+ function OverlayButton({ label, icon, onActivate, onPointerDown, extraClass, disabled = false }) {
496
547
  return (
497
548
  <button
498
549
  type="button"
499
550
  aria-label={label}
500
551
  title={label}
552
+ disabled={disabled}
501
553
  className={cx(styles.selBtn, extraClass)}
502
554
  onPointerDown={(event) => {
503
555
  event.stopPropagation();
556
+ if (disabled) return;
504
557
  onPointerDown?.(event);
505
558
  }}
506
559
  onClick={(event) => {
507
560
  event.stopPropagation();
561
+ if (disabled) return;
508
562
  onActivate?.();
509
563
  }}>
510
564
  <Icon name={icon} />
@@ -577,29 +631,37 @@ function snapDelta(delta, snap) {
577
631
  return Math.round(delta / snap.gridSize) * snap.gridSize;
578
632
  }
579
633
 
580
- // Clone the scene and patch the Layout of every selected actor. `patch(layout,
581
- // start)` returns the props to merge into that actor's Layout, or null to leave
582
- // it untouched. Returns the original `sceneData` when nothing changed so callers
583
- // can skip a no-op commit.
584
- function updateSelectedLayouts(sceneData, actorIds, starts, patch) {
634
+ // Clone the RAW `sceneData` (the sparse write target) and patch the Layout
635
+ // override of every selected actor, but read each actor's CURRENT layout
636
+ // from `previewSceneData` (blueprint-merged) so `patch` sees real width/
637
+ // height/rotation even when an instance inherits them and has no Layout
638
+ // override of its own yet. `patch(layout, start)` returns the props to merge
639
+ // into that actor's Layout OVERRIDE (not the merged layout), or null to leave
640
+ // it untouched -- this is exactly the sparse-override write path (drag =
641
+ // x/y, always non-inherited; scale additionally promotes width/height to
642
+ // instance overrides). Returns the original `sceneData` when nothing changed
643
+ // so callers can skip a no-op commit.
644
+ function updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, patch) {
585
645
  const wanted = new Set(actorIds);
586
646
  const next = structuredClone(sceneData);
587
647
  let changed = false;
588
648
  for (const actor of next.actors) {
589
649
  if (!wanted.has(actor.id)) continue;
590
650
  const start = starts[actor.id];
591
- const layout = actor.components.Layout;
592
- if (!start || !layout) continue;
593
- const props = patch(layout, start);
651
+ const previewLayout = previewSceneData.actors.find((candidate) => candidate.id === actor.id)
652
+ ?.components?.Layout;
653
+ if (!start || !previewLayout) continue;
654
+ const props = patch(previewLayout, start);
594
655
  if (!props) continue;
595
- actor.components.Layout = { ...layout, ...props };
656
+ actor.components ??= {};
657
+ actor.components.Layout = { ...(actor.components.Layout ?? {}), ...props };
596
658
  changed = true;
597
659
  }
598
660
  return changed ? next : sceneData;
599
661
  }
600
662
 
601
- function moveSelected(sceneData, actorIds, starts, dx, dy) {
602
- return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
663
+ function moveSelected(sceneData, previewSceneData, actorIds, starts, dx, dy) {
664
+ return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
603
665
  const x = Math.round(start.x + dx);
604
666
  const y = Math.round(start.y + dy);
605
667
  return layout.x === x && layout.y === y ? null : { x, y };
@@ -608,8 +670,8 @@ function moveSelected(sceneData, actorIds, starts, dx, dy) {
608
670
 
609
671
  // Common-pivot rotation: actor centers orbit the selection pivot while each
610
672
  // actor also spins by the same delta, matching a temporary group transform.
611
- function rotateSelected(sceneData, actorIds, starts, pivot, deltaDeg) {
612
- return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
673
+ function rotateSelected(sceneData, previewSceneData, actorIds, starts, pivot, deltaDeg) {
674
+ return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
613
675
  const center = {
614
676
  x: start.x + start.width / 2,
615
677
  y: start.y + start.height / 2,
@@ -623,11 +685,11 @@ function rotateSelected(sceneData, actorIds, starts, pivot, deltaDeg) {
623
685
  });
624
686
  }
625
687
 
626
- function scaleSelected(sceneData, actorIds, starts, startTransform, nextLocalBounds) {
688
+ function scaleSelected(sceneData, previewSceneData, actorIds, starts, startTransform, nextLocalBounds) {
627
689
  const startBounds = startTransform.localBounds;
628
690
  const scaleX = startBounds.width === 0 ? 1 : nextLocalBounds.width / startBounds.width;
629
691
  const scaleY = startBounds.height === 0 ? 1 : nextLocalBounds.height / startBounds.height;
630
- return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
692
+ return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
631
693
  const startCenter = {
632
694
  x: start.x + start.width / 2,
633
695
  y: start.y + start.height / 2,
@@ -79,9 +79,17 @@ export function SingleEditor({ path, editor }) {
79
79
  [stashKey, selectedActorIds, multiSelectMode]
80
80
  );
81
81
  const { sprites } = collectAssets(files);
82
+ // Optimistic cross-file edit: fold the new text into live files state NOW
83
+ // (so merged previews update this frame) and debounce the real write; the
84
+ // fs echo of our own write is skipped while pending (shouldSkipPath above).
85
+ // Used for this editor's own file AND for blueprint-file edits made from a
86
+ // scene panel's blueprint inspector.
87
+ function onChangeFile(targetPath, nextText) {
88
+ setFiles((current) => ({ ...current, [targetPath]: nextText }));
89
+ schedule(targetPath, nextText);
90
+ }
82
91
  function onChange(nextText) {
83
- setFiles((current) => ({ ...current, [path]: nextText }));
84
- schedule(path, nextText);
92
+ onChangeFile(path, nextText);
85
93
  }
86
94
  const kind = editor || getFileKind(path);
87
95
  const text = files[path] ?? '';
@@ -94,6 +102,7 @@ export function SingleEditor({ path, editor }) {
94
102
  files={files}
95
103
  sprites={sprites}
96
104
  onChange={onChange}
105
+ onChangeFile={onChangeFile}
97
106
  selectedActorIds={selectedActorIds}
98
107
  onSelectActorIds={setSelectedActorIds}
99
108
  multiSelectMode={multiSelectMode}
@@ -138,8 +138,14 @@ export function useUndoRedoShortcuts(history, enabled = true) {
138
138
  if (event.shiftKey) historyRef.current.redo();
139
139
  else historyRef.current.undo();
140
140
  }
141
- window.addEventListener('keydown', onKeyDown);
142
- return () => window.removeEventListener('keydown', onKeyDown);
141
+ // Capture phase, not bubble: this must win Cmd+Z/Cmd+Shift+Z before it can
142
+ // fall through to the browser's own undo, and before any other listener
143
+ // further down the tree (e.g. a widget that stops propagation on its own
144
+ // keydown) can swallow it first. This only matters once this iframe
145
+ // actually HAS keyboard focus (see EditorBody's pointer-down focus claim)
146
+ // -- capture order can't help if the event never reaches this window.
147
+ window.addEventListener('keydown', onKeyDown, true);
148
+ return () => window.removeEventListener('keydown', onKeyDown, true);
143
149
  }, []);
144
150
  }
145
151
 
@@ -1,23 +1,9 @@
1
- import { useEffect, useState } from 'react';
2
1
  import { styles, useMobileSheet } from '../engine/ui';
3
2
 
4
- // Inspector panel as a bottom sheet on compact viewports, docked on desktop.
5
- // Used by the pixel-art (PxArt) editor. `inspectorOpen` drives visibility;
6
- // tapping the grab handle toggles between the high and low snaps.
3
+ // Inspector panel as a drag-resizable bottom sheet on compact viewports, docked
4
+ // on desktop. Used by the pixel-art (PxArt) editor. `inspectorOpen` drives
5
+ // visibility; the sheet hook owns the resize height and the settle-on-release
6
+ // behavior (drag the grab handle to resize, tap it to toggle peek / expanded).
7
7
  export function useInspectorSheet(inspectorOpen) {
8
- const [snap, setSnap] = useState('high');
9
- useEffect(() => {
10
- if (inspectorOpen) setSnap('high');
11
- }, [inspectorOpen]);
12
- const effectiveSnap = inspectorOpen ? snap : 'hidden';
13
- return useMobileSheet({
14
- snap: effectiveSnap,
15
- baseClassName: styles.inspector,
16
- onTransition: (direction) => {
17
- if (!inspectorOpen) return;
18
- if (direction === 'tap') setSnap((previous) => (previous === 'high' ? 'low' : 'high'));
19
- else if (direction === 'down') setSnap('low');
20
- else if (direction === 'up') setSnap('high');
21
- },
22
- });
8
+ return useMobileSheet({ open: inspectorOpen, baseClassName: styles.inspector });
23
9
  }
@@ -6,7 +6,7 @@ import { SceneUI } from './SceneUI';
6
6
  // behavior-driven UI overlay. No game logic lives here -- behaviors and
7
7
  // scenes are the place for that. The dev logs drawer is a builtin shell panel
8
8
  // now (cli/src/shell), not rendered here.
9
- export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame }) {
9
+ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirstFrame }) {
10
10
  const canvasRef = useRef(null);
11
11
  const runtimeRef = useRef(null);
12
12
  const getRuntime = useCallback(() => runtimeRef.current, []);
@@ -16,7 +16,7 @@ export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame
16
16
  const ctx = canvas.getContext('2d');
17
17
  if (!ctx) return undefined;
18
18
  configureSceneCanvas(canvas, ctx);
19
- const runtime = makeScene(sceneData, behaviorClasses, sprites).clone();
19
+ const runtime = makeScene(sceneData, behaviorClasses, sprites, files).clone();
20
20
  runtimeRef.current = runtime;
21
21
  // Store BOTH the physical code ('KeyX', 'ArrowLeft', 'Space') and the
22
22
  // logical key ('x', 'ArrowLeft', ' ') so behaviors can match either. Codes