castle-web-cli 0.4.129 → 0.4.131

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 (144) hide show
  1. package/dist/agent-prompts.js +4 -4
  2. package/dist/filesChanged.d.ts +2 -0
  3. package/dist/filesChanged.js +6 -2
  4. package/dist/ide.js +15 -4
  5. package/dist/importBrowse.js +1 -0
  6. package/dist/index.js +10 -4
  7. package/dist/init.js +63 -15
  8. package/dist/native/loop.js +1 -1
  9. package/dist/native/tools.js +1 -1
  10. package/dist/serve.d.ts +1 -0
  11. package/dist/serve.js +20 -1
  12. package/dist/shell/assets/index-BpOLUyVO.js +441 -0
  13. package/dist/shell/assets/{index-CAq6f9B5.css → index-D6K-0YDB.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/physics-2d/CLAUDE.md +9 -10
  16. package/kits/physics-2d/castle.json +1 -1
  17. package/kits/physics-2d/editors/PlayOnly.jsx +16 -6
  18. package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
  19. package/kits/physics-2d/editors/SceneEditor.jsx +10 -10
  20. package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
  21. package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
  22. package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
  23. package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
  24. package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
  25. package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
  26. package/kits/physics-2d/engine/ScenePlayer.jsx +2 -2
  27. package/kits/physics-2d/engine/blueprint.js +23 -5
  28. package/kits/physics-2d/engine/liveReload.js +33 -21
  29. package/kits/physics-2d/engine/scene.js +17 -5
  30. package/kits/physics-2d/engine/systemRegistry.js +1 -1
  31. package/kits/physics-2d/engine/ui.jsx +2 -2
  32. package/kits/physics-2d/engine/ui.module.css +42 -0
  33. package/kits/physics-3d/CLAUDE.md +108 -0
  34. package/kits/physics-3d/behaviors/Body.jsx +51 -0
  35. package/kits/physics-3d/behaviors/Door.jsx +31 -0
  36. package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
  37. package/kits/physics-3d/behaviors/Model.jsx +14 -0
  38. package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
  39. package/kits/physics-3d/behaviors/Player.jsx +172 -0
  40. package/kits/physics-3d/behaviors/Shape.jsx +75 -0
  41. package/kits/physics-3d/behaviors/Solid.jsx +11 -0
  42. package/kits/physics-3d/behaviors/Transform.jsx +24 -0
  43. package/kits/physics-3d/blueprints/barrel.scene +15 -0
  44. package/kits/physics-3d/blueprints/crate.scene +29 -0
  45. package/kits/physics-3d/blueprints/door.scene +35 -0
  46. package/kits/physics-3d/blueprints/gem.scene +26 -0
  47. package/kits/physics-3d/blueprints/pillar.scene +25 -0
  48. package/kits/physics-3d/blueprints/platform.scene +23 -0
  49. package/kits/physics-3d/blueprints/player.scene +29 -0
  50. package/kits/physics-3d/blueprints/rock.scene +14 -0
  51. package/kits/physics-3d/blueprints/statue.scene +26 -0
  52. package/kits/physics-3d/blueprints/tree.scene +14 -0
  53. package/kits/physics-3d/blueprints/wall.scene +26 -0
  54. package/kits/{basic-2d → physics-3d}/castle.json +23 -5
  55. package/kits/physics-3d/docs/pxmodel-format.md +111 -0
  56. package/kits/physics-3d/drawings/crate.pxart +26 -0
  57. package/kits/physics-3d/drawings/door-edge.pxart +26 -0
  58. package/kits/physics-3d/drawings/door.pxart +27 -0
  59. package/kits/physics-3d/drawings/face.pxart +26 -0
  60. package/kits/physics-3d/drawings/floor.pxart +27 -0
  61. package/kits/physics-3d/drawings/platform.pxart +27 -0
  62. package/kits/physics-3d/drawings/statue.pxart +27 -0
  63. package/kits/physics-3d/drawings/wall.pxart +27 -0
  64. package/kits/physics-3d/engine3d/PlayOnly3D.jsx +36 -0
  65. package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
  66. package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
  67. package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
  68. package/kits/physics-3d/engine3d/editor3dData.js +114 -0
  69. package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
  70. package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
  71. package/kits/physics-3d/engine3d/editorWorld.js +249 -0
  72. package/kits/physics-3d/engine3d/materials.js +174 -0
  73. package/kits/physics-3d/engine3d/meshops.js +123 -0
  74. package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
  75. package/kits/physics-3d/engine3d/modelFiles.js +136 -0
  76. package/kits/physics-3d/engine3d/pxmodel.js +265 -0
  77. package/kits/physics-3d/engine3d/thumbnails.js +137 -0
  78. package/kits/physics-3d/engine3d/world3d.js +216 -0
  79. package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
  80. package/kits/physics-3d/index.html +20 -0
  81. package/kits/physics-3d/main.jsx +30 -0
  82. package/kits/physics-3d/models/barrel.pxmodel +10 -0
  83. package/kits/physics-3d/models/player.pxmodel +222 -0
  84. package/kits/physics-3d/models/rock.pxmodel +30 -0
  85. package/kits/physics-3d/models/tree.pxmodel +10 -0
  86. package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
  87. package/kits/{basic-2d → physics-3d}/package.json +11 -9
  88. package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
  89. package/kits/physics-3d/scenes/main.scene +398 -0
  90. package/kits/physics-3d/scenes/model-lab.scene +111 -0
  91. package/kits/physics-3d/systems/physics3d.js +356 -0
  92. package/package.json +5 -2
  93. package/dist/shell/assets/index-DKu9ejyh.js +0 -436
  94. package/kits/basic-2d/CLAUDE.md +0 -221
  95. package/kits/basic-2d/behaviors/Camera.jsx +0 -43
  96. package/kits/basic-2d/behaviors/Collider.jsx +0 -213
  97. package/kits/basic-2d/behaviors/Layout.jsx +0 -53
  98. package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
  99. package/kits/basic-2d/behaviors/tint.js +0 -47
  100. package/kits/basic-2d/blueprints/cauldron.scene +0 -20
  101. package/kits/basic-2d/docs/pxart-format.md +0 -377
  102. package/kits/basic-2d/drawings/cauldron.pxart +0 -113
  103. package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
  104. package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
  105. package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
  106. package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
  107. package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
  108. package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
  109. package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
  110. package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
  111. package/kits/basic-2d/editors/editorHistory.js +0 -157
  112. package/kits/basic-2d/editors/inspectorSheet.js +0 -13
  113. package/kits/basic-2d/editors/pixelCanvas.js +0 -11
  114. package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
  115. package/kits/basic-2d/editors/pixelGeometry.js +0 -140
  116. package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
  117. package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
  118. package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
  119. package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
  120. package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
  121. package/kits/basic-2d/editors/pxArtTools.js +0 -232
  122. package/kits/basic-2d/editors/useArtboardFit.js +0 -105
  123. package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
  124. package/kits/basic-2d/engine/SceneUI.jsx +0 -59
  125. package/kits/basic-2d/engine/assets.js +0 -15
  126. package/kits/basic-2d/engine/autoInspector.jsx +0 -70
  127. package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
  128. package/kits/basic-2d/engine/blueprint.js +0 -557
  129. package/kits/basic-2d/engine/collider.js +0 -200
  130. package/kits/basic-2d/engine/files.js +0 -141
  131. package/kits/basic-2d/engine/liveReload.js +0 -88
  132. package/kits/basic-2d/engine/pxart.js +0 -1032
  133. package/kits/basic-2d/engine/pxartSmooth.js +0 -222
  134. package/kits/basic-2d/engine/scene.js +0 -696
  135. package/kits/basic-2d/engine/spriteGeometry.js +0 -32
  136. package/kits/basic-2d/engine/systemRegistry.js +0 -16
  137. package/kits/basic-2d/engine/ui.jsx +0 -695
  138. package/kits/basic-2d/engine/ui.module.css +0 -2287
  139. package/kits/basic-2d/index.html +0 -24
  140. package/kits/basic-2d/main.jsx +0 -24
  141. package/kits/basic-2d/scenes/main.scene +0 -16
  142. package/kits/basic-2d/scripts/draw.mjs +0 -121
  143. /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
  144. /package/kits/{basic-2d → physics-3d}/vite.config.js +0 -0
@@ -132,14 +132,14 @@ export function PixelToolStrip({ tools, tool, onSelectTool, ariaLabel, disabled
132
132
  }
133
133
 
134
134
  // Compact W × H selects above the artboard (not a sidebar panel).
135
- export function CanvasSizeBar({ width, height, onResize }) {
135
+ export function CanvasSizeBar({ width, height, onResize, disabled = false }) {
136
136
  return (
137
137
  <div className={styles.canvasSizeBar} aria-label="Canvas size">
138
- <ResolutionSelect label="Width" value={width} onChange={(w) => onResize(w, height)} />
138
+ <ResolutionSelect label="Width" value={width} onChange={(w) => onResize(w, height)} disabled={disabled} />
139
139
  <span className={styles.canvasSizeSep} aria-hidden="true">
140
140
  ×
141
141
  </span>
142
- <ResolutionSelect label="Height" value={height} onChange={(h) => onResize(width, h)} />
142
+ <ResolutionSelect label="Height" value={height} onChange={(h) => onResize(width, h)} disabled={disabled} />
143
143
  </div>
144
144
  );
145
145
  }
@@ -175,13 +175,13 @@ const FINISH_STEPS = [
175
175
  // (properties of the .pxart asset — see docs/pxart-format.md — not per-actor
176
176
  // Sprite props). Highlights whichever preset the pair exactly matches, or none
177
177
  // when it matches no preset.
178
- export function FinishBar({ renderer, cornerRadius, onChange }) {
178
+ export function FinishBar({ renderer, cornerRadius, onChange, disabled = false }) {
179
179
  const isOn = (step) =>
180
180
  step.renderer === (renderer ?? GRID_RENDERER) && step.cornerRadius === (cornerRadius ?? 0);
181
181
  return (
182
182
  <div className={styles.finishBar} aria-label="Finish">
183
183
  <span>Finish</span>
184
- <div className={styles.finishSegments} role="radiogroup" aria-label="Finish">
184
+ <div className={styles.finishSegments} role="radiogroup" aria-label="Finish" aria-disabled={disabled || undefined}>
185
185
  {FINISH_STEPS.map((step) => (
186
186
  <button
187
187
  key={step.id}
@@ -189,6 +189,7 @@ export function FinishBar({ renderer, cornerRadius, onChange }) {
189
189
  role="radio"
190
190
  aria-label={step.label}
191
191
  aria-checked={isOn(step) ? 'true' : 'false'}
192
+ disabled={disabled}
192
193
  className={cx(styles.finishSegment, isOn(step) && styles.finishSegmentOn)}
193
194
  title={step.title}
194
195
  onClick={() =>
@@ -202,7 +203,7 @@ export function FinishBar({ renderer, cornerRadius, onChange }) {
202
203
  );
203
204
  }
204
205
 
205
- function ResolutionSelect({ label, value, onChange }) {
206
+ function ResolutionSelect({ label, value, onChange, disabled = false }) {
206
207
  const options = RESOLUTION_STEPS.includes(value)
207
208
  ? RESOLUTION_STEPS
208
209
  : [...RESOLUTION_STEPS, value].sort((a, b) => a - b);
@@ -211,6 +212,7 @@ function ResolutionSelect({ label, value, onChange }) {
211
212
  className={styles.canvasSizeSelect}
212
213
  aria-label={label}
213
214
  value={value}
215
+ disabled={disabled}
214
216
  onChange={(event) => onChange(Number(event.target.value))}>
215
217
  {options.map((step) => (
216
218
  <option key={step} value={step}>
@@ -940,10 +942,14 @@ export function PxArtInspectorDock({
940
942
  moveActions,
941
943
  selectActions,
942
944
  pathActions,
945
+ readOnly = false,
943
946
  }) {
944
947
  const { tool, picking, setPicking } = toolState;
945
948
  return (
946
- <aside className={cx(styles.pxArtInspector, styles.drawingToolsPanel)}>
949
+ <aside
950
+ className={cx(styles.pxArtInspector, styles.drawingToolsPanel, readOnly && styles.drawingReadOnly)}
951
+ aria-disabled={readOnly || undefined}
952
+ inert={readOnly || undefined}>
947
953
  <div className={cx(styles.sheetBody, styles.inspectorBody, styles.drawingSettingsColumn)}>
948
954
  {PALETTE_TOOLS.has(tool) || picking ? (
949
955
  <Panel
@@ -146,7 +146,7 @@ function useResizableTimelineHeight() {
146
146
  // the top, Aseprite-style) by frame COLUMNS, with thumbnails per cell, layer +
147
147
  // frame controls, linked-cell affordances, onion-skin toggles, playback, and a
148
148
  // tags editor. All mutation flows through `actions` (wired in PxArtEditor).
149
- export function PxArtTimeline({ sprite, text, selection, playing, onion, actions }) {
149
+ export function PxArtTimeline({ sprite, text, selection, playing, onion, actions, readOnly = false }) {
150
150
  // Hooks run unconditionally (order-stable); only the render branches on viewport.
151
151
  const compact = useCompactViewport();
152
152
  const resizable = useResizableTimelineHeight();
@@ -159,7 +159,13 @@ export function PxArtTimeline({ sprite, text, selection, playing, onion, actions
159
159
  });
160
160
  const grid = (
161
161
  <div className={tl.scroll}>
162
- <TimelineGrid sprite={sprite} text={text} selection={selection} actions={actions} />
162
+ <TimelineGrid
163
+ sprite={sprite}
164
+ text={text}
165
+ selection={selection}
166
+ actions={actions}
167
+ readOnly={readOnly}
168
+ />
163
169
  </div>
164
170
  );
165
171
  if (compact) {
@@ -178,6 +184,7 @@ export function PxArtTimeline({ sprite, text, selection, playing, onion, actions
178
184
  playing={playing}
179
185
  onion={onion}
180
186
  actions={actions}
187
+ readOnly={readOnly}
181
188
  />
182
189
  {grid}
183
190
  </section>
@@ -198,6 +205,7 @@ export function PxArtTimeline({ sprite, text, selection, playing, onion, actions
198
205
  playing={playing}
199
206
  onion={onion}
200
207
  actions={actions}
208
+ readOnly={readOnly}
201
209
  />
202
210
  {grid}
203
211
  {SHOW_TAGS ? <TagsPanel sprite={sprite} actions={actions} /> : null}
@@ -211,7 +219,7 @@ export function PxArtTimeline({ sprite, text, selection, playing, onion, actions
211
219
  // (duplicate/move/delete/rename/link/unlink/visibility) lives in the right-click
212
220
  // context menus on the grid's layer rows, frame headers, and cells (see
213
221
  // TimelineGrid + ContextMenu).
214
- function TimelineBar({ sprite, selection, playing, onion, actions }) {
222
+ function TimelineBar({ sprite, selection, playing, onion, actions, readOnly = false }) {
215
223
  const { layerIndex, frameIndex } = selection;
216
224
  // A single-frame sprite has no animation, so the three animation controls
217
225
  // (global duration, onion skin, play/pause) are meaningless — gate them on
@@ -227,19 +235,20 @@ function TimelineBar({ sprite, selection, playing, onion, actions }) {
227
235
  return (
228
236
  <div className={tl.bar}>
229
237
  <div className={tl.barGroup}>
230
- <IconButton icon="grid" label="Pixel" onClick={() => actions.addLayer(layerIndex)} />
238
+ <IconButton icon="grid" label="Pixel" disabled={readOnly} onClick={() => actions.addLayer(layerIndex)} />
231
239
  <span className={tl.barLabel}>Pixel</span>
232
240
  </div>
233
241
  <div className={tl.barGroup}>
234
242
  <IconButton
235
243
  icon="draw-polygon"
236
244
  label="Path"
245
+ disabled={readOnly}
237
246
  onClick={() => actions.addLayer(layerIndex, 'path')}
238
247
  />
239
248
  <span className={tl.barLabel}>Path</span>
240
249
  </div>
241
250
  <div className={tl.barGroup}>
242
- <IconButton icon="film" label="Frame" onClick={() => actions.addFrame(frameIndex)} />
251
+ <IconButton icon="film" label="Frame" disabled={readOnly} onClick={() => actions.addFrame(frameIndex)} />
243
252
  <span className={tl.barLabel}>Frame</span>
244
253
  </div>
245
254
  <div className={tl.barSpacer} />
@@ -249,7 +258,11 @@ function TimelineBar({ sprite, selection, playing, onion, actions }) {
249
258
  <span className={tl.durationIcon} title="Default frame duration (ms) — applies to all frames">
250
259
  <FaGlyph icon={faStopwatch} className={tl.durationGlyph} />
251
260
  </span>
252
- <GlobalDurationInput value={sprite.defaultDurationMs} onCommit={actions.setDefaultDuration} />
261
+ <GlobalDurationInput
262
+ value={sprite.defaultDurationMs}
263
+ onCommit={actions.setDefaultDuration}
264
+ disabled={readOnly}
265
+ />
253
266
  </div>
254
267
  <OnionControls onion={onion} actions={actions} />
255
268
  <div className={tl.barGroup}>
@@ -268,12 +281,13 @@ function TimelineBar({ sprite, selection, playing, onion, actions }) {
268
281
 
269
282
  // The sprite's GLOBAL duration. Commits a finite, positive value on blur/Enter;
270
283
  // an empty/invalid entry snaps back to the current value (see CommitInput).
271
- function GlobalDurationInput({ value, onCommit }) {
284
+ function GlobalDurationInput({ value, onCommit, disabled = false }) {
272
285
  return (
273
286
  <CommitInput
274
287
  text={String(value)}
275
288
  className={tl.globalDuration}
276
289
  title="Global frame duration (ms) — inherited by every frame without its own override"
290
+ disabled={disabled}
277
291
  onCommit={(draft) => {
278
292
  const ms = Number(draft);
279
293
  if (Number.isFinite(ms) && ms > 0) onCommit(ms);
@@ -322,7 +336,7 @@ function OnionControls({ onion, actions }) {
322
336
  );
323
337
  }
324
338
 
325
- function TimelineGrid({ sprite, text, selection, actions }) {
339
+ function TimelineGrid({ sprite, text, selection, actions, readOnly = false }) {
326
340
  const frames = sprite.frames;
327
341
  // Render rows top-down: the last layer (top of the composite stack) first.
328
342
  const rows = sprite.layers.map((_, i) => i).reverse();
@@ -353,12 +367,13 @@ function TimelineGrid({ sprite, text, selection, actions }) {
353
367
  const openMenu = useCallback(
354
368
  (target, event) => {
355
369
  selectTarget(target);
370
+ if (readOnly) return;
356
371
  const rect = event.currentTarget?.getBoundingClientRect?.();
357
372
  const x = Number.isFinite(event.clientX) ? event.clientX : rect?.left + rect?.width / 2;
358
373
  const y = Number.isFinite(event.clientY) ? event.clientY : rect?.top + rect?.height / 2;
359
374
  setMenu({ ...target, x: x ?? 0, y: y ?? 0 });
360
375
  },
361
- [selectTarget]
376
+ [selectTarget, readOnly]
362
377
  );
363
378
  const press = useTimelineItemPress(openMenu);
364
379
  const activateTarget = (target, event) => {
@@ -412,6 +427,7 @@ function TimelineGrid({ sprite, text, selection, actions }) {
412
427
  onPointerDown={(event) => press.onPointerDown(event, { kind: 'layer', layerIndex: li })}
413
428
  actions={actions}
414
429
  onContextMenu={openLayerMenu}
430
+ readOnly={readOnly}
415
431
  />
416
432
  {frames.map((_, f) => (
417
433
  <CellThumb
@@ -744,7 +760,7 @@ const kindBadgeStyle = {
744
760
  // on blur/Enter, re-syncing to `text` when idle. The caller's `onCommit(draft)`
745
761
  // interprets the raw string (set / clear / revert). Shared by the per-frame and
746
762
  // global duration fields so their edit/commit behavior stays identical.
747
- function CommitInput({ text, className, placeholder, title, onCommit }) {
763
+ function CommitInput({ text, className, placeholder, title, onCommit, disabled = false }) {
748
764
  const [draft, setDraft] = useState(text);
749
765
  const editing = useRef(false);
750
766
  if (!editing.current && draft !== text) setDraft(text);
@@ -759,6 +775,7 @@ function CommitInput({ text, className, placeholder, title, onCommit }) {
759
775
  placeholder={placeholder}
760
776
  title={title}
761
777
  inputMode="numeric"
778
+ disabled={disabled}
762
779
  onFocus={() => (editing.current = true)}
763
780
  onChange={(event) => setDraft(event.target.value)}
764
781
  onBlur={commit}
@@ -807,7 +824,7 @@ function FaGlyph({ icon, className = tl.visGlyph }) {
807
824
  );
808
825
  }
809
826
 
810
- function LayerHeader({ layer, index, active, onActivate, onPointerDown, actions, onContextMenu }) {
827
+ function LayerHeader({ layer, index, active, onActivate, onPointerDown, actions, onContextMenu, readOnly = false }) {
811
828
  return (
812
829
  <div
813
830
  className={active ? tl.layerHead + ' ' + tl.layerHeadActive : tl.layerHead}
@@ -832,6 +849,7 @@ function LayerHeader({ layer, index, active, onActivate, onPointerDown, actions,
832
849
  max="100"
833
850
  value={Math.round(layer.opacity * 100)}
834
851
  title={`Opacity ${Math.round(layer.opacity * 100)}%`}
852
+ disabled={readOnly}
835
853
  onClick={(event) => event.stopPropagation()}
836
854
  onChange={(event) => actions.patchLayer(index, { opacity: Number(event.target.value) / 100 })}
837
855
  />
@@ -108,6 +108,12 @@
108
108
  text-align: center;
109
109
  }
110
110
 
111
+ .globalDuration:disabled {
112
+ opacity: 0.55;
113
+ cursor: default;
114
+ color: var(--castle-inspector-muted);
115
+ }
116
+
111
117
  /* Icon-only label (stopwatch) sitting next to the global duration input, in
112
118
  place of the old "Duration … ms · all frames" text. */
113
119
  .durationIcon {
@@ -333,6 +339,11 @@
333
339
  accent-color: var(--castle-selected);
334
340
  }
335
341
 
342
+ .opacity:disabled {
343
+ opacity: 0.4;
344
+ cursor: default;
345
+ }
346
+
336
347
  .opacity::-webkit-slider-runnable-track {
337
348
  height: 3px;
338
349
  border-radius: 2px;
@@ -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, files, behaviorClasses, onFirstFrame }) {
9
+ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, scenePath, onFirstFrame }) {
10
10
  const canvasRef = useRef(null);
11
11
  const runtimeRef = useRef(null);
12
12
  // An error thrown from a behavior's update/draw kills the raf loop (it can't
@@ -22,7 +22,7 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
22
22
  const ctx = canvas.getContext('2d');
23
23
  if (!ctx) return undefined;
24
24
  configureSceneCanvas(canvas, ctx);
25
- const runtime = makeScene(sceneData, behaviorClasses, sprites, files).clone();
25
+ const runtime = makeScene(sceneData, behaviorClasses, sprites, files, scenePath).clone();
26
26
  runtimeRef.current = runtime;
27
27
  // Store BOTH the physical code ('KeyX', 'ArrowLeft', 'Space') and the
28
28
  // logical key ('x', 'ArrowLeft', ' ') so behaviors can match either. Codes
@@ -69,23 +69,41 @@ function parseBlueprintText(text) {
69
69
  function resolveTemplateRefs(template, files, fromPath) {
70
70
  if (!template) return template;
71
71
  for (const props of Object.values(template.components ?? {})) {
72
- for (const [key, value] of Object.entries(props ?? {})) {
73
- if (typeof value !== 'string' || !value) continue;
72
+ resolveRefsIn(props, files, fromPath);
73
+ }
74
+ return template;
75
+ }
76
+
77
+ // Nested because a ref is not always a bare prop: `Shape.art` can be one string
78
+ // for the whole shape OR an object naming a file per face, and an unresolved
79
+ // face ref means an untextured surface with no error anywhere. Rewriting only
80
+ // when the resolved path is actually in the file map is what keeps this from
81
+ // mangling strings that were never refs.
82
+ function resolveRefsIn(props, files, fromPath) {
83
+ for (const [key, value] of Object.entries(props ?? {})) {
84
+ if (typeof value === 'string' && value) {
74
85
  const resolved = resolveDeckFile(value, fromPath);
75
86
  if (resolved !== value && files?.[resolved] !== undefined) props[key] = resolved;
87
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
88
+ resolveRefsIn(value, files, fromPath);
76
89
  }
77
90
  }
78
- return template;
79
91
  }
80
92
 
81
93
  // Resolve a blueprint's template from the LIVE files map -- the load-bearing
82
94
  // call for live propagation. Returns null when the file is missing, deleted,
83
95
  // or malformed (a dangling `blueprint` ref degrades to "no template" rather
84
96
  // than throwing).
85
- export function getBlueprintTemplate(files, blueprintPath) {
97
+ export function getBlueprintTemplate(files, blueprintPath, fromPath) {
86
98
  // The ref may name this deck's blueprint or an import's; the template's own
87
99
  // refs then resolve against whichever deck the blueprint turned out to be in.
88
- const key = resolveDeckFile(blueprintPath);
100
+ //
101
+ // `fromPath` is the file the ref was WRITTEN in, and it matters as soon as a
102
+ // kit is opened as an import: a scene at `imports/<alias>/scenes/main.scene`
103
+ // says `blueprints/wall.scene` meaning its own deck's, and resolving that
104
+ // against the deck root finds nothing -- every actor silently loses its
105
+ // template and renders as an untextured default.
106
+ const key = resolveDeckFile(blueprintPath, fromPath);
89
107
  const template = parseBlueprintText(files?.[key]);
90
108
  return resolveTemplateRefs(template, files, key);
91
109
  }
@@ -1,13 +1,13 @@
1
- // Consumer side of the serve's `files_changed` broadcast. The engine decides
2
- // what a change means for this bundle:
3
- // - data files (.scene / .pxart / .sprite / .style / .drawing) are re-read
4
- // over the serve's files API and folded into React state — live re-render,
5
- // no reload.
6
- // - code the bundle imports (per the event's module-graph closure), html,
7
- // or added/deleted code files (import.meta.glob may pick them up) mean
8
- // this context is stale requestReload() (save-state hooks run first).
1
+ // Consumer side of the serve's `files_changed` broadcast. Data files are
2
+ // re-read over the serve's files API and folded into React state, so an editor
3
+ // shows a drawing someone else just changed without reloading anything.
4
+ //
5
+ // A CODE change does nothing here. Only a page reload picks up new code, and a
6
+ // reload thrown at a panel someone is working in loses their place, so the
7
+ // decision belongs to the person: every panel header carries a reload control,
8
+ // and the shell offers the Play panel a restart when it has fallen behind.
9
9
  import { useEffect, useRef, useState } from 'react';
10
- import { onFilesChanged, requestReload } from 'castle-web-sdk';
10
+ import { onFilesChanged } from 'castle-web-sdk';
11
11
  import { initialFiles } from './files';
12
12
 
13
13
  const DATA_EXTS = ['.scene', '.pxart', '.sprite', '.style', '.drawing'];
@@ -17,7 +17,20 @@ export function isDataFile(path) {
17
17
  return DATA_EXTS.some((ext) => path.endsWith(ext));
18
18
  }
19
19
 
20
- function needsContextReload(change) {
20
+ // Which iframe is speaking -- every panel runs its own copy of this.
21
+ function panelTag() {
22
+ try {
23
+ const q = new URLSearchParams(location.search);
24
+ return q.get('panel') || q.get('file') || 'deck';
25
+ } catch {
26
+ return 'deck';
27
+ }
28
+ }
29
+
30
+ // Whether a change means this bundle's code is stale. Only used for the log
31
+ // line -- nothing acts on it -- but knowing a panel went stale and when is what
32
+ // makes a vague "it looked wrong" report checkable against `.castle/serve.log`.
33
+ function isCodeChange(change) {
21
34
  if (isDataFile(change.path)) return false;
22
35
  if (change.path.endsWith('.html')) return true;
23
36
  // In the module graph (directly or as someone's import) -> our code changed.
@@ -37,34 +50,34 @@ async function readDeckFile(path) {
37
50
  // Deck files as live state: starts from the build-time glob snapshot and stays
38
51
  // current as files change on disk from any source (editors, agents, terminal).
39
52
  // `shouldSkipPath` lets an editor keep its own in-flight edits from being
40
- // clobbered by the fs echo of a stale write. `dataVersion` bumps on every
41
- // applied data change — key a player on it to rebuild from fresh data.
53
+ // clobbered by the fs echo of a stale write.
42
54
  export function useLiveDeckFiles({ shouldSkipPath } = {}) {
43
55
  const [files, setFiles] = useState(initialFiles);
44
- const [dataVersion, setDataVersion] = useState(0);
45
56
  const skipRef = useRef(shouldSkipPath);
46
57
  skipRef.current = shouldSkipPath;
47
58
 
48
59
  useEffect(
49
60
  () =>
50
61
  onFilesChanged((event) => {
51
- if (event.changes.some(needsContextReload)) {
52
- requestReload();
53
- return;
54
- }
62
+ const stale = event.changes.find(isCodeChange);
63
+ if (stale) console.log(`[reload] ${panelTag()} code stale <- ${stale.path}`);
64
+ // Data still applies in the same event: a change that carries both is
65
+ // one edit, and holding the data back would leave the panel showing
66
+ // neither the old state nor the new one.
55
67
  const dataChanges = event.changes.filter(
56
68
  (change) => isDataFile(change.path) && !skipRef.current?.(change.path)
57
69
  );
58
70
  if (dataChanges.length === 0) return;
59
- void applyDataChanges(dataChanges, setFiles, setDataVersion);
71
+ console.log(`[reload] ${panelTag()} data <- ${dataChanges.map((c) => c.path).join(', ')}`);
72
+ void applyDataChanges(dataChanges, setFiles);
60
73
  }),
61
74
  []
62
75
  );
63
76
 
64
- return { files, setFiles, dataVersion };
77
+ return { files, setFiles };
65
78
  }
66
79
 
67
- async function applyDataChanges(changes, setFiles, setDataVersion) {
80
+ async function applyDataChanges(changes, setFiles) {
68
81
  const updates = await Promise.all(
69
82
  changes.map(async (change) => {
70
83
  if (change.event === 'delete') return { path: change.path, text: null };
@@ -85,5 +98,4 @@ async function applyDataChanges(changes, setFiles, setDataVersion) {
85
98
  }
86
99
  return next;
87
100
  });
88
- setDataVersion((version) => version + 1);
89
101
  }
@@ -35,7 +35,12 @@ export class SceneRuntime {
35
35
  // actor's `blueprint` ref against the CURRENT blueprint file content -- not
36
36
  // a load-time snapshot -- so a blueprint edit propagates to every scene that
37
37
  // places it, in both the editor and play mode / `PlayOnly`.
38
- constructor(sceneData, behaviors, sprites, files) {
38
+ constructor(sceneData, behaviors, sprites, files, scenePath) {
39
+ // The file this scene was read from, so blueprint refs in it resolve the
40
+ // way its own deck writes them. Undefined when a caller hands over scene
41
+ // data with no file behind it; refs then resolve against the deck root,
42
+ // which is what they meant before this existed.
43
+ this.scenePath = scenePath;
39
44
  this.behaviors = new Map(behaviors.map((Behavior) => [Behavior.behaviorName, Behavior]));
40
45
  this.sprites = sprites ?? {};
41
46
  this.files = files ?? {};
@@ -87,7 +92,7 @@ export class SceneRuntime {
87
92
  for (const actor of this.data.actors ?? []) {
88
93
  actor.runtime = {};
89
94
  if (actor.blueprint) {
90
- const template = getBlueprintTemplate(this.files, actor.blueprint);
95
+ const template = getBlueprintTemplate(this.files, actor.blueprint, this.scenePath);
91
96
  actor.components = mergeComponents(template?.components, actor.components);
92
97
  }
93
98
  this.actors.set(actor.id, actor);
@@ -120,13 +125,20 @@ export class SceneRuntime {
120
125
  // Transition the running scene to the scene stored in the named file (read it
121
126
  // fresh, then load it). The building block for "go to scene" / restart.
122
127
  loadFromFile(name) {
128
+ this.scenePath = resolveSceneFileKey(name);
123
129
  this.load(this.readFromFile(name));
124
130
  }
125
131
 
126
132
  clone() {
127
133
  // Route through makeScene so the clone gets the same registered systems as a
128
134
  // freshly-made runtime.
129
- return makeScene(this.serialize(), [...this.behaviors.values()], this.sprites, this.files);
135
+ return makeScene(
136
+ this.serialize(),
137
+ [...this.behaviors.values()],
138
+ this.sprites,
139
+ this.files,
140
+ this.scenePath
141
+ );
130
142
  }
131
143
 
132
144
  serialize() {
@@ -426,8 +438,8 @@ export class SceneRuntime {
426
438
  }
427
439
  }
428
440
 
429
- export function makeScene(sceneData, behaviors, sprites, files) {
430
- const runtime = new SceneRuntime(sceneData, behaviors, sprites, files);
441
+ export function makeScene(sceneData, behaviors, sprites, files, scenePath) {
442
+ const runtime = new SceneRuntime(sceneData, behaviors, sprites, files, scenePath);
431
443
  // Install any runtime systems the kit provides (systems/*.js) -- no-op in a kit
432
444
  // with none. A system's engine is created lazily on first use, so draw-only
433
445
  // editor previews (which never call update) stay free.
@@ -3,7 +3,7 @@
3
3
  // SceneRuntime (see engine/scene.js `makeScene`) to register itself via
4
4
  // `runtime.registerSystem(...)`. A system is a plain object with an optional
5
5
  // `afterBehaviors(scene, dt)` hook, run once per frame after all behavior
6
- // updates. Empty in a kit with no `systems/` dir (e.g. basic-2d); a kit adds a
6
+ // updates. Empty in a kit with no `systems/` dir; a kit adds a
7
7
  // system by dropping a file here -- no edits to the engine required. Symmetric
8
8
  // with editors/behaviorRegistry.js.
9
9
  // Root-anchored, deck + imports (see engine/files.js).
@@ -740,8 +740,8 @@ function settleSheet(drag, setHeight, expandedRef) {
740
740
  const settled = setHeight(drag.startHeight - (drag.lastY - drag.startY));
741
741
  if (settled > peek + 2) expandedRef.current = settled;
742
742
  }
743
- // Persist a sheet's resting height across reloads (an agent's CODE edit
744
- // triggers requestReload, which would otherwise reset every sheet to the
743
+ // Persist a sheet's resting height across reloads (picking up a code change
744
+ // means reloading the panel, which would otherwise reset every sheet to the
745
745
  // default size). Keyed per sheet via `storageKey`; sessionStorage-scoped and
746
746
  // best-effort, the same degrade-to-memory-only contract as editorHistory.
747
747
  function sheetHeightStorageKey(key) {
@@ -1550,6 +1550,11 @@
1550
1550
  cursor: default;
1551
1551
  }
1552
1552
 
1553
+ .button:disabled:hover,
1554
+ .iconButton:disabled:hover {
1555
+ background: var(--castle-inspector-button-bg);
1556
+ }
1557
+
1553
1558
  .actorList {
1554
1559
  display: grid;
1555
1560
  gap: 4px;
@@ -1608,6 +1613,13 @@
1608
1613
  display: none;
1609
1614
  }
1610
1615
 
1616
+ /* Read-only paint/inspector chrome: still there so the layout doesn't jump,
1617
+ but clearly not interactive. */
1618
+ .drawingReadOnly {
1619
+ opacity: 0.45;
1620
+ pointer-events: none;
1621
+ }
1622
+
1611
1623
  /* Stack the tool rail and the undo/redo rail vertically (the shared rule above is
1612
1624
  a flex row, which pushed the second rail out of the 50px column). */
1613
1625
  .artboardToolColumn {
@@ -1721,6 +1733,22 @@
1721
1733
  color: var(--castle-selected-ink);
1722
1734
  }
1723
1735
 
1736
+ .finishSegment:disabled {
1737
+ cursor: default;
1738
+ opacity: 0.4;
1739
+ }
1740
+
1741
+ .finishSegment:disabled:hover {
1742
+ background: var(--castle-inspector-button-bg);
1743
+ }
1744
+
1745
+ .finishSegment.finishSegmentOn:disabled,
1746
+ .finishSegment.finishSegmentOn:disabled:hover {
1747
+ opacity: 0.85;
1748
+ background: var(--castle-selected);
1749
+ color: var(--castle-selected-ink);
1750
+ }
1751
+
1724
1752
  .canvasSizeSep {
1725
1753
  color: var(--castle-inspector-muted);
1726
1754
  user-select: none;
@@ -1742,6 +1770,12 @@
1742
1770
  background-position: right 6px center;
1743
1771
  }
1744
1772
 
1773
+ .canvasSizeSelect:disabled {
1774
+ opacity: 0.55;
1775
+ cursor: default;
1776
+ color: var(--castle-inspector-muted);
1777
+ }
1778
+
1745
1779
  .pxArtInspector {
1746
1780
  min-width: 0;
1747
1781
  border-left: 1px solid var(--castle-inspector-divider);
@@ -2121,6 +2155,14 @@
2121
2155
  cursor: default;
2122
2156
  }
2123
2157
 
2158
+ .drawingToolButton.drawingToolSelected:disabled {
2159
+ opacity: 0.7;
2160
+ }
2161
+
2162
+ .drawingToolButton:disabled:hover {
2163
+ background: var(--castle-inspector-button-bg);
2164
+ }
2165
+
2124
2166
  /* Active / selected square icon button -- the eyedropper reference: a light
2125
2167
  fill with a dark glyph. The single selected/engaged look shared by the
2126
2168
  IconButton component (`active`), the onion-skin toggle, and the pixel