castle-web-cli 0.4.81 → 0.4.83

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 (40) hide show
  1. package/dist/agent-prompts.d.ts +0 -3
  2. package/dist/agent-prompts.js +3 -8
  3. package/dist/agent.d.ts +1 -2
  4. package/dist/agent.js +327 -157
  5. package/dist/castle-host/host.js +28 -0
  6. package/dist/ide.js +150 -1
  7. package/dist/init.js +1 -1
  8. package/dist/native/loop.js +15 -29
  9. package/dist/native/openrouter.d.ts +5 -1
  10. package/dist/native/openrouter.js +20 -1
  11. package/dist/native/tools.d.ts +0 -1
  12. package/dist/native/tools.js +3 -79
  13. package/dist/native/types.d.ts +4 -1
  14. package/dist/native/types.js +3 -3
  15. package/dist/shell/assets/index-BMkQt27u.css +1 -0
  16. package/dist/shell/assets/index-C9Zhmien.js +142 -0
  17. package/dist/shell/index.html +2 -2
  18. package/dist/shell/operator.png +0 -0
  19. package/kits/basic-2d/CLAUDE.md +27 -22
  20. package/kits/basic-2d/behaviors/Collider.jsx +24 -30
  21. package/kits/basic-2d/behaviors/Layout.jsx +9 -6
  22. package/kits/basic-2d/behaviors/Sprite.jsx +137 -7
  23. package/kits/basic-2d/blueprints/cauldron.scene +3 -5
  24. package/kits/basic-2d/editors/BlueprintLibrary.jsx +11 -11
  25. package/kits/basic-2d/editors/SceneEditor.jsx +212 -50
  26. package/kits/basic-2d/editors/SelectionOverlay.jsx +73 -54
  27. package/kits/basic-2d/editors/inspectorSheet.js +5 -1
  28. package/kits/basic-2d/engine/ScenePlayer.jsx +102 -7
  29. package/kits/basic-2d/engine/autoInspector.jsx +26 -7
  30. package/kits/basic-2d/engine/blueprint.js +109 -11
  31. package/kits/basic-2d/engine/collider.js +146 -0
  32. package/kits/basic-2d/engine/scene.js +53 -30
  33. package/kits/basic-2d/engine/spriteGeometry.js +32 -0
  34. package/kits/basic-2d/engine/ui.jsx +89 -30
  35. package/kits/basic-2d/engine/ui.module.css +157 -53
  36. package/kits/basic-2d/scenes/main.scene +3 -3
  37. package/package.json +2 -1
  38. package/dist/shell/assets/index-D3unT7do.js +0 -141
  39. package/dist/shell/assets/index-RZrw5gQ2.css +0 -1
  40. package/kits/basic-2d/pnpm-workspace.yaml +0 -3
@@ -7,7 +7,7 @@ import { cx, Icon, styles, useElementSize } from '../engine/ui';
7
7
  // as a sibling of the `<canvas>` and mirrors the `SceneUI` overlay approach:
8
8
  //
9
9
  // - A ResizeObserver measures the canvas-sized box so a card-unit (500x700)
10
- // layer can be `scale()`d onto it -- the chrome (dashed box, corner dots,
10
+ // layer can be `scale()`d onto it -- the chrome (solid box, corner dots,
11
11
  // center pivot) lives in card units inside that scaled layer and is
12
12
  // translated by -camera so it tracks the edit camera exactly like the canvas
13
13
  // draw path (`ctx.translate(-camera.x, -camera.y)`).
@@ -27,6 +27,11 @@ const ACTION_GAP_PX = 22; // px from a box edge to clone/delete buttons
27
27
  const ROTATE_STEP = 15; // degrees -- rotation snaps to this increment when grid snap is on
28
28
  const ROTATE_MAGNET = 7; // degrees -- within this band of the pre-drag angle, snap back to it exactly
29
29
  const MIN_LAYOUT_SIZE = 4; // card units; prevents inverted / zero-size Layout boxes
30
+ // Below this on-screen box extent (px) along an axis, the mid-edge scale handle
31
+ // for that axis would collide with the corner handles, so we drop it and keep
32
+ // only the corners. Gated per-axis: n/s depend on the box's px width, e/w on its
33
+ // px height (a wide, short box can keep n/s while dropping e/w, and vice versa).
34
+ const EDGE_HANDLE_MIN_PX = 40;
30
35
  // Move/grab handle is intentionally hidden for now (its drag logic + helpers
31
36
  // stay wired below so it can be re-enabled later); flip this to render it.
32
37
  const SHOW_MOVE_HANDLE = false;
@@ -61,6 +66,7 @@ export function SelectionOverlay({
61
66
  editCameraRef,
62
67
  sceneData,
63
68
  previewSceneData,
69
+ sprites,
64
70
  selectedActorIds,
65
71
  snap,
66
72
  onArrange,
@@ -107,14 +113,14 @@ export function SelectionOverlay({
107
113
  if (!canvas || !sceneData || selectedActorIds.length === 0) return null;
108
114
  const startPoint = screenToCard(canvas, event.clientX, event.clientY);
109
115
  const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
110
- const state = { recorded: false };
116
+ const state = { recorded: false, lastKey: '0,0' };
111
117
  return {
112
118
  onMove: (moveEvent) => {
113
119
  const point = screenToCard(canvas, moveEvent.clientX, moveEvent.clientY);
114
120
  const dx = snapDelta(point.x - startPoint.x, snap);
115
121
  const dy = snapDelta(point.y - startPoint.y, snap);
116
122
  const next = moveSelected(sceneData, previewSceneData, selectedActorIds, starts, dx, dy);
117
- applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
123
+ applyDragResult(next, sceneData, `${dx},${dy}`, state, recordSnapshot, applyScene);
118
124
  },
119
125
  };
120
126
  });
@@ -139,11 +145,11 @@ export function SelectionOverlay({
139
145
  // pointer-down snapshot. `onMove` closes over the `sceneData` captured at
140
146
  // drag-start, where the actor still sits at its original angle, so when
141
147
  // the magnet pulls `delta` back to 0 `rotateSelected` returns that
142
- // unchanged snapshot. `applyDragResult`'s `next === sceneData` guard
143
- // would treat that as a no-op and skip it, stranding the actor on the
144
- // last grid step -- the reason returning to the original angle felt
145
- // impossible. Re-applying whenever the delta changes lets delta 0
146
- // actually restore the start angle.
148
+ // unchanged snapshot; an identity guard would treat that as a no-op and
149
+ // skip it, stranding the actor on the last grid step -- the reason
150
+ // returning to the original angle felt impossible. Re-applying whenever
151
+ // the delta changes lets delta 0 actually restore the start angle.
152
+ // (Scale/move share this contract via `applyDragResult`'s `key` param.)
147
153
  if (delta === state.lastDelta) return;
148
154
  state.lastDelta = delta;
149
155
  setGroupFrameRotation(normalizeAngle(startFrameRotation + delta));
@@ -165,7 +171,7 @@ export function SelectionOverlay({
165
171
  const startTransform = makeBoundsTransform(frame, frame.rotation);
166
172
  const startPointer = pointerLocal(canvas, editCameraRef, event, startTransform);
167
173
  const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
168
- const state = { recorded: false };
174
+ const state = { recorded: false, lastKey: '0,0,0' };
169
175
  return {
170
176
  onMove: (moveEvent) => {
171
177
  const point = pointerLocal(canvas, editCameraRef, moveEvent, startTransform);
@@ -179,7 +185,8 @@ export function SelectionOverlay({
179
185
  moveEvent.shiftKey
180
186
  );
181
187
  const next = scaleSelected(sceneData, previewSceneData, selectedActorIds, starts, startTransform, nextBounds);
182
- applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
188
+ // Include shift (uniform) in the key so toggling it without moving still re-applies.
189
+ applyDragResult(next, sceneData, `${dx},${dy},${moveEvent.shiftKey ? 1 : 0}`, state, recordSnapshot, applyScene);
183
190
  },
184
191
  };
185
192
  });
@@ -190,7 +197,7 @@ export function SelectionOverlay({
190
197
  }
191
198
 
192
199
  const geometry = getOverlayGeometry(frame, box, camera);
193
- const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds);
200
+ const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds, sprites);
194
201
 
195
202
  return (
196
203
  <div ref={rootRef} className={styles.selOverlayRoot}>
@@ -221,17 +228,6 @@ export function SelectionOverlay({
221
228
  }}
222
229
  />
223
230
  ))}
224
- {/* Rotate about the actor center, in card units, exactly like the
225
- canvas. Sits between the scale + camera-translate layers and the
226
- card-unit chrome. */}
227
- <div
228
- className={styles.selChromeRotate}
229
- style={{
230
- transformOrigin: `${geometry.centerCardX}px ${geometry.centerCardY}px`,
231
- transform: `rotate(${geometry.rotation}deg)`,
232
- }}>
233
- <SelectionChrome frame={frame} stemLength={geometry.stemLength} />
234
- </div>
235
231
  </div>
236
232
  </div>
237
233
 
@@ -239,11 +235,21 @@ export function SelectionOverlay({
239
235
  readable. The rotate handle orbits via its anchor; clone/delete flip
240
236
  above/below to stay clear of it. */}
241
237
  <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. */}
238
+ {/* Solid bounding box, connector stem, and center pivot render here
239
+ (un-scaled px layer) rather than the chrome layer, so their stroke
240
+ width and pivot size stay constant with zoom. Their px dimensions
241
+ are projected from the box's screen extents, so the box still traces
242
+ the actual bounds. The stem is anchored at the box's rotated
243
+ right-center edge and rotates about that point out to the handle. */}
244
+ <div
245
+ className={styles.selStem}
246
+ style={{
247
+ left: geometry.stemStart.x,
248
+ top: geometry.stemStart.y,
249
+ width: geometry.stemLengthPx,
250
+ transform: `translateY(-50%) rotate(${geometry.rotation}deg)`,
251
+ }}
252
+ />
247
253
  <div
248
254
  className={styles.selBox}
249
255
  style={{
@@ -332,22 +338,6 @@ export function SelectionOverlay({
332
338
  );
333
339
  }
334
340
 
335
- // Chrome in card units: dashed bounding box, scale handles, connector stem, and
336
- // center pivot dot. The layer itself ignores pointer events; only the handles
337
- // opt back in so the canvas still receives ordinary selection gestures.
338
- function SelectionChrome({ frame, stemLength }) {
339
- const left = frame.x;
340
- const top = frame.y;
341
- const width = frame.width;
342
- const height = frame.height;
343
- const centerY = top + height / 2;
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 }} />;
349
- }
350
-
351
341
  // `sceneData` here is always the MERGED preview scene (see the note on
352
342
  // `SelectionOverlay` above) -- every Layout read in this file is.
353
343
  function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
@@ -381,20 +371,28 @@ function getOverlayGeometry(frame, box, camera) {
381
371
  const halfW = (frame.width / 2 + HANDLE_OFFSET) * sx;
382
372
  const halfH = (frame.height / 2 + HANDLE_OFFSET) * sy;
383
373
  const rotateAnchor = getRotateAnchor({ centerX, centerY, halfW, rotation });
374
+ // Stem starts at the box's rotated right-center edge and runs out to the
375
+ // rotate handle. Projected to screen px (like the box + handles) so its
376
+ // thickness stays constant with zoom instead of scaling with the chrome layer.
377
+ const rad = (rotation * Math.PI) / 180;
378
+ const halfWpx = (frame.width / 2) * sx;
379
+ const stemStart = {
380
+ x: centerX + halfWpx * Math.cos(rad),
381
+ y: centerY + halfWpx * Math.sin(rad),
382
+ };
384
383
  return {
385
384
  sx,
386
385
  sy,
387
386
  camX,
388
387
  camY,
389
388
  rotation,
390
- centerCardX,
391
- centerCardY,
392
389
  centerX,
393
390
  centerY,
394
391
  halfH,
395
392
  rotateAnchor,
396
393
  cloneAnchor: getActionAnchor({ centerX, centerY, halfH, rotateAnchor }),
397
- stemLength: HANDLE_OFFSET + GAP_PX / sx,
394
+ stemStart,
395
+ stemLengthPx: Math.hypot(rotateAnchor.x - stemStart.x, rotateAnchor.y - stemStart.y),
398
396
  boxWidthPx: frame.width * sx,
399
397
  boxHeightPx: frame.height * sy,
400
398
  scaleHandles: getScaleHandleAnchors({
@@ -407,13 +405,23 @@ function getOverlayGeometry(frame, box, camera) {
407
405
  };
408
406
  }
409
407
 
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.
408
+ // Project the scale handles from the box's rotated corners/edges into screen px
409
+ // so they can render in the un-scaled button layer at a constant size. Corners
410
+ // are always shown; mid-edge handles are dropped once the box is too small along
411
+ // their axis (see EDGE_HANDLE_MIN_PX) so they don't overlap the corners.
412
412
  function getScaleHandleAnchors({ centerX, centerY, halfWpx, halfHpx, rotation }) {
413
413
  const rad = (rotation * Math.PI) / 180;
414
414
  const cosR = Math.cos(rad);
415
415
  const sinR = Math.sin(rad);
416
- return SCALE_HANDLES.map((handle) => {
416
+ const boxWidthPx = halfWpx * 2;
417
+ const boxHeightPx = halfHpx * 2;
418
+ return SCALE_HANDLES.filter((handle) => {
419
+ if (handle.corner) return true;
420
+ // n/s run along the box's width; e/w run along its height.
421
+ return handle.x === 0
422
+ ? boxWidthPx >= EDGE_HANDLE_MIN_PX
423
+ : boxHeightPx >= EDGE_HANDLE_MIN_PX;
424
+ }).map((handle) => {
417
425
  const lx = handle.x * halfWpx;
418
426
  const ly = handle.y * halfHpx;
419
427
  return {
@@ -447,7 +455,7 @@ function getActionAnchor({ centerX, centerY, halfH, rotateAnchor }) {
447
455
  };
448
456
  }
449
457
 
450
- function getSelectedColliderFrames(sceneData, actorIds) {
458
+ function getSelectedColliderFrames(sceneData, actorIds, sprites) {
451
459
  if (!sceneData || !actorIds || actorIds.length === 0) return [];
452
460
  const wanted = new Set(actorIds);
453
461
  return sceneData.actors
@@ -455,7 +463,7 @@ function getSelectedColliderFrames(sceneData, actorIds) {
455
463
  .map((actor) => {
456
464
  const layout = actor.components.Layout;
457
465
  const collider = actor.components.Collider;
458
- const rect = getColliderRect(actor);
466
+ const rect = getColliderRect(actor, sprites);
459
467
  if (!layout || !collider || !rect) return null;
460
468
  return {
461
469
  actorId: actor.id,
@@ -598,9 +606,20 @@ function usePointerDragHandle(begin) {
598
606
  }, []);
599
607
  }
600
608
 
601
- function applyDragResult(next, sceneData, state, recordSnapshot, applyScene) {
602
- if (next === sceneData) return;
603
- if (!state.recorded) {
609
+ // Gate the commit on the gesture's snapped input (`key`), not on
610
+ // `next === sceneData`. During a drag the handlers compute `next` relative to
611
+ // the pointer-down `sceneData`, so returning the handle to its start value
612
+ // makes `next` identity-equal to that snapshot -- an identity guard would treat
613
+ // that as a no-op and skip `applyScene`, stranding the actor on the last grid
614
+ // step (the reason handles couldn't be dragged back to their origin). Keying on
615
+ // the snapped input re-applies whenever it changes, including back to the start
616
+ // key, so the origin is reachable. The identity check now only decides whether
617
+ // to record the undo snapshot, so a pure no-op drag never touches history. This
618
+ // mirrors the rotate handle's delta-keyed commit.
619
+ function applyDragResult(next, sceneData, key, state, recordSnapshot, applyScene) {
620
+ if (key === state.lastKey) return;
621
+ state.lastKey = key;
622
+ if (next !== sceneData && !state.recorded) {
604
623
  recordSnapshot();
605
624
  state.recorded = true;
606
625
  }
@@ -5,5 +5,9 @@ import { styles, useMobileSheet } from '../engine/ui';
5
5
  // visibility; the sheet hook owns the resize height and the settle-on-release
6
6
  // behavior (drag the grab handle to resize, tap it to toggle peek / expanded).
7
7
  export function useInspectorSheet(inspectorOpen) {
8
- return useMobileSheet({ open: inspectorOpen, baseClassName: styles.inspector });
8
+ return useMobileSheet({
9
+ open: inspectorOpen,
10
+ baseClassName: styles.inspector,
11
+ storageKey: 'pxart-inspector',
12
+ });
9
13
  }
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useRef } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { configureSceneCanvas, makeScene } from './scene';
3
3
  import { SceneUI } from './SceneUI';
4
4
  // Engine-level scene player: mount a `SceneRuntime` against a canvas, wire
@@ -9,6 +9,12 @@ import { SceneUI } from './SceneUI';
9
9
  export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirstFrame }) {
10
10
  const canvasRef = useRef(null);
11
11
  const runtimeRef = useRef(null);
12
+ // An error thrown from a behavior's update/draw kills the raf loop (it can't
13
+ // schedule the next frame), which otherwise just black-screens the preview
14
+ // with the throw buried in the console. Capture it here and show a redbox
15
+ // overlay instead. `runId` bumps to remount the loop on Restart.
16
+ const [error, setError] = useState(null);
17
+ const [runId, setRunId] = useState(0);
12
18
  const getRuntime = useCallback(() => runtimeRef.current, []);
13
19
  useEffect(() => {
14
20
  const canvas = canvasRef.current;
@@ -54,8 +60,18 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
54
60
  canvas.addEventListener('pointermove', onPointerMove);
55
61
  canvas.addEventListener('pointerup', onPointerUp);
56
62
  canvas.addEventListener('pointercancel', onPointerUp);
57
- canvas.focus();
58
- const stopLoop = startPlayerLoop(canvas, ctx, runtime, onFirstFrame);
63
+ // Don't steal focus from the embedding shell (e.g. the chat composer) on
64
+ // reload/remount. A real standalone/play, or an iframe that's
65
+ // already focused, still auto-focuses; a click into the game always
66
+ // focuses via onPointerDown above.
67
+ if (document.hasFocus() || window.parent === window) canvas.focus();
68
+ const onLoopError = (err) => {
69
+ // Forward to the console (the serve log picks it up) and surface it.
70
+ // eslint-disable-next-line no-console
71
+ console.error(err);
72
+ setError(err);
73
+ };
74
+ const stopLoop = startPlayerLoop(canvas, ctx, runtime, onFirstFrame, onLoopError);
59
75
  return () => {
60
76
  stopLoop();
61
77
  window.removeEventListener('keydown', onKeyDown);
@@ -67,6 +83,10 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
67
83
  runtimeRef.current = null;
68
84
  };
69
85
  // eslint-disable-next-line react-hooks/exhaustive-deps
86
+ }, [runId]);
87
+ const onRestart = useCallback(() => {
88
+ setError(null);
89
+ setRunId((n) => n + 1);
70
90
  }, []);
71
91
  return (
72
92
  <div style={{ position: 'fixed', inset: 0, background: '#000' }}>
@@ -76,19 +96,28 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
76
96
  style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
77
97
  />
78
98
  <SceneUI getRuntime={getRuntime} />
99
+ {error ? <PlayErrorOverlay error={error} onRestart={onRestart} /> : null}
79
100
  </div>
80
101
  );
81
102
  }
82
- function startPlayerLoop(canvas, ctx, runtime, onFirstFrame) {
103
+ function startPlayerLoop(canvas, ctx, runtime, onFirstFrame, onError) {
83
104
  let raf = 0;
84
105
  let previousTime = performance.now();
85
106
  let firstFrameSignaled = false;
86
107
  const tick = (now) => {
87
108
  const dt = Math.min(0.033, (now - previousTime) / 1000);
88
109
  previousTime = now;
89
- runtime.update(dt);
90
- configureSceneCanvas(canvas, ctx);
91
- runtime.draw(ctx, { useCamera: true });
110
+ // A throw in a behavior's update/draw must not abort the loop silently:
111
+ // catch it, surface it via onError, and stop scheduling frames (so it
112
+ // doesn't rethrow 60x/second). Restart remounts a fresh loop.
113
+ try {
114
+ runtime.update(dt);
115
+ configureSceneCanvas(canvas, ctx);
116
+ runtime.draw(ctx, { useCamera: true });
117
+ } catch (err) {
118
+ onError?.(err);
119
+ return;
120
+ }
92
121
  if (!firstFrameSignaled) {
93
122
  firstFrameSignaled = true;
94
123
  // Wait one frame so the draw composites before reveal, avoiding a blank flash.
@@ -99,3 +128,69 @@ function startPlayerLoop(canvas, ctx, runtime, onFirstFrame) {
99
128
  raf = requestAnimationFrame(tick);
100
129
  return () => cancelAnimationFrame(raf);
101
130
  }
131
+ // Redbox overlay shown when the play loop throws. Covers the canvas with the
132
+ // error name/message and stack so a broken behavior is legible instead of a
133
+ // black screen. Restart remounts the loop; fixing the code and restarting the
134
+ // serve reloads the whole preview fresh.
135
+ function PlayErrorOverlay({ error, onRestart }) {
136
+ const name = error?.name ?? 'Error';
137
+ const message = error?.message ?? String(error);
138
+ const stack = typeof error?.stack === 'string' ? error.stack : '';
139
+ return (
140
+ <div
141
+ style={{
142
+ position: 'absolute',
143
+ inset: 0,
144
+ zIndex: 2147483000,
145
+ display: 'flex',
146
+ flexDirection: 'column',
147
+ gap: 12,
148
+ padding: '20px 22px',
149
+ background: 'rgba(20, 8, 8, 0.94)',
150
+ color: '#ffd9dc',
151
+ font: "13px/1.5 'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
152
+ overflow: 'auto',
153
+ boxSizing: 'border-box',
154
+ }}
155
+ >
156
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
157
+ <span style={{ fontSize: 15, fontWeight: 700, color: '#ff6b74' }}>Play error</span>
158
+ <button
159
+ type="button"
160
+ onClick={onRestart}
161
+ style={{
162
+ marginLeft: 'auto',
163
+ padding: '4px 12px',
164
+ border: '1px solid #a02828',
165
+ borderRadius: 4,
166
+ background: '#a02828',
167
+ color: '#fff',
168
+ font: 'inherit',
169
+ cursor: 'pointer',
170
+ }}
171
+ >
172
+ Restart
173
+ </button>
174
+ </div>
175
+ <div style={{ color: '#fff', fontWeight: 600, wordBreak: 'break-word' }}>
176
+ {name}: {message}
177
+ </div>
178
+ {stack ? (
179
+ <pre
180
+ style={{
181
+ margin: 0,
182
+ whiteSpace: 'pre-wrap',
183
+ wordBreak: 'break-word',
184
+ color: '#e79aa0',
185
+ fontSize: 12,
186
+ }}
187
+ >
188
+ {stack}
189
+ </pre>
190
+ ) : null}
191
+ <div style={{ color: '#c98a8f', fontSize: 12 }}>
192
+ The preview stopped. Fix the code and restart the serve to reload, or press Restart to retry.
193
+ </div>
194
+ </div>
195
+ );
196
+ }
@@ -1,6 +1,18 @@
1
1
  import React from 'react';
2
2
  import { CheckboxField, ColorField, NumberField, Panel, TextField, isHexColor } from './ui';
3
- export function AutoFields({ defaultProps, component, setComponent, only, exclude }) {
3
+ // Build the override-indicator field props (purple tint + "Default: X [Reset]")
4
+ // for a single property from a behavior panel's override context. Returns an
5
+ // empty object when there's no context (blueprint template editing) or the
6
+ // property isn't overridden on this instance, so fields render normally.
7
+ export function overrideProps(override, prop) {
8
+ if (!override || !override.isOverridden(prop)) return {};
9
+ return {
10
+ overridden: true,
11
+ defaultValue: override.baseline(prop),
12
+ onReset: () => override.reset(prop),
13
+ };
14
+ }
15
+ export function AutoFields({ defaultProps, component, setComponent, only, exclude, override }) {
4
16
  const keys = Object.keys(defaultProps).filter((key) => {
5
17
  if (only) return only.includes(key);
6
18
  if (exclude) return !exclude.includes(key);
@@ -14,14 +26,15 @@ export function AutoFields({ defaultProps, component, setComponent, only, exclud
14
26
  const set = (value) => setComponent({ [key]: value });
15
27
  const label = humanizeKey(key);
16
28
  const sample = fallback ?? current;
29
+ const ov = overrideProps(override, key);
17
30
  if (typeof sample === 'number') {
18
- return <NumberField key={key} label={label} value={current} onChange={set} />;
31
+ return <NumberField key={key} label={label} value={current} onChange={set} {...ov} />;
19
32
  }
20
33
  if (typeof sample === 'boolean') {
21
- return <CheckboxField key={key} label={label} checked={current} onChange={set} />;
34
+ return <CheckboxField key={key} label={label} checked={current} onChange={set} {...ov} />;
22
35
  }
23
36
  if (isHexColor(sample)) {
24
- return <ColorField key={key} label={label} value={current} onChange={set} />;
37
+ return <ColorField key={key} label={label} value={current} onChange={set} {...ov} />;
25
38
  }
26
39
  return (
27
40
  <TextField
@@ -29,16 +42,22 @@ export function AutoFields({ defaultProps, component, setComponent, only, exclud
29
42
  label={label}
30
43
  value={current == null ? '' : String(current)}
31
44
  onChange={set}
45
+ {...ov}
32
46
  />
33
47
  );
34
48
  })}
35
49
  </>
36
50
  );
37
51
  }
38
- export function AutoInspector({ behaviorName, defaultProps, component, setComponent }) {
52
+ export function AutoInspector({ behaviorName, defaultProps, component, setComponent, override }) {
39
53
  return (
40
- <Panel title={humanizeKey(behaviorName)}>
41
- <AutoFields defaultProps={defaultProps} component={component} setComponent={setComponent} />
54
+ <Panel title={humanizeKey(behaviorName)} overridden={override?.anyOverridden()}>
55
+ <AutoFields
56
+ defaultProps={defaultProps}
57
+ component={component}
58
+ setComponent={setComponent}
59
+ override={override}
60
+ />
42
61
  </Panel>
43
62
  );
44
63
  }
@@ -8,7 +8,7 @@
8
8
  // editors (which also mint/fork/migrate/cascade-delete blueprint files).
9
9
  import { formatJson } from './files';
10
10
  import { DEFAULT_RESOLUTION, TRANSPARENT, serializeCompact } from './pxart';
11
- import { cardSize, mintActorId } from './scene';
11
+ import { cardSize, dedupeActorId } from './scene';
12
12
 
13
13
  export const BLUEPRINTS_DIR = 'blueprints';
14
14
  export const DRAWINGS_DIR = 'drawings';
@@ -126,6 +126,79 @@ function mintBlueprintPath(existingPaths) {
126
126
  return `${BLUEPRINTS_DIR}/blueprint-${max + 1}.scene`;
127
127
  }
128
128
 
129
+ // "auto-tetris" -> "Auto Tetris". Splits on non-alphanumeric runs (covers the
130
+ // hyphen/underscore ids actors typically use) and title-cases each word.
131
+ function humanizeActorId(id) {
132
+ return id
133
+ .split(/[^a-zA-Z0-9]+/)
134
+ .filter(Boolean)
135
+ .map((word) => word[0].toUpperCase() + word.slice(1))
136
+ .join(' ');
137
+ }
138
+
139
+ // "Auto Tetris" -> "auto-tetris" for the filename slug. Kept independent of
140
+ // the actor id's own formatting (an id could already be any casing/shape).
141
+ function slugifyActorId(id) {
142
+ return id
143
+ .trim()
144
+ .toLowerCase()
145
+ .replace(/[^a-z0-9]+/g, '-')
146
+ .replace(/^-+|-+$/g, '');
147
+ }
148
+
149
+ // Mint a readable, unique actor id from a display name (a blueprint's `name`):
150
+ // slugified and slug-suffix-deduped against `existing` (`cauldron`,
151
+ // `cauldron-2`, ...). This is the editor's placement/creation default, so a
152
+ // freshly placed actor reads as `cauldron` rather than an opaque hex id.
153
+ // `mintActorId` (hex) still backs runtime spawns and the dedup fallback.
154
+ export function mintActorIdFromName(existing, name) {
155
+ const base = slugifyActorId(typeof name === 'string' ? name : '');
156
+ return dedupeActorId(existing, base);
157
+ }
158
+
159
+ // Derive a migration blueprint's { name, path } from the orphan actor's own
160
+ // `id` -- the one meaningful signal available at migration time (vs. the
161
+ // anonymous "Blueprint N" counter), so e.g. an actor `id: "logo"` mints
162
+ // `blueprints/logo.scene` named "Logo" instead of "Blueprint 1". Returns null
163
+ // when the id doesn't yield a usable name/slug, so the caller falls back to
164
+ // the counter-based mint functions.
165
+ //
166
+ // Collision safety is load-bearing: `knownBlueprints` includes every
167
+ // already-migrated blueprint in this same run (see the caller's loop), so two
168
+ // orphan actors that humanize to the same name, or that collide with a
169
+ // pre-existing blueprint, must never mint the same path or duplicate name.
170
+ // Deterministically disambiguate with a numeric suffix instead.
171
+ function deriveBlueprintName(actorId, knownBlueprints) {
172
+ if (typeof actorId !== 'string') return null;
173
+ const baseSlug = slugifyActorId(actorId);
174
+ const baseName = humanizeActorId(actorId.trim());
175
+ if (!baseSlug || !baseName) return null;
176
+ const existingNames = new Set(knownBlueprints.map((blueprint) => blueprint.name));
177
+ const existingPaths = new Set(knownBlueprints.map((blueprint) => blueprint.path));
178
+ const MAX_ATTEMPTS = 1000;
179
+ for (let suffix = 1; suffix <= MAX_ATTEMPTS; suffix += 1) {
180
+ const name = suffix === 1 ? baseName : `${baseName} ${suffix}`;
181
+ const path = suffix === 1 ? `${BLUEPRINTS_DIR}/${baseSlug}.scene` : `${BLUEPRINTS_DIR}/${baseSlug}-${suffix}.scene`;
182
+ if (!existingNames.has(name) && !existingPaths.has(path)) return { name, path };
183
+ }
184
+ // Pathological: 1000 colliding suffixes. Let the caller fall back to the
185
+ // counter-based mint functions rather than looping forever.
186
+ return null;
187
+ }
188
+
189
+ // { name, path } for a freshly minted migration blueprint: the actor id's
190
+ // derived name/slug when usable and collision-free, else the anonymous
191
+ // counter (see `deriveBlueprintName`). Factored out so the branching here
192
+ // doesn't add to `migrateOrphanActors`'s own complexity.
193
+ function mintMigrationBlueprintName(actorId, knownBlueprints) {
194
+ const derived = deriveBlueprintName(actorId, knownBlueprints);
195
+ if (derived) return derived;
196
+ return {
197
+ name: mintBlueprintName(knownBlueprints.map((blueprint) => blueprint.name)),
198
+ path: mintBlueprintPath(knownBlueprints.map((blueprint) => blueprint.path)),
199
+ };
200
+ }
201
+
129
202
  export function formatBlueprintFileText(name, components) {
130
203
  return formatJson({ name, actors: [{ components }] });
131
204
  }
@@ -260,9 +333,23 @@ function mintDrawingPath(files) {
260
333
  return `${DRAWINGS_DIR}/drawing-${max + 1}.pxart`;
261
334
  }
262
335
 
336
+ // The z that draws on top of every current actor: max effective z + 1 (0 for
337
+ // an empty scene). An actor's effective z resolves its own Layout override
338
+ // first, then its blueprint template, then 0 -- so this beats instances that
339
+ // only inherit z from their blueprint.
340
+ function topZ(actors, files) {
341
+ let max = -Infinity;
342
+ for (const actor of actors ?? []) {
343
+ const template = actor.blueprint ? getBlueprintTemplate(files, actor.blueprint) : null;
344
+ const z = actor.components?.Layout?.z ?? template?.components?.Layout?.z ?? 0;
345
+ if (z > max) max = z;
346
+ }
347
+ return max === -Infinity ? 0 : max + 1;
348
+ }
349
+
263
350
  export function addActorWithBlueprint(sceneData, files) {
264
- const width = 64;
265
- const height = 64;
351
+ const width = 50;
352
+ const height = 50;
266
353
  const drawingFile = { path: mintDrawingPath(files), text: blankPxArtText() };
267
354
  const components = {
268
355
  Layout: { width, height, z: 0 },
@@ -271,10 +358,12 @@ export function addActorWithBlueprint(sceneData, files) {
271
358
  const blueprintFile = mintBlueprintFile(files, components);
272
359
  const next = structuredClone(sceneData);
273
360
  const existingIds = new Set(next.actors.map((actor) => actor.id));
274
- const newId = mintActorId(existingIds);
361
+ const newId = mintActorIdFromName(existingIds, blueprintFile.name);
275
362
  const x = Math.round((cardSize.width - width) / 2);
276
363
  const y = Math.round((cardSize.height - height) / 2);
277
- next.actors.push({ id: newId, blueprint: blueprintFile.path, components: { Layout: { x, y } } });
364
+ // Guarantee the new actor renders on top of whatever's already there.
365
+ const z = topZ(next.actors, files);
366
+ next.actors.push({ id: newId, blueprint: blueprintFile.path, components: { Layout: { x, y, z } } });
278
367
  return { sceneData: next, newId, blueprintFile, drawingFile };
279
368
  }
280
369
 
@@ -284,8 +373,8 @@ export function addActorWithBlueprint(sceneData, files) {
284
373
  export function blueprintDropXY(files, blueprintPath, position, snap) {
285
374
  const template = getBlueprintTemplate(files, blueprintPath);
286
375
  const layout = template?.components?.Layout ?? {};
287
- const width = layout.width ?? 64;
288
- const height = layout.height ?? 64;
376
+ const width = layout.width ?? 50;
377
+ const height = layout.height ?? 50;
289
378
  const dropX = position?.x ?? cardSize.width / 2;
290
379
  const dropY = position?.y ?? cardSize.height / 2;
291
380
  return {
@@ -301,7 +390,8 @@ export function blueprintDropXY(files, blueprintPath, position, snap) {
301
390
  export function placeBlueprintInstance(sceneData, files, blueprintPath, position, snap) {
302
391
  const next = structuredClone(sceneData);
303
392
  const existingIds = new Set(next.actors.map((actor) => actor.id));
304
- const newId = mintActorId(existingIds);
393
+ const template = getBlueprintTemplate(files, blueprintPath);
394
+ const newId = mintActorIdFromName(existingIds, template?.name);
305
395
  const { x, y } = blueprintDropXY(files, blueprintPath, position, snap);
306
396
  next.actors.push({ id: newId, blueprint: blueprintPath, components: { Layout: { x, y } } });
307
397
  return { sceneData: next, newId };
@@ -408,11 +498,19 @@ export function migrateOrphanActors(files, behaviors, sceneData) {
408
498
  if (!props) continue;
409
499
  const Behavior = findBehavior(behaviors, behaviorName);
410
500
  const { inherited, overridden } = splitByInherit(Behavior, props);
411
- if (Object.keys(inherited).length > 0) templateComponents[behaviorName] = inherited;
501
+ // A behavior attached with no props at all (e.g. `AutoTetris: {}`) has
502
+ // both `inherited` and `overridden` empty. It must still land somewhere,
503
+ // or the attachment itself -- not just some prop of it -- silently
504
+ // vanishes from the resolved actor. Fall back to the template side (as
505
+ // `{}`) so the instance keeps referencing the behavior exactly once.
506
+ if (Object.keys(inherited).length > 0 || Object.keys(overridden).length === 0) {
507
+ templateComponents[behaviorName] = inherited;
508
+ }
412
509
  if (Object.keys(overridden).length > 0) instanceOverrides[behaviorName] = overridden;
413
510
  }
414
- const name = mintBlueprintName(knownBlueprints.map((blueprint) => blueprint.name));
415
- const path = mintBlueprintPath(knownBlueprints.map((blueprint) => blueprint.path));
511
+ // The actor's own id is the one meaningful signal available here (vs. an
512
+ // anonymous "Blueprint N") -- see `mintMigrationBlueprintName`.
513
+ const { name, path } = mintMigrationBlueprintName(actor.id, knownBlueprints);
416
514
  const text = formatBlueprintFileText(name, templateComponents);
417
515
  newBlueprintFiles.push({ path, text });
418
516
  knownBlueprints = [...knownBlueprints, { path, name, components: templateComponents }];