castle-web-cli 0.4.82 → 0.4.84

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 (37) hide show
  1. package/dist/agent-failures.d.ts +17 -0
  2. package/dist/agent-failures.js +151 -0
  3. package/dist/agent.d.ts +27 -0
  4. package/dist/agent.js +614 -57
  5. package/dist/ide.js +150 -1
  6. package/dist/native/loop.js +40 -1
  7. package/dist/native/openrouter.d.ts +12 -1
  8. package/dist/native/openrouter.js +45 -2
  9. package/dist/native/types.d.ts +6 -0
  10. package/dist/native/types.js +0 -38
  11. package/dist/openrouter-catalog.d.ts +28 -0
  12. package/dist/openrouter-catalog.js +299 -0
  13. package/dist/shell/assets/index-BOgm5T3W.js +144 -0
  14. package/dist/shell/assets/index-DonnH--m.css +1 -0
  15. package/dist/shell/index.html +2 -2
  16. package/dist/shell/operator.png +0 -0
  17. package/kits/basic-2d/CLAUDE.md +27 -23
  18. package/kits/basic-2d/behaviors/Collider.jsx +24 -30
  19. package/kits/basic-2d/behaviors/Layout.jsx +9 -6
  20. package/kits/basic-2d/behaviors/Sprite.jsx +137 -7
  21. package/kits/basic-2d/blueprints/cauldron.scene +3 -5
  22. package/kits/basic-2d/editors/BlueprintLibrary.jsx +11 -11
  23. package/kits/basic-2d/editors/SceneEditor.jsx +212 -50
  24. package/kits/basic-2d/editors/SelectionOverlay.jsx +73 -54
  25. package/kits/basic-2d/editors/inspectorSheet.js +5 -1
  26. package/kits/basic-2d/engine/ScenePlayer.jsx +98 -7
  27. package/kits/basic-2d/engine/autoInspector.jsx +26 -7
  28. package/kits/basic-2d/engine/blueprint.js +35 -8
  29. package/kits/basic-2d/engine/collider.js +146 -0
  30. package/kits/basic-2d/engine/scene.js +53 -30
  31. package/kits/basic-2d/engine/spriteGeometry.js +32 -0
  32. package/kits/basic-2d/engine/ui.jsx +89 -30
  33. package/kits/basic-2d/engine/ui.module.css +157 -53
  34. package/kits/basic-2d/scenes/main.scene +3 -3
  35. package/package.json +2 -1
  36. package/dist/shell/assets/index-ByhgiJoP.js +0 -141
  37. package/dist/shell/assets/index-D6hM_VlW.css +0 -1
@@ -4,17 +4,15 @@
4
4
  {
5
5
  "components": {
6
6
  "Layout": {
7
- "width": 80,
8
- "height": 80,
7
+ "width": 100,
8
+ "height": 100,
9
9
  "z": 1
10
10
  },
11
11
  "Sprite": {
12
12
  "file": "drawings/cauldron.pxart"
13
13
  },
14
14
  "Collider": {
15
- "kind": "solid",
16
- "width": 70,
17
- "height": 70
15
+ "kind": "solid"
18
16
  }
19
17
  }
20
18
  }
@@ -17,6 +17,7 @@ export function BlueprintLibrary({
17
17
  editCameraRef,
18
18
  isPlaying,
19
19
  selectedBlueprintPath,
20
+ instanceBlueprintPath,
20
21
  onSelectBlueprint,
21
22
  onAddActor,
22
23
  dragPlace,
@@ -103,16 +104,22 @@ export function BlueprintLibrary({
103
104
  type="button"
104
105
  className={styles.bpActionSlot}
105
106
  aria-label="Add actor"
106
- title="Add actor (creates a new blueprint)"
107
+ title="New blueprint"
107
108
  onClick={onAddActor}
108
109
  disabled={isPlaying}>
109
- <span className={styles.dashedSquareIcon} aria-hidden="true" />
110
+ <Icon name="plus" />
110
111
  </button>
111
112
  {blueprints.map((blueprint) => (
112
113
  <button
113
114
  key={blueprint.path}
114
115
  type="button"
115
- className={cx(styles.bpSlot, selectedBlueprintPath === blueprint.path && styles.bpSlotSelected)}
116
+ className={cx(
117
+ styles.bpSlot,
118
+ selectedBlueprintPath === blueprint.path && styles.bpSlotSelected,
119
+ selectedBlueprintPath !== blueprint.path &&
120
+ instanceBlueprintPath === blueprint.path &&
121
+ styles.bpSlotInstance
122
+ )}
116
123
  title={`${blueprint.name} -- click to edit, drag to place`}
117
124
  disabled={isPlaying}
118
125
  onPointerDown={(event) => onSlotPointerDown(event, blueprint)}
@@ -125,7 +132,6 @@ export function BlueprintLibrary({
125
132
  onSelectBlueprint(blueprint.path);
126
133
  }}>
127
134
  <BlueprintThumbnail blueprint={blueprint} sprites={sprites} />
128
- <InstanceCountBadge files={files} blueprintPath={blueprint.path} />
129
135
  </button>
130
136
  ))}
131
137
  {dragPreview ? <BlueprintDragPreview preview={dragPreview} sprites={sprites} /> : null}
@@ -176,12 +182,6 @@ function DeleteBlueprintConfirm({ files, blueprint, onCancel, onConfirm }) {
176
182
  );
177
183
  }
178
184
 
179
- function InstanceCountBadge({ files, blueprintPath }) {
180
- const count = countBlueprintInstances(files, blueprintPath);
181
- if (count === 0) return null;
182
- return <span className={styles.bpSlotBadge}>{count > 99 ? '99+' : count}</span>;
183
- }
184
-
185
185
  function BlueprintDragPreview({ preview, sprites }) {
186
186
  const layout = preview.blueprint.components?.Layout;
187
187
  const width = Math.max(24, Math.min(80, layout?.width ?? 48));
@@ -201,7 +201,7 @@ function BlueprintDragPreview({ preview, sprites }) {
201
201
  );
202
202
  }
203
203
 
204
- function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
204
+ export function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
205
205
  const canvasRef = useRef(null);
206
206
  const spritePath = blueprint.components?.Sprite?.file;
207
207
  const sprite = spritePath ? sprites?.[spritePath] : null;
@@ -1,5 +1,5 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- import { writeFile } from 'castle-web-sdk';
2
+ import { onSaveReloadState, takeReloadState, writeFile } from 'castle-web-sdk';
3
3
  import { basename, formatJson, parseJsonFile } from '../engine/files';
4
4
  import {
5
5
  arrangeActors,
@@ -17,9 +17,11 @@ import {
17
17
  addActorWithBlueprint,
18
18
  blueprintDropXY,
19
19
  cascadeDeleteBlueprint,
20
+ countBlueprintInstances,
20
21
  forkActorToBlueprint,
21
22
  getBlueprintTemplate,
22
23
  isBlueprintPath,
24
+ isInheritedProp,
23
25
  migrateOrphanActors,
24
26
  placeBlueprintInstance,
25
27
  setBlueprintName,
@@ -45,7 +47,7 @@ import {
45
47
  import { AutoInspector } from '../engine/autoInspector';
46
48
  import { SceneUI } from '../engine/SceneUI';
47
49
  import { SelectionOverlay } from './SelectionOverlay';
48
- import { BlueprintLibrary } from './BlueprintLibrary';
50
+ import { BlueprintLibrary, BlueprintThumbnail } from './BlueprintLibrary';
49
51
  import { behaviorClasses, findBehaviorClass } from './behaviorRegistry';
50
52
  import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
51
53
  // A blueprint file's `actors[0]` has no `id` (it's the template, not an
@@ -137,16 +139,32 @@ export function SceneEditor({
137
139
  const canvasRef = useRef(null);
138
140
  const runtimeRef = useRef(null);
139
141
  const marqueeRef = useRef(null);
140
- const editCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
142
+ // Ephemeral editor state (camera, play mode, inspected blueprint) restored
143
+ // across a reload: an agent's CODE edit triggers requestReload, which would
144
+ // otherwise reset the viewport/play state on every change. Data edits fold in
145
+ // live without a reload, so they never need this. `takeReloadState` is
146
+ // one-shot (clears on read), so read it once here. Selection is stashed
147
+ // separately by SingleEditor.
148
+ const reloadStashKey = `scene-editor:${path}`;
149
+ const [reloadStash] = useState(() => takeReloadState(reloadStashKey));
150
+ const editCameraRef = useRef(reloadStash?.editCamera ?? { x: 0, y: 0, zoom: 1 });
141
151
  const selectedActorIdsRef = useRef(selectedActorIds);
142
152
  selectedActorIdsRef.current = selectedActorIds;
143
- const [isPlaying, setIsPlaying] = useState(false);
153
+ const [isPlaying, setIsPlaying] = useState(reloadStash?.isPlaying ?? false);
144
154
  // Hotbar blueprint selection (tap a slot -> inspect/edit the blueprint in
145
155
  // the sidebar; drag remains the placement gesture). Mutually exclusive with
146
156
  // actor selection: ANY actor-selection change -- including clearing it via
147
157
  // empty-canvas click or Escape -- also drops the blueprint selection, which
148
158
  // is why every internal caller goes through the wrapper below.
149
- const [selectedBlueprintPath, setSelectedBlueprintPath] = useState(null);
159
+ const [selectedBlueprintPath, setSelectedBlueprintPath] = useState(
160
+ reloadStash?.selectedBlueprintPath ?? null
161
+ );
162
+ // Read live in the render loop (a ref so changing it doesn't restart the
163
+ // loop): while a blueprint is selected, its instances stay lit and every
164
+ // other actor dims. Assigned below once `hotbarBlueprint` resolves, so a
165
+ // stale/deleted selection (file vanished) doesn't dim the scene with nothing
166
+ // to highlight.
167
+ const selectedBlueprintPathRef = useRef(selectedBlueprintPath);
150
168
  const onSelectActorIds = useCallback(
151
169
  (ids) => {
152
170
  setSelectedBlueprintPath(null);
@@ -171,6 +189,19 @@ export function SceneEditor({
171
189
  // then, but without this the keyboard shortcut could still rewrite the
172
190
  // scene file out from under the running play-mode runtime.
173
191
  useUndoRedoShortcuts(history, !isPlaying);
192
+ // Stash ephemeral editor state right before a reload so it survives an agent
193
+ // code edit. `editCamera` is read live from the ref at save time (a reload is
194
+ // imminent, so the latest value is what matters); isPlaying / blueprint
195
+ // selection re-register whenever they change.
196
+ useEffect(
197
+ () =>
198
+ onSaveReloadState(reloadStashKey, () => ({
199
+ isPlaying,
200
+ selectedBlueprintPath,
201
+ editCamera: editCameraRef.current,
202
+ })),
203
+ [reloadStashKey, isPlaying, selectedBlueprintPath]
204
+ );
174
205
  const showMulti = !isBlueprintFile && (selectedActorIds.length > 1 || multiSelectMode);
175
206
  const inspectorSheet = useSelectionInspectorSheet(true);
176
207
  const { value: parsedSceneData, error } = parseJsonFile(path, text);
@@ -196,6 +227,7 @@ export function SceneEditor({
196
227
  editCameraRef,
197
228
  marqueeRef,
198
229
  selectedActorIdsRef,
230
+ selectedBlueprintPathRef,
199
231
  });
200
232
  const panGesture = usePanGesture({ canvasRef, editCameraRef, isPlaying });
201
233
  useScenePlayKeys(runtimeRef);
@@ -249,8 +281,11 @@ export function SceneEditor({
249
281
  selectedActorIds.length === 1
250
282
  ? previewSceneData.actors.find((actor) => actor.id === selectedActorIds[0])
251
283
  : undefined;
284
+ const selectedBlueprintTemplate = rawSelectedActor?.blueprint
285
+ ? getBlueprintTemplate(files, rawSelectedActor.blueprint)
286
+ : undefined;
252
287
  const selectedBlueprintName = rawSelectedActor?.blueprint
253
- ? getBlueprintTemplate(files, rawSelectedActor.blueprint)?.name ?? basename(rawSelectedActor.blueprint)
288
+ ? selectedBlueprintTemplate?.name ?? basename(rawSelectedActor.blueprint)
254
289
  : undefined;
255
290
  // Hotbar-selected blueprint, resolved fresh from live files each render. A
256
291
  // selection whose file vanished (cascade delete, external edit) degrades to
@@ -259,6 +294,9 @@ export function SceneEditor({
259
294
  !isBlueprintFile && selectedBlueprintPath
260
295
  ? getBlueprintTemplate(files, selectedBlueprintPath)
261
296
  : null;
297
+ // Only dim for a blueprint that actually resolves to an inspectable template
298
+ // (mirrors the sidebar): a vanished/stale selection highlights nothing.
299
+ selectedBlueprintPathRef.current = hotbarBlueprint ? selectedBlueprintPath : null;
262
300
  // Template edits from the sidebar write the BLUEPRINT file, not this scene:
263
301
  // optimistic fold into live files state (instant merged-instance updates
264
302
  // here) + debounced writeFile via SingleEditor's saver, which also makes
@@ -391,24 +429,6 @@ export function SceneEditor({
391
429
  void writeFile(result.blueprintFile.path, result.blueprintFile.text);
392
430
  history.commit(serialize(result.sceneData));
393
431
  };
394
- const inspectorLabel = isBlueprintFile
395
- ? 'Blueprint'
396
- : hotbarBlueprint
397
- ? 'Blueprint'
398
- : showMulti
399
- ? 'Multi-select'
400
- : selectedActor
401
- ? 'Inspector'
402
- : 'Scene';
403
- const inspectorHint = isBlueprintFile
404
- ? sceneData.name ?? basename(path)
405
- : hotbarBlueprint
406
- ? hotbarBlueprint.name
407
- : showMulti
408
- ? `${selectedActorIds.length} actor${selectedActorIds.length === 1 ? '' : 's'} selected`
409
- : selectedActor
410
- ? `actor ${selectedActor.id}`
411
- : 'scene settings';
412
432
  return (
413
433
  <>
414
434
  <EditorHeader
@@ -451,6 +471,7 @@ export function SceneEditor({
451
471
  editCameraRef={editCameraRef}
452
472
  isPlaying={isPlaying}
453
473
  selectedBlueprintPath={selectedBlueprintPath}
474
+ instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
454
475
  onSelectBlueprint={onSelectBlueprint}
455
476
  onAddActor={onAddActor}
456
477
  dragPlace={dragPlace}
@@ -506,6 +527,7 @@ export function SceneEditor({
506
527
  editCameraRef={editCameraRef}
507
528
  sceneData={sceneData}
508
529
  previewSceneData={previewSceneData}
530
+ sprites={sprites}
509
531
  selectedActorIds={selectedActorIds}
510
532
  snap={snapSettings}
511
533
  onArrange={isBlueprintFile ? undefined : actions.arrangeSelection}
@@ -519,13 +541,15 @@ export function SceneEditor({
519
541
  </div>
520
542
  <aside {...inspectorSheet.rootProps}>
521
543
  <div {...inspectorSheet.grabProps}>
522
- <SheetGrabHandle label={inspectorLabel} hint={inspectorHint} />
544
+ <SheetGrabHandle />
523
545
  </div>
524
546
  <div className={cx(styles.sheetBody, styles.inspectorBody)}>
525
547
  {hotbarBlueprint ? (
526
548
  <BlueprintInspector
527
549
  name={hotbarBlueprint.name}
528
550
  template={hotbarBlueprint}
551
+ sprites={sprites}
552
+ blueprintPath={selectedBlueprintPath}
529
553
  files={files}
530
554
  onRename={blueprintActions.rename}
531
555
  onSetComponent={blueprintActions.setComponent}
@@ -542,14 +566,23 @@ export function SceneEditor({
542
566
  />
543
567
  ) : selectedActor ? (
544
568
  <ActorInspector
569
+ key={selectedActor.id}
545
570
  selectedActor={selectedActor}
546
571
  rawActor={rawSelectedActor}
547
572
  blueprintName={selectedBlueprintName}
573
+ blueprintTemplate={selectedBlueprintTemplate}
574
+ sprites={sprites}
548
575
  files={files}
576
+ showAddBehavior={isBlueprintFile}
549
577
  onSetComponent={actions.updateComponent}
550
578
  onAddBehavior={actions.addBehavior}
551
579
  onRemoveBehavior={actions.removeBehavior}
552
580
  onFork={isBlueprintFile ? undefined : onForkSelection}
581
+ onEditBlueprint={
582
+ isBlueprintFile || !rawSelectedActor?.blueprint
583
+ ? undefined
584
+ : () => onSelectBlueprint(rawSelectedActor.blueprint)
585
+ }
553
586
  />
554
587
  ) : (
555
588
  <SceneInspector
@@ -692,8 +725,9 @@ function parseHexColor(value) {
692
725
  function useSelectionInspectorSheet(open) {
693
726
  // Inspector visibility is driven by selection / multi-mode; the sheet hook
694
727
  // owns the drag-resize height and remembers it across selections while
695
- // mounted (and restores it when the sheet reopens).
696
- return useMobileSheet({ open, baseClassName: styles.inspector });
728
+ // mounted (and restores it when the sheet reopens), plus persists it across
729
+ // reloads via `storageKey`.
730
+ return useMobileSheet({ open, baseClassName: styles.inspector, storageKey: 'scene-inspector' });
697
731
  }
698
732
  function useSelectionGesture(args) {
699
733
  const dragRef = useRef(null);
@@ -724,6 +758,7 @@ function useSelectionGesture(args) {
724
758
  movingActorIds: [],
725
759
  moveStarts: {},
726
760
  recordedMoveSnapshot: false,
761
+ lastMoveKey: '0,0',
727
762
  selectionBeforeMarquee: [...current.selectedActorIds],
728
763
  };
729
764
  if (event.shiftKey) {
@@ -759,6 +794,15 @@ function useSelectionGesture(args) {
759
794
  const dx = snapDelta(dxTotal, snap.gridSize, snap.enabled);
760
795
  const dy = snapDelta(dyTotal, snap.gridSize, snap.enabled);
761
796
  drag.lastPoint = point;
797
+ // Key the commit on the snapped delta, not on `nextScene === sceneData`.
798
+ // `moveActorsFromStarts` returns the pointer-down scene unchanged when the
799
+ // delta lands back on the start, and an identity guard would skip applying
800
+ // it -- stranding the actors on the last grid step instead of letting them
801
+ // return to origin. Re-apply whenever the snapped delta changes; the
802
+ // identity check now only gates recording the undo snapshot.
803
+ const moveKey = `${dx},${dy}`;
804
+ if (moveKey === drag.lastMoveKey) return;
805
+ drag.lastMoveKey = moveKey;
762
806
  const nextScene = moveActorsFromStarts(
763
807
  current.sceneData,
764
808
  drag.movingActorIds,
@@ -766,8 +810,7 @@ function useSelectionGesture(args) {
766
810
  dx,
767
811
  dy
768
812
  );
769
- if (nextScene === current.sceneData) return;
770
- if (!drag.recordedMoveSnapshot) {
813
+ if (nextScene !== current.sceneData && !drag.recordedMoveSnapshot) {
771
814
  current.recordSceneSnapshot();
772
815
  drag.recordedMoveSnapshot = true;
773
816
  }
@@ -1179,6 +1222,7 @@ function useScenePlayLoop({
1179
1222
  editCameraRef,
1180
1223
  marqueeRef,
1181
1224
  selectedActorIdsRef,
1225
+ selectedBlueprintPathRef,
1182
1226
  }) {
1183
1227
  // Spin up / tear down the play-mode runtime as the user toggles play. `text`
1184
1228
  // is the stable identity for `sceneData` (which is re-parsed every render);
@@ -1234,7 +1278,7 @@ function useScenePlayLoop({
1234
1278
  viewport: frameViewport,
1235
1279
  useCamera: true,
1236
1280
  editPlaceholders: !isPlaying,
1237
- editSelectedActorIds,
1281
+ dimBlueprintPath: isPlaying ? null : selectedBlueprintPathRef.current,
1238
1282
  });
1239
1283
  }
1240
1284
  raf = requestAnimationFrame(frame);
@@ -1242,7 +1286,17 @@ function useScenePlayLoop({
1242
1286
  raf = requestAnimationFrame(frame);
1243
1287
  return () => cancelAnimationFrame(raf);
1244
1288
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `text` proxies `sceneData` identity; resetting on every parse churns the loop and wipes runtime state.
1245
- }, [sprites, files, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
1289
+ }, [
1290
+ sprites,
1291
+ files,
1292
+ isPlaying,
1293
+ text,
1294
+ canvasRef,
1295
+ runtimeRef,
1296
+ marqueeRef,
1297
+ selectedActorIdsRef,
1298
+ selectedBlueprintPathRef,
1299
+ ]);
1246
1300
  }
1247
1301
  function useScenePlayKeys(runtimeRef) {
1248
1302
  useEffect(() => {
@@ -1343,6 +1397,8 @@ function SceneInspector({ sceneData, snapSettings, onChangeScene, onChangeEditor
1343
1397
  function BlueprintInspector({
1344
1398
  name,
1345
1399
  template,
1400
+ sprites,
1401
+ blueprintPath,
1346
1402
  files,
1347
1403
  onRename,
1348
1404
  onSetComponent,
@@ -1350,16 +1406,25 @@ function BlueprintInspector({
1350
1406
  onRemoveBehavior,
1351
1407
  }) {
1352
1408
  const templateActor = { id: '__blueprint__', components: template.components };
1409
+ const instanceCount = countBlueprintInstances(files, blueprintPath);
1353
1410
  return (
1354
1411
  <>
1355
- <div className={styles.instanceHint}>
1356
- <input
1357
- className={styles.blueprintNameInput}
1358
- value={name}
1359
- onChange={(event) => onRename?.(event.target.value)}
1360
- aria-label="Blueprint name"
1361
- spellCheck={false}
1362
- />
1412
+ <div className={styles.instanceHeader}>
1413
+ <div className={styles.instanceHeaderName}>
1414
+ <BlueprintThumbnail blueprint={template} sprites={sprites} />
1415
+ <div className={styles.instanceHeaderNameCol}>
1416
+ <input
1417
+ className={styles.blueprintNameInput}
1418
+ value={name}
1419
+ onChange={(event) => onRename?.(event.target.value)}
1420
+ aria-label="Blueprint name"
1421
+ spellCheck={false}
1422
+ />
1423
+ <span className={styles.instanceHeaderId}>
1424
+ {instanceCount} {instanceCount === 1 ? 'actor' : 'actors'}
1425
+ </span>
1426
+ </div>
1427
+ </div>
1363
1428
  </div>
1364
1429
  <ActorInspector
1365
1430
  selectedActor={templateActor}
@@ -1372,15 +1437,43 @@ function BlueprintInspector({
1372
1437
  </>
1373
1438
  );
1374
1439
  }
1440
+ // Per-behavior helper the inspector fields use to render instance overrides.
1441
+ // `isOverridden` is true only for inherited props the instance has its own
1442
+ // value for (position/rotation are `inherit: false` and always instance-local,
1443
+ // so they never read as "overriding" a blueprint default). `baseline` is what
1444
+ // the prop falls back to when reset -- the blueprint template value if present,
1445
+ // else the behavior's default. `reset` writes that baseline; for inherited
1446
+ // props `applyComponentPatch` then drops the now-redundant override.
1447
+ function makeOverrideContext({ Behavior, rawComponent, templateProps, setComponent }) {
1448
+ const baselineOf = (prop) =>
1449
+ templateProps && prop in templateProps ? templateProps[prop] : Behavior?.defaultProps?.[prop];
1450
+ return {
1451
+ isOverridden: (prop) =>
1452
+ isInheritedProp(Behavior, prop) && !!rawComponent && prop in rawComponent,
1453
+ // True when the behavior overrides at least one inherited prop -- i.e. it
1454
+ // has a field that renders with the purple override tint. Drives the
1455
+ // matching tint on the panel's title. Position/rotation are inherit:false,
1456
+ // so a position-only instance override doesn't count (nor does it tint any
1457
+ // field), matching isOverridden's semantics.
1458
+ anyOverridden: () =>
1459
+ !!rawComponent && Object.keys(rawComponent).some((prop) => isInheritedProp(Behavior, prop)),
1460
+ baseline: baselineOf,
1461
+ reset: (prop) => setComponent({ [prop]: baselineOf(prop) }),
1462
+ };
1463
+ }
1375
1464
  function ActorInspector({
1376
1465
  selectedActor,
1377
1466
  rawActor,
1378
1467
  blueprintName,
1468
+ blueprintTemplate,
1469
+ sprites,
1470
+ showAddBehavior = true,
1379
1471
  files,
1380
1472
  onSetComponent,
1381
1473
  onAddBehavior,
1382
1474
  onRemoveBehavior,
1383
1475
  onFork,
1476
+ onEditBlueprint,
1384
1477
  }) {
1385
1478
  const isInstance = Boolean(rawActor?.blueprint);
1386
1479
  const presentNames = new Set(
@@ -1392,38 +1485,96 @@ function ActorInspector({
1392
1485
  (candidate) => !presentNames.has(candidate.behaviorName)
1393
1486
  );
1394
1487
  const behaviorEntries = Object.entries(selectedActor.components).filter((entry) => !!entry[1]);
1488
+ // For an instance, behaviors it doesn't already override are collapsed behind
1489
+ // a reveal button; Layout and any behavior with an instance override stay
1490
+ // visible below the header. Revealing is a pure view toggle -- it never
1491
+ // touches the actor, it just exposes the inherited behaviors' fields so they
1492
+ // can be overridden. Keyed by actor id at the call site, so the collapsed
1493
+ // state resets when a different actor is selected.
1494
+ const [showAllBehaviors, setShowAllBehaviors] = useState(false);
1495
+ const isPinnedBehavior = (behaviorName) =>
1496
+ behaviorName === 'Layout' || Boolean(rawActor?.components?.[behaviorName]);
1497
+ const collapsed = isInstance && !showAllBehaviors;
1498
+ const visibleEntries = collapsed
1499
+ ? behaviorEntries.filter(([behaviorName]) => isPinnedBehavior(behaviorName))
1500
+ : behaviorEntries;
1501
+ const showReveal = collapsed && visibleEntries.length < behaviorEntries.length;
1395
1502
  return (
1396
1503
  <>
1397
1504
  {isInstance ? (
1398
- <div className={styles.instanceHint}>
1399
- <span className={styles.instanceHintLabel}>Instance of {blueprintName}</span>
1505
+ <div className={styles.instanceHeader}>
1506
+ <div className={styles.instanceHeaderName}>
1507
+ <BlueprintThumbnail
1508
+ blueprint={blueprintTemplate ?? { components: selectedActor.components }}
1509
+ sprites={sprites}
1510
+ />
1511
+ <div className={styles.instanceHeaderNameCol}>
1512
+ <span className={styles.instanceHeaderNameText}>
1513
+ <strong className={styles.instanceHeaderNameStrong}>{blueprintName}</strong> Actor
1514
+ </span>
1515
+ <span className={styles.instanceHeaderId}>id: {selectedActor.id}</span>
1516
+ </div>
1517
+ </div>
1518
+ {onEditBlueprint ? (
1519
+ <button
1520
+ type="button"
1521
+ className={styles.instanceForkButton}
1522
+ onClick={onEditBlueprint}
1523
+ title="Select this blueprint in the hotbar">
1524
+ <Icon name="pencil" />
1525
+ <span>Edit blueprint</span>
1526
+ </button>
1527
+ ) : null}
1400
1528
  {onFork ? (
1401
- <IconButton icon="code-fork" label="New blueprint from this actor" onClick={onFork} />
1529
+ <button
1530
+ type="button"
1531
+ className={styles.instanceForkButton}
1532
+ onClick={onFork}
1533
+ title="New blueprint from this actor">
1534
+ <Icon name="code-fork" />
1535
+ <span>Fork new blueprint</span>
1536
+ </button>
1402
1537
  ) : null}
1403
1538
  </div>
1404
1539
  ) : null}
1405
- <AddBehaviorPicker
1406
- available={availableBehaviors}
1407
- onAdd={(behaviorName) => onAddBehavior(selectedActor.id, behaviorName)}
1408
- />
1409
- {behaviorEntries.map(([behaviorName, component], index) => {
1540
+ {showAddBehavior ? (
1541
+ <AddBehaviorPicker
1542
+ available={availableBehaviors}
1543
+ onAdd={(behaviorName) => onAddBehavior(selectedActor.id, behaviorName)}
1544
+ />
1545
+ ) : null}
1546
+ {visibleEntries.map(([behaviorName, component], index) => {
1410
1547
  const Behavior = behaviorClasses.find(
1411
1548
  (candidate) => candidate.behaviorName === behaviorName
1412
1549
  );
1413
1550
  const Inspector = Behavior?.Inspector;
1551
+ const setComponent = (nextProps) =>
1552
+ onSetComponent(selectedActor.id, behaviorName, nextProps);
1553
+ // Instance overrides get a purple tint + "Default: X [Reset]" sub-line.
1554
+ // Null when editing a blueprint template (nothing to override against).
1555
+ const override = isInstance
1556
+ ? makeOverrideContext({
1557
+ Behavior,
1558
+ rawComponent: rawActor?.components?.[behaviorName],
1559
+ templateProps: blueprintTemplate?.components?.[behaviorName],
1560
+ setComponent,
1561
+ })
1562
+ : null;
1414
1563
  const body = Inspector ? (
1415
1564
  <Inspector
1416
1565
  actor={selectedActor}
1417
1566
  component={component}
1418
1567
  files={files}
1419
- setComponent={(nextProps) => onSetComponent(selectedActor.id, behaviorName, nextProps)}
1568
+ setComponent={setComponent}
1569
+ override={override}
1420
1570
  />
1421
1571
  ) : (
1422
1572
  <AutoInspector
1423
1573
  behaviorName={behaviorName}
1424
1574
  defaultProps={Behavior?.defaultProps ?? component}
1425
1575
  component={component}
1426
- setComponent={(nextProps) => onSetComponent(selectedActor.id, behaviorName, nextProps)}
1576
+ setComponent={setComponent}
1577
+ override={override}
1427
1578
  />
1428
1579
  );
1429
1580
  // Layout is core to every actor -- it cannot be removed. A component
@@ -1432,7 +1583,9 @@ function ActorInspector({
1432
1583
  // inherited component" concept (see propertyMeta punt list).
1433
1584
  const removable =
1434
1585
  behaviorName !== 'Layout' && (!isInstance || Boolean(rawActor.components?.[behaviorName]));
1435
- const isLast = index === behaviorEntries.length - 1;
1586
+ // When the reveal button follows, keep the last visible panel's divider
1587
+ // so it doesn't merge into the button row.
1588
+ const isLast = index === visibleEntries.length - 1 && !showReveal;
1436
1589
  // Trash renders before the panel so the panel stays the wrapper's
1437
1590
  // last child -- the panel's own `:last-child` border zeroes out, and
1438
1591
  // this wrapper owns the divider between panels deterministically.
@@ -1469,6 +1622,15 @@ function ActorInspector({
1469
1622
  </div>
1470
1623
  );
1471
1624
  })}
1625
+ {showReveal ? (
1626
+ <button
1627
+ type="button"
1628
+ className={styles.revealOverridesButton}
1629
+ onClick={() => setShowAllBehaviors(true)}>
1630
+ <Icon name="chevron-down" />
1631
+ <span>Override behavior properties</span>
1632
+ </button>
1633
+ ) : null}
1472
1634
  </>
1473
1635
  );
1474
1636
  }