castle-web-cli 0.4.78 → 0.4.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/agent-prompts.d.ts +4 -1
  2. package/dist/agent-prompts.js +28 -7
  3. package/dist/agent.d.ts +7 -2
  4. package/dist/agent.js +655 -51
  5. package/dist/native/loop.d.ts +2 -0
  6. package/dist/native/loop.js +698 -0
  7. package/dist/native/openrouter.d.ts +55 -0
  8. package/dist/native/openrouter.js +354 -0
  9. package/dist/native/playtest-browser.d.ts +34 -0
  10. package/dist/native/playtest-browser.js +354 -0
  11. package/dist/native/playtest-executor.d.ts +3 -0
  12. package/dist/native/playtest-executor.js +156 -0
  13. package/dist/native/playtest.d.ts +131 -0
  14. package/dist/native/playtest.js +314 -0
  15. package/dist/native/tools.d.ts +38 -0
  16. package/dist/native/tools.js +630 -0
  17. package/dist/native/types.d.ts +40 -0
  18. package/dist/native/types.js +41 -0
  19. package/dist/serve.js +12 -0
  20. package/dist/shell/assets/{index-yGdKhgfZ.js → index-CNT3KxJb.js} +37 -37
  21. package/dist/shell/assets/{index-WE24qX3d.css → index-RZrw5gQ2.css} +1 -1
  22. package/dist/shell/index.html +2 -2
  23. package/kits/basic-2d/CLAUDE.md +29 -3
  24. package/kits/basic-2d/behaviors/Layout.jsx +10 -0
  25. package/kits/basic-2d/behaviors/Sprite.jsx +1 -1
  26. package/kits/basic-2d/blueprints/cauldron.scene +22 -0
  27. package/kits/basic-2d/castle.json +5 -7
  28. package/kits/basic-2d/docs/pxart-format.md +4 -3
  29. package/kits/basic-2d/drawings/cauldron.pxart +113 -0
  30. package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
  31. package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
  32. package/kits/basic-2d/editors/SceneEditor.jsx +399 -411
  33. package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
  34. package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
  35. package/kits/basic-2d/editors/editorHistory.js +8 -2
  36. package/kits/basic-2d/editors/inspectorSheet.js +5 -19
  37. package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
  38. package/kits/basic-2d/engine/blueprint.js +423 -0
  39. package/kits/basic-2d/engine/files.js +1 -1
  40. package/kits/basic-2d/engine/scene.js +29 -29
  41. package/kits/basic-2d/engine/ui.jsx +160 -21
  42. package/kits/basic-2d/engine/ui.module.css +155 -13
  43. package/kits/basic-2d/pnpm-workspace.yaml +3 -0
  44. package/kits/basic-2d/scenes/main.scene +3 -13
  45. package/package.json +2 -1
  46. package/kits/basic-2d/drawings/pig.pxart +0 -26
@@ -1,22 +1,36 @@
1
- import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
- import { renderSpriteFrame } from '../engine/pxart';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { writeFile } from 'castle-web-sdk';
3
3
  import { basename, formatJson, parseJsonFile } from '../engine/files';
4
4
  import {
5
- addActor,
6
5
  arrangeActors,
7
6
  cardSize,
8
7
  configureSceneCanvas,
9
8
  duplicateActors,
10
9
  makeScene,
10
+ mintActorId,
11
11
  removeActorComponent,
12
12
  removeActors,
13
13
  screenToCard,
14
14
  setActorComponent,
15
15
  } from '../engine/scene';
16
+ import {
17
+ addActorWithBlueprint,
18
+ blueprintDropXY,
19
+ cascadeDeleteBlueprint,
20
+ forkActorToBlueprint,
21
+ getBlueprintTemplate,
22
+ isBlueprintPath,
23
+ migrateOrphanActors,
24
+ placeBlueprintInstance,
25
+ setBlueprintName,
26
+ setBlueprintTemplateComponents,
27
+ setInstanceComponent,
28
+ } from '../engine/blueprint';
16
29
  import {
17
30
  cx,
18
31
  CheckboxField,
19
32
  ColorField,
33
+ ConfirmDialog,
20
34
  EditorBody,
21
35
  EditorHeader,
22
36
  Icon,
@@ -31,8 +45,14 @@ import {
31
45
  import { AutoInspector } from '../engine/autoInspector';
32
46
  import { SceneUI } from '../engine/SceneUI';
33
47
  import { SelectionOverlay } from './SelectionOverlay';
48
+ import { BlueprintLibrary } from './BlueprintLibrary';
34
49
  import { behaviorClasses, findBehaviorClass } from './behaviorRegistry';
35
50
  import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
51
+ // A blueprint file's `actors[0]` has no `id` (it's the template, not an
52
+ // instance) -- injected/stripped at the read/write boundary below so the rest
53
+ // of this editor (selection, drag, inspector) can keep treating it like any
54
+ // other single-actor scene.
55
+ const BLUEPRINT_TEMPLATE_ACTOR_ID = '__template__';
36
56
  const LONG_PRESS_MS = 500;
37
57
  const DRAG_THRESHOLD = 4;
38
58
  const DEFAULT_GRID_SIZE = 25;
@@ -63,17 +83,54 @@ function editViewportForZoom(zoom) {
63
83
  originY: EDIT_VIEWPORT.originY / z,
64
84
  };
65
85
  }
86
+ // A blueprint file on disk has no `id` on its template actor -- inject a
87
+ // synthetic one so every other piece of editor code that expects `actor.id`
88
+ // (selection, drag, inspector lookups) keeps working unmodified. Stripped
89
+ // again by `stripTemplateId` right before a write (see `serialize` below).
90
+ function withTemplateId(sceneData, isBlueprintFile) {
91
+ if (!sceneData || !isBlueprintFile) return sceneData;
92
+ const next = structuredClone(sceneData);
93
+ next.actors = next.actors ?? [];
94
+ if (next.actors[0]) next.actors[0].id = BLUEPRINT_TEMPLATE_ACTOR_ID;
95
+ return next;
96
+ }
97
+ function stripTemplateId(sceneData) {
98
+ const next = structuredClone(sceneData);
99
+ if (next.actors?.[0]) delete next.actors[0].id;
100
+ return next;
101
+ }
102
+ // On opening a scene with orphan actors (no `blueprint` field -- the
103
+ // pre-blueprint shape), mint one blueprint per orphan and rewrite the scene
104
+ // in place. Runs once per distinct file text; the blueprint-file writes are
105
+ // fire-and-forget (they're brand new files, nothing else references them
106
+ // yet) while the scene rewrite goes through this file's own `onChange` so it
107
+ // picks up the normal history/live-reload path.
108
+ function useMigrateOrphanActors({ isBlueprintFile, sceneData, files, text, onChange }) {
109
+ useEffect(() => {
110
+ if (isBlueprintFile || !sceneData) return;
111
+ const hasOrphans = (sceneData.actors ?? []).some((actor) => !actor.blueprint);
112
+ if (!hasOrphans) return;
113
+ const { sceneData: migrated, newBlueprintFiles } = migrateOrphanActors(files, behaviorClasses, sceneData);
114
+ for (const blueprintFile of newBlueprintFiles) void writeFile(blueprintFile.path, blueprintFile.text);
115
+ onChange(formatJson(migrated));
116
+ // Re-run only when this file's own text changes (e.g. after the migration
117
+ // write lands, or the user opens a different orphaned file) -- `files`
118
+ // changes on every keystroke elsewhere and would otherwise loop.
119
+ // eslint-disable-next-line react-hooks/exhaustive-deps
120
+ }, [text, isBlueprintFile]);
121
+ }
66
122
  export function SceneEditor({
67
123
  path,
68
124
  text,
69
125
  files,
70
126
  sprites,
71
127
  onChange,
128
+ onChangeFile,
72
129
  onToggleFiles,
73
130
  filesOpen,
74
131
  headerHost,
75
132
  selectedActorIds,
76
- onSelectActorIds,
133
+ onSelectActorIds: onSelectActorIdsRaw,
77
134
  multiSelectMode,
78
135
  onSetMultiSelectMode,
79
136
  }) {
@@ -84,18 +141,54 @@ export function SceneEditor({
84
141
  const selectedActorIdsRef = useRef(selectedActorIds);
85
142
  selectedActorIdsRef.current = selectedActorIds;
86
143
  const [isPlaying, setIsPlaying] = useState(false);
144
+ // Hotbar blueprint selection (tap a slot -> inspect/edit the blueprint in
145
+ // the sidebar; drag remains the placement gesture). Mutually exclusive with
146
+ // actor selection: ANY actor-selection change -- including clearing it via
147
+ // empty-canvas click or Escape -- also drops the blueprint selection, which
148
+ // is why every internal caller goes through the wrapper below.
149
+ const [selectedBlueprintPath, setSelectedBlueprintPath] = useState(null);
150
+ const onSelectActorIds = useCallback(
151
+ (ids) => {
152
+ setSelectedBlueprintPath(null);
153
+ onSelectActorIdsRaw(ids);
154
+ },
155
+ [onSelectActorIdsRaw]
156
+ );
157
+ const onSelectBlueprint = useCallback(
158
+ (blueprintPath) => {
159
+ onSelectActorIdsRaw([]);
160
+ onSetMultiSelectMode(false);
161
+ setSelectedBlueprintPath(blueprintPath);
162
+ },
163
+ [onSelectActorIdsRaw, onSetMultiSelectMode]
164
+ );
165
+ const isBlueprintFile = isBlueprintPath(path);
166
+ // Working state for an in-progress hotbar drag-placement (see `dragPlace`
167
+ // below). A ref, not state: pointer moves can outpace re-renders.
168
+ const dragPlaceRef = useRef(null);
87
169
  const history = useEditHistory(text, onChange, path);
88
170
  // Disabled during play: the header undo/redo buttons are already disabled
89
171
  // then, but without this the keyboard shortcut could still rewrite the
90
172
  // scene file out from under the running play-mode runtime.
91
173
  useUndoRedoShortcuts(history, !isPlaying);
92
- const showMulti = selectedActorIds.length > 1 || multiSelectMode;
174
+ const showMulti = !isBlueprintFile && (selectedActorIds.length > 1 || multiSelectMode);
93
175
  const inspectorSheet = useSelectionInspectorSheet(true);
94
- const { value: sceneData, error } = parseJsonFile(path, text);
176
+ const { value: parsedSceneData, error } = parseJsonFile(path, text);
177
+ const sceneData = withTemplateId(parsedSceneData, isBlueprintFile);
178
+ // Sparse per-instance overrides merged with each actor's blueprint template
179
+ // -- what hit-testing, drag-start snapshots, and the inspector should read.
180
+ // Never the basis for a write (see engine/blueprint.js).
181
+ const previewSceneData = sceneData ? makeScene(sceneData, behaviorClasses, sprites, files).data : null;
182
+ const serialize = useCallback(
183
+ (next) => formatJson(isBlueprintFile ? stripTemplateId(next) : next),
184
+ [isBlueprintFile]
185
+ );
95
186
  const getRuntime = useCallback(() => runtimeRef.current, []);
187
+ useMigrateOrphanActors({ isBlueprintFile, sceneData, files, text, onChange });
96
188
  useScenePlayLoop({
97
189
  sceneData,
98
190
  sprites,
191
+ files,
99
192
  isPlaying,
100
193
  text,
101
194
  canvasRef,
@@ -110,25 +203,28 @@ export function SceneEditor({
110
203
  canvasRef,
111
204
  sceneData,
112
205
  sprites,
206
+ files,
113
207
  isPlaying,
114
208
  selectedActorIds,
115
209
  onSelectActorIds,
116
- multiSelectMode,
210
+ multiSelectMode: showMulti && multiSelectMode,
117
211
  onSetMultiSelectMode,
118
212
  marqueeRef,
119
213
  editCameraRef,
120
- applyScene: (next) => onChange(formatJson(next)),
214
+ applyScene: (next) => onChange(serialize(next)),
121
215
  recordSceneSnapshot: history.recordSnapshot,
122
216
  });
123
217
  const playPointer = usePlayPointerGesture({ canvasRef, runtimeRef });
124
218
  useSelectionKeyboard({
125
219
  sceneData,
126
220
  selectedActorIds,
221
+ selectedBlueprintPath,
127
222
  onSelectActorIds,
128
223
  multiSelectMode,
129
224
  onSetMultiSelectMode,
130
225
  isPlaying,
131
- commitScene: (next) => history.commit(formatJson(next)),
226
+ isBlueprintFile,
227
+ commitScene: (next) => history.commit(serialize(next)),
132
228
  });
133
229
  if (!sceneData) {
134
230
  return (
@@ -145,37 +241,194 @@ export function SceneEditor({
145
241
  </>
146
242
  );
147
243
  }
148
- const selectedActor =
244
+ const rawSelectedActor =
149
245
  selectedActorIds.length === 1
150
246
  ? sceneData.actors.find((actor) => actor.id === selectedActorIds[0])
151
247
  : undefined;
248
+ const selectedActor =
249
+ selectedActorIds.length === 1
250
+ ? previewSceneData.actors.find((actor) => actor.id === selectedActorIds[0])
251
+ : undefined;
252
+ const selectedBlueprintName = rawSelectedActor?.blueprint
253
+ ? getBlueprintTemplate(files, rawSelectedActor.blueprint)?.name ?? basename(rawSelectedActor.blueprint)
254
+ : undefined;
255
+ // Hotbar-selected blueprint, resolved fresh from live files each render. A
256
+ // selection whose file vanished (cascade delete, external edit) degrades to
257
+ // "nothing selected" rather than a broken inspector.
258
+ const hotbarBlueprint =
259
+ !isBlueprintFile && selectedBlueprintPath
260
+ ? getBlueprintTemplate(files, selectedBlueprintPath)
261
+ : null;
262
+ // Template edits from the sidebar write the BLUEPRINT file, not this scene:
263
+ // optimistic fold into live files state (instant merged-instance updates
264
+ // here) + debounced writeFile via SingleEditor's saver, which also makes
265
+ // `shouldSkipPath` ignore the fs echo of our own write. Same plain
266
+ // per-property merge semantics as editing the blueprint's own file. Outside
267
+ // this scene file's undo history (accepted v1 cross-file gap, like fork).
268
+ const updateBlueprintTemplate = (mutate) => {
269
+ if (!selectedBlueprintPath || !hotbarBlueprint) return;
270
+ const components = structuredClone(hotbarBlueprint.components);
271
+ mutate(components);
272
+ const nextText = setBlueprintTemplateComponents(files, selectedBlueprintPath, components);
273
+ if (nextText == null) return;
274
+ if (onChangeFile) onChangeFile(selectedBlueprintPath, nextText);
275
+ else void writeFile(selectedBlueprintPath, nextText);
276
+ };
277
+ const blueprintActions = {
278
+ setComponent: (behaviorName, nextProps) =>
279
+ updateBlueprintTemplate((components) => {
280
+ components[behaviorName] = { ...(components[behaviorName] ?? {}), ...nextProps };
281
+ }),
282
+ addBehavior: (behaviorName) => {
283
+ const Behavior = findBehaviorClass(behaviorName);
284
+ if (!Behavior) return;
285
+ updateBlueprintTemplate((components) => {
286
+ components[behaviorName] = { ...Behavior.defaultProps };
287
+ });
288
+ },
289
+ removeBehavior: (behaviorName) =>
290
+ updateBlueprintTemplate((components) => {
291
+ delete components[behaviorName];
292
+ }),
293
+ // Renaming writes only the blueprint file's top-level `name` (not this
294
+ // scene), so it flows through the same optimistic files update + debounced
295
+ // writeFile path as template edits above.
296
+ rename: (name) => {
297
+ if (!selectedBlueprintPath) return;
298
+ const nextText = setBlueprintName(files, selectedBlueprintPath, name);
299
+ if (nextText == null) return;
300
+ if (onChangeFile) onChangeFile(selectedBlueprintPath, nextText);
301
+ else void writeFile(selectedBlueprintPath, nextText);
302
+ },
303
+ };
152
304
  const actions = makeSceneActions({
153
305
  sceneData,
306
+ files,
307
+ serialize,
154
308
  commit: history.commit,
155
309
  selectedActorIds,
156
310
  onSelectActorIds,
311
+ isBlueprintFile,
157
312
  });
158
313
  const snapSettings = getSnapSettings(sceneData);
159
314
  const stageWrapStyle = {
160
315
  backgroundColor: darkenSceneBackground(sceneData.background),
161
316
  };
162
317
  const updateSceneSettings = (nextScene) => {
163
- history.commit(formatJson(setSceneSettings(sceneData, nextScene)));
318
+ history.commit(serialize(setSceneSettings(sceneData, nextScene)));
164
319
  };
165
320
  const updateSceneEditorSettings = (nextEditor) => {
166
- history.commit(formatJson(setSceneEditorSettings(sceneData, nextEditor)));
321
+ history.commit(serialize(setSceneEditorSettings(sceneData, nextEditor)));
322
+ };
323
+ const onAddActor = () => {
324
+ const { sceneData: next, newId, blueprintFile, drawingFile } = addActorWithBlueprint(
325
+ sceneData,
326
+ files
327
+ );
328
+ void writeFile(drawingFile.path, drawingFile.text);
329
+ void writeFile(blueprintFile.path, blueprintFile.text);
330
+ history.commit(serialize(next));
331
+ onSelectActorIds([newId]);
332
+ };
333
+ // Drag-out from the hotbar places a REAL instance the moment the pointer
334
+ // enters the stage, so the rest of the drag is a normal grid-snapped actor
335
+ // move and the drop is just "let go" (castle-client belt behavior). One
336
+ // undo entry per gesture: a snapshot at entry, then plain onChange while
337
+ // the drag is live. Leaving the stage removes the actor again (back to the
338
+ // floating preview), which lands the text back at the snapshot -- the
339
+ // history's no-op trimming keeps undo clean. `dragPlaceRef` carries the
340
+ // working scene between pointer events so a move never applies to a parse
341
+ // that predates the placement.
342
+ const dragPlace = {
343
+ enter: (blueprintPath, position) => {
344
+ history.recordSnapshot();
345
+ const { sceneData: next, newId } = placeBlueprintInstance(sceneData, files, blueprintPath, position, snapSettings);
346
+ const layout = next.actors.find((actor) => actor.id === newId)?.components?.Layout ?? {};
347
+ dragPlaceRef.current = { actorId: newId, blueprintPath, sceneData: next, x: layout.x, y: layout.y };
348
+ onChange(serialize(next));
349
+ onSelectActorIds([newId]);
350
+ },
351
+ move: (position) => {
352
+ const drag = dragPlaceRef.current;
353
+ if (!drag) return;
354
+ const { x, y } = blueprintDropXY(files, drag.blueprintPath, position, snapSettings);
355
+ if (x === drag.x && y === drag.y) return;
356
+ const next = structuredClone(drag.sceneData);
357
+ const actor = next.actors.find((candidate) => candidate.id === drag.actorId);
358
+ if (!actor) return;
359
+ actor.components.Layout = { ...actor.components.Layout, x, y };
360
+ Object.assign(drag, { sceneData: next, x, y });
361
+ onChange(serialize(next));
362
+ },
363
+ leave: () => {
364
+ const drag = dragPlaceRef.current;
365
+ if (!drag) return;
366
+ dragPlaceRef.current = null;
367
+ onChange(serialize(removeActors(drag.sceneData, [drag.actorId])));
368
+ onSelectActorIds([]);
369
+ },
370
+ drop: () => {
371
+ const drag = dragPlaceRef.current;
372
+ if (!drag) return;
373
+ dragPlaceRef.current = null;
374
+ onSelectActorIds([drag.actorId]);
375
+ },
376
+ };
377
+ const onDeleteBlueprint = (blueprintPath) => {
378
+ // Every write here targets a plain `scenes/*.scene` (cascaded instance
379
+ // removal) or the blueprint file itself (tombstoned) -- never a
380
+ // blueprint's own template id, so no `serialize`/strip step is needed.
381
+ for (const write of cascadeDeleteBlueprint(files, blueprintPath)) {
382
+ if (write.path === path) history.commit(write.text);
383
+ else void writeFile(write.path, write.text);
384
+ }
385
+ onSelectActorIds([]);
167
386
  };
168
- const inspectorLabel = showMulti ? 'Multi-select' : selectedActor ? 'Inspector' : 'Scene';
169
- const inspectorHint = showMulti
170
- ? `${selectedActorIds.length} actor${selectedActorIds.length === 1 ? '' : 's'} selected`
171
- : selectedActor
172
- ? `actor ${selectedActor.id}`
173
- : 'scene settings';
387
+ const onForkSelection = () => {
388
+ if (isBlueprintFile || !rawSelectedActor?.blueprint) return;
389
+ const result = forkActorToBlueprint(files, behaviorClasses, sceneData, rawSelectedActor.id);
390
+ if (!result) return;
391
+ void writeFile(result.blueprintFile.path, result.blueprintFile.text);
392
+ history.commit(serialize(result.sceneData));
393
+ };
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';
174
412
  return (
175
413
  <>
176
414
  <EditorHeader
177
- title={sceneData.name ?? basename(path)}
178
- subtitle={isPlaying ? 'WASD / arrows' : `${sceneData.actors.length} actors`}
415
+ title={
416
+ isBlueprintFile ? (
417
+ <span className={styles.editorHeaderTitleRow}>
418
+ <span className={styles.blueprintBadge}>Blueprint</span>
419
+ {sceneData.name ?? basename(path)}
420
+ </span>
421
+ ) : (
422
+ sceneData.name ?? basename(path)
423
+ )
424
+ }
425
+ subtitle={
426
+ isPlaying
427
+ ? 'WASD / arrows'
428
+ : isBlueprintFile
429
+ ? 'editing template -- changes apply to every placed instance'
430
+ : `${sceneData.actors.length} actors`
431
+ }
179
432
  right={
180
433
  <HeaderPlaybackButtons
181
434
  isPlaying={isPlaying}
@@ -190,18 +443,20 @@ export function SceneEditor({
190
443
  <EditorBody>
191
444
  <div className={styles.sceneWorkspace}>
192
445
  <div className={styles.sceneTools}>
193
- <BlueprintHotbar
194
- sceneData={sceneData}
195
- files={files}
196
- sprites={sprites}
197
- canvasRef={canvasRef}
198
- editCameraRef={editCameraRef}
199
- selectedActorIds={selectedActorIds}
200
- onSelectActorIds={onSelectActorIds}
201
- isPlaying={isPlaying}
202
- onChange={onChange}
203
- history={history}
204
- />
446
+ {isBlueprintFile ? null : (
447
+ <BlueprintLibrary
448
+ files={files}
449
+ sprites={sprites}
450
+ canvasRef={canvasRef}
451
+ editCameraRef={editCameraRef}
452
+ isPlaying={isPlaying}
453
+ selectedBlueprintPath={selectedBlueprintPath}
454
+ onSelectBlueprint={onSelectBlueprint}
455
+ onAddActor={onAddActor}
456
+ dragPlace={dragPlace}
457
+ onDeleteBlueprint={onDeleteBlueprint}
458
+ />
459
+ )}
205
460
  </div>
206
461
  <div className={styles.stageWrap} style={stageWrapStyle}>
207
462
  <div className={styles.stageCard} data-castle-card>
@@ -250,12 +505,13 @@ export function SceneEditor({
250
505
  canvasRef={canvasRef}
251
506
  editCameraRef={editCameraRef}
252
507
  sceneData={sceneData}
508
+ previewSceneData={previewSceneData}
253
509
  selectedActorIds={selectedActorIds}
254
510
  snap={snapSettings}
255
- onArrange={actions.arrangeSelection}
256
- onClone={actions.duplicateSelection}
257
- onDelete={actions.deleteSelection}
258
- applyScene={(next) => onChange(formatJson(next))}
511
+ onArrange={isBlueprintFile ? undefined : actions.arrangeSelection}
512
+ onClone={isBlueprintFile ? undefined : actions.duplicateSelection}
513
+ onDelete={isBlueprintFile ? undefined : actions.deleteSelection}
514
+ applyScene={(next) => onChange(serialize(next))}
259
515
  recordSnapshot={history.recordSnapshot}
260
516
  />
261
517
  )}
@@ -266,7 +522,17 @@ export function SceneEditor({
266
522
  <SheetGrabHandle label={inspectorLabel} hint={inspectorHint} />
267
523
  </div>
268
524
  <div className={cx(styles.sheetBody, styles.inspectorBody)}>
269
- {showMulti ? (
525
+ {hotbarBlueprint ? (
526
+ <BlueprintInspector
527
+ name={hotbarBlueprint.name}
528
+ template={hotbarBlueprint}
529
+ files={files}
530
+ onRename={blueprintActions.rename}
531
+ onSetComponent={blueprintActions.setComponent}
532
+ onAddBehavior={blueprintActions.addBehavior}
533
+ onRemoveBehavior={blueprintActions.removeBehavior}
534
+ />
535
+ ) : showMulti ? (
270
536
  <MultiSelectInspector
271
537
  count={selectedActorIds.length}
272
538
  onDeselectAll={() => {
@@ -277,10 +543,13 @@ export function SceneEditor({
277
543
  ) : selectedActor ? (
278
544
  <ActorInspector
279
545
  selectedActor={selectedActor}
546
+ rawActor={rawSelectedActor}
547
+ blueprintName={selectedBlueprintName}
280
548
  files={files}
281
549
  onSetComponent={actions.updateComponent}
282
550
  onAddBehavior={actions.addBehavior}
283
551
  onRemoveBehavior={actions.removeBehavior}
552
+ onFork={isBlueprintFile ? undefined : onForkSelection}
284
553
  />
285
554
  ) : (
286
555
  <SceneInspector
@@ -328,355 +597,21 @@ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
328
597
  </>
329
598
  );
330
599
  }
331
- // Blueprint hotbar -- lightweight actor stamps saved in scene.editor.blueprints.
332
- function BlueprintHotbar({
333
- sceneData,
334
- files,
335
- sprites,
336
- canvasRef,
337
- editCameraRef,
338
- selectedActorIds,
339
- onSelectActorIds,
340
- isPlaying,
341
- onChange,
342
- history,
343
- }) {
344
- const blueprints = sceneData.editor?.blueprints ?? [];
345
- const dragRef = useRef(null);
346
- const suppressClickRef = useRef(false);
347
- const [dragPreview, setDragPreview] = useState(null);
348
- // Right-click context menu anchored at the cursor: { blueprint, x, y }.
349
- const [contextMenu, setContextMenu] = useState(null);
350
- const closeContextMenu = useCallback(() => setContextMenu(null), []);
351
- const onAdd = () => {
352
- const { sceneData: next, newId } = addActor(sceneData, files, sprites);
353
- history.recordSnapshot();
354
- onChange(formatJson(next));
355
- onSelectActorIds([newId]);
356
- };
357
- const onSaveSelection = () => {
358
- const next = addSelectedActorBlueprint(sceneData, selectedActorIds);
359
- if (next === sceneData) return;
360
- history.commit(formatJson(next));
361
- };
362
- const onStamp = (blueprint, position) => {
363
- const { sceneData: next, newId } = stampActorBlueprint(sceneData, blueprint, position);
364
- if (next === sceneData) return;
365
- history.commit(formatJson(next));
366
- onSelectActorIds([newId]);
367
- };
368
- const onRemoveBlueprint = (blueprintId) => {
369
- const next = removeActorBlueprint(sceneData, blueprintId);
370
- if (next === sceneData) return;
371
- history.commit(formatJson(next));
372
- };
373
- const onBlueprintContextMenu = (event, blueprint) => {
374
- if (isPlaying) return;
375
- event.preventDefault();
376
- event.stopPropagation();
377
- setContextMenu({ blueprint, x: event.clientX, y: event.clientY });
378
- };
379
- const onBlueprintPointerDown = (event, blueprint) => {
380
- if (isPlaying || event.button !== 0) return;
381
- event.preventDefault();
382
- const drag = {
383
- blueprint,
384
- pointerId: event.pointerId,
385
- startX: event.clientX,
386
- startY: event.clientY,
387
- moved: false,
388
- };
389
- dragRef.current = drag;
390
- try {
391
- event.currentTarget.setPointerCapture(event.pointerId);
392
- } catch {
393
- // Window listeners still finish the drag if capture is unavailable.
394
- }
395
- const onMove = (moveEvent) => {
396
- if (moveEvent.pointerId !== drag.pointerId) return;
397
- if (!drag.moved && Math.hypot(moveEvent.clientX - drag.startX, moveEvent.clientY - drag.startY) > DRAG_THRESHOLD) {
398
- drag.moved = true;
399
- }
400
- if (drag.moved) {
401
- setDragPreview({
402
- blueprint,
403
- x: moveEvent.clientX,
404
- y: moveEvent.clientY,
405
- overStage: Boolean(eventToStagePoint(moveEvent, canvasRef, editCameraRef)),
406
- });
407
- }
408
- };
409
- const onUp = (upEvent) => {
410
- if (upEvent.pointerId !== drag.pointerId) return;
411
- window.removeEventListener('pointermove', onMove);
412
- window.removeEventListener('pointerup', onUp);
413
- window.removeEventListener('pointercancel', onUp);
414
- dragRef.current = null;
415
- setDragPreview(null);
416
- if (!drag.moved) return;
417
- suppressClickRef.current = true;
418
- const position = eventToStagePoint(upEvent, canvasRef, editCameraRef);
419
- if (position) onStamp(blueprint, position);
420
- };
421
- window.addEventListener('pointermove', onMove);
422
- window.addEventListener('pointerup', onUp);
423
- window.addEventListener('pointercancel', onUp);
424
- };
425
- return (
426
- <div className={styles.bpHotbar} aria-label="Blueprints">
427
- <button
428
- type="button"
429
- className={styles.bpActionSlot}
430
- aria-label="Save selected actor as blueprint"
431
- title="Save selected actor as blueprint"
432
- onClick={onSaveSelection}
433
- disabled={isPlaying || selectedActorIds.length !== 1}>
434
- <Icon name="stamp" />
435
- </button>
436
- <button
437
- type="button"
438
- className={styles.bpActionSlot}
439
- aria-label="Add actor"
440
- title="Add actor"
441
- onClick={onAdd}
442
- disabled={isPlaying}>
443
- <span className={styles.dashedSquareIcon} aria-hidden="true" />
444
- </button>
445
- {blueprints.map((blueprint) => (
446
- <button
447
- key={blueprint.id}
448
- type="button"
449
- className={styles.bpSlot}
450
- title={`Add ${blueprint.name}`}
451
- disabled={isPlaying}
452
- onPointerDown={(event) => onBlueprintPointerDown(event, blueprint)}
453
- onContextMenu={(event) => onBlueprintContextMenu(event, blueprint)}
454
- onClick={() => {
455
- if (suppressClickRef.current) {
456
- suppressClickRef.current = false;
457
- return;
458
- }
459
- onStamp(blueprint);
460
- }}>
461
- <BlueprintThumbnail blueprint={blueprint} sprites={sprites} />
462
- </button>
463
- ))}
464
- {dragPreview ? (
465
- <BlueprintDragPreview preview={dragPreview} sprites={sprites} />
466
- ) : null}
467
- {contextMenu ? (
468
- <BlueprintContextMenu
469
- x={contextMenu.x}
470
- y={contextMenu.y}
471
- onRemove={() => onRemoveBlueprint(contextMenu.blueprint.id)}
472
- onClose={closeContextMenu}
473
- />
474
- ) : null}
475
- </div>
476
- );
477
- }
478
- // Cursor-anchored right-click menu for a hotbar blueprint. Mirrors the
479
- // pxArtTimeline ContextMenu: reuses the shared `selArrangeMenu`/`selArrangeItem`
480
- // chrome, fixed-positions at the click point with viewport clamping, and closes
481
- // on outside pointerdown or Escape.
482
- function BlueprintContextMenu({ x, y, onRemove, onClose }) {
483
- const ref = useRef(null);
484
- const [pos, setPos] = useState({ left: x, top: y });
485
- useLayoutEffect(() => {
486
- const el = ref.current;
487
- if (!el) return;
488
- const rect = el.getBoundingClientRect();
489
- const margin = 8;
490
- const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
491
- const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
492
- setPos({ left: Math.min(x, maxLeft), top: Math.min(y, maxTop) });
493
- }, [x, y]);
494
- useEffect(() => {
495
- const onPointerDown = (event) => {
496
- if (!ref.current?.contains(event.target)) onClose();
497
- };
498
- const onKeyDown = (event) => {
499
- if (event.key === 'Escape') onClose();
500
- };
501
- window.addEventListener('pointerdown', onPointerDown);
502
- window.addEventListener('keydown', onKeyDown);
503
- return () => {
504
- window.removeEventListener('pointerdown', onPointerDown);
505
- window.removeEventListener('keydown', onKeyDown);
506
- };
507
- }, [onClose]);
508
- return (
509
- <div
510
- ref={ref}
511
- className={styles.selArrangeMenu}
512
- role="menu"
513
- aria-label="Blueprint"
514
- style={{ position: 'fixed', left: pos.left, top: pos.top, transform: 'none' }}
515
- onPointerDown={(event) => event.stopPropagation()}
516
- onContextMenu={(event) => event.preventDefault()}>
517
- <button
518
- type="button"
519
- role="menuitem"
520
- className={styles.selArrangeItem}
521
- onClick={() => {
522
- onRemove();
523
- onClose();
524
- }}>
525
- Remove blueprint
526
- </button>
527
- </div>
528
- );
529
- }
530
- function BlueprintDragPreview({ preview, sprites }) {
531
- const layout = preview.blueprint.actor?.components?.Layout;
532
- const width = Math.max(24, Math.min(80, layout?.width ?? 48));
533
- const height = Math.max(24, Math.min(80, layout?.height ?? 48));
534
- return (
535
- <div
536
- className={cx(styles.bpDragPreview, preview.overStage && styles.bpDragPreviewOverStage)}
537
- style={{
538
- left: preview.x,
539
- top: preview.y,
540
- width,
541
- height,
542
- transform: `translate(-50%, -50%) rotate(${layout?.rotation ?? 0}deg)`,
543
- }}>
544
- <BlueprintThumbnail blueprint={preview.blueprint} sprites={sprites} preview />
545
- </div>
546
- );
547
- }
548
- function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
549
- const canvasRef = useRef(null);
550
- const spritePath = blueprint.actor?.components?.Sprite?.file;
551
- const sprite = spritePath ? sprites?.[spritePath] : null;
552
- useEffect(() => {
553
- const canvas = canvasRef.current;
554
- if (!canvas || !sprite) return;
555
- // A .pxart blueprint renders its first frame via the SDK.
556
- renderSpriteFrame(sprite, 0, canvas);
557
- }, [sprite]);
558
- if (!sprite) {
559
- return (
560
- <span className={styles.bpSlotFallback}>
561
- <Icon name="clone" />
562
- </span>
563
- );
564
- }
565
- return (
566
- <canvas
567
- ref={canvasRef}
568
- className={cx(styles.bpSlotThumb, preview && styles.bpPreviewThumb)}
569
- aria-hidden="true"
570
- />
571
- );
572
- }
573
- function addSelectedActorBlueprint(sceneData, selectedActorIds) {
574
- if (selectedActorIds.length !== 1) return sceneData;
575
- const actor = sceneData.actors.find((candidate) => candidate.id === selectedActorIds[0]);
576
- if (!actor) return sceneData;
577
- const next = structuredClone(sceneData);
578
- const copy = structuredClone(actor);
579
- delete copy.id;
580
- if (copy.components?.Layout) {
581
- copy.components.Layout = {
582
- ...copy.components.Layout,
583
- x: 0,
584
- y: 0,
585
- };
586
- }
587
- const blueprints = [...(next.editor?.blueprints ?? [])];
588
- const name = makeBlueprintName(actor, blueprints.length);
589
- blueprints.push({
590
- id: makeBlueprintId(blueprints),
591
- name,
592
- actor: copy,
593
- });
594
- next.editor = {
595
- ...(next.editor ?? {}),
596
- blueprints,
597
- };
598
- return next;
599
- }
600
- function stampActorBlueprint(sceneData, blueprint, position) {
601
- if (!blueprint?.actor) return { sceneData, newId: null };
602
- const next = structuredClone(sceneData);
603
- const existingIds = new Set(next.actors.map((actor) => actor.id));
604
- const actor = structuredClone(blueprint.actor);
605
- const newId = makeActorId(existingIds);
606
- actor.id = newId;
607
- actor.components = actor.components ?? {};
608
- const layout = actor.components.Layout ?? { width: 64, height: 64, z: 0, rotation: 0 };
609
- actor.components.Layout = {
610
- ...layout,
611
- x: Math.round((position?.x ?? cardSize.width / 2) - layout.width / 2),
612
- y: Math.round((position?.y ?? cardSize.height / 2) - layout.height / 2),
613
- };
614
- next.actors.push(actor);
615
- return { sceneData: next, newId };
616
- }
617
- function removeActorBlueprint(sceneData, blueprintId) {
618
- const blueprints = sceneData.editor?.blueprints ?? [];
619
- if (!blueprints.some((blueprint) => blueprint.id === blueprintId)) return sceneData;
620
- const next = structuredClone(sceneData);
621
- next.editor = {
622
- ...(next.editor ?? {}),
623
- blueprints: (next.editor?.blueprints ?? []).filter(
624
- (blueprint) => blueprint.id !== blueprintId
625
- ),
626
- };
627
- return next;
628
- }
629
- function eventToStagePoint(event, canvasRef, editCameraRef) {
630
- const canvas = canvasRef.current;
631
- if (!canvas) return null;
632
- const rect = canvas.getBoundingClientRect();
633
- if (
634
- event.clientX < rect.left ||
635
- event.clientX > rect.right ||
636
- event.clientY < rect.top ||
637
- event.clientY > rect.bottom
638
- ) {
639
- return null;
640
- }
641
- const raw = screenToCard(canvas, event.clientX, event.clientY);
642
- const camera = editCameraRef.current ?? { x: 0, y: 0 };
643
- return {
644
- x: raw.x + camera.x,
645
- y: raw.y + camera.y,
646
- };
647
- }
648
- function makeBlueprintName(actor, index) {
649
- const spriteFile = actor.components?.Sprite?.file;
650
- if (spriteFile) return basename(spriteFile).replace(/\.pxart$/i, '');
651
- return `Actor ${index + 1}`;
652
- }
653
- function makeBlueprintId(blueprints) {
654
- const existing = new Set(blueprints.map((blueprint) => blueprint.id));
655
- for (let index = 1; index < 1000; index++) {
656
- const candidate = `blueprint-${index}`;
657
- if (!existing.has(candidate)) return candidate;
658
- }
659
- return `blueprint-${Date.now()}`;
660
- }
661
- function makeActorId(existingIds) {
662
- for (let attempt = 0; attempt < 64; attempt++) {
663
- const candidate = Math.floor(Math.random() * 0xffffffff)
664
- .toString(16)
665
- .padStart(8, '0');
666
- if (!existingIds.has(candidate)) return candidate;
667
- }
668
- return `actor-${Date.now()}`;
669
- }
670
- function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorIds }) {
600
+ // `updateComponent` goes through `setInstanceComponent`, which already
601
+ // branches on whether the target actor has a `blueprint` ref: for a
602
+ // blueprint's own template actor (no ref) it's a plain merge; for a normal
603
+ // instance it diffs each patched prop against the blueprint and records only
604
+ // what actually differs, dropping an override that becomes redundant. Add/
605
+ // remove-behavior stay whole-component operations straight against the
606
+ // instance's own sparse `components` -- there's no per-property baseline to
607
+ // diff when a component doesn't exist on one side at all.
608
+ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile }) {
671
609
  function commitScene(next, options) {
672
- commit(formatJson(next), options);
610
+ commit(serialize(next), options);
673
611
  }
674
612
  return {
675
- // Keyed by the changed field so consecutive edits to the SAME field (e.g.
676
- // dragging a ColorField or clicking a NumberField stepper) coalesce into
677
- // one undo entry, while edits to a different field stay separate steps.
678
613
  updateComponent: (actorId, behaviorName, nextProps) =>
679
- commitScene(setActorComponent(sceneData, actorId, behaviorName, nextProps), {
614
+ commitScene(setInstanceComponent(files, behaviorClasses, sceneData, actorId, behaviorName, nextProps), {
680
615
  coalesceKey: componentCoalesceKey(actorId, behaviorName, nextProps),
681
616
  }),
682
617
  addBehavior: (actorId, behaviorName) => {
@@ -689,18 +624,18 @@ function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorId
689
624
  removeBehavior: (actorId, behaviorName) =>
690
625
  commitScene(removeActorComponent(sceneData, actorId, behaviorName)),
691
626
  deleteSelection: () => {
692
- if (selectedActorIds.length === 0) return;
627
+ if (isBlueprintFile || selectedActorIds.length === 0) return;
693
628
  commitScene(removeActors(sceneData, selectedActorIds));
694
629
  onSelectActorIds([]);
695
630
  },
696
631
  duplicateSelection: () => {
697
- if (selectedActorIds.length === 0) return;
632
+ if (isBlueprintFile || selectedActorIds.length === 0) return;
698
633
  const { sceneData: next, newIds } = duplicateActors(sceneData, selectedActorIds);
699
634
  commitScene(next);
700
635
  if (newIds.length) onSelectActorIds(newIds);
701
636
  },
702
637
  arrangeSelection: (action) => {
703
- if (selectedActorIds.length === 0) return;
638
+ if (isBlueprintFile || selectedActorIds.length === 0) return;
704
639
  const next = arrangeActors(sceneData, selectedActorIds, action);
705
640
  if (next !== sceneData) commitScene(next);
706
641
  },
@@ -755,20 +690,10 @@ function parseHexColor(value) {
755
690
  };
756
691
  }
757
692
  function useSelectionInspectorSheet(open) {
758
- const [snap, setSnap] = useState('high');
759
- // Inspector visibility is driven by selection / multi-mode. Snap state
760
- // persists across selections so switching keeps the user's chosen snap.
761
- const effectiveSnap = open ? snap : 'hidden';
762
- return useMobileSheet({
763
- snap: effectiveSnap,
764
- baseClassName: styles.inspector,
765
- onTransition: (direction) => {
766
- if (!open) return;
767
- if (direction === 'tap') setSnap((prev) => (prev === 'high' ? 'low' : 'high'));
768
- else if (direction === 'down') setSnap('low');
769
- else if (direction === 'up') setSnap('high');
770
- },
771
- });
693
+ // Inspector visibility is driven by selection / multi-mode; the sheet hook
694
+ // 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 });
772
697
  }
773
698
  function useSelectionGesture(args) {
774
699
  const dragRef = useRef(null);
@@ -783,7 +708,7 @@ function useSelectionGesture(args) {
783
708
  const cam = current.editCameraRef.current;
784
709
  const point = { x: raw.x + cam.x, y: raw.y + cam.y };
785
710
  current.canvasRef.current.setPointerCapture(event.pointerId);
786
- const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
711
+ const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
787
712
  const actor = scene.actorAt(point.x, point.y);
788
713
  const drag = {
789
714
  pointerId: event.pointerId,
@@ -1178,7 +1103,7 @@ function finalizeMarquee(drag, current) {
1178
1103
  const m = current.marqueeRef.current;
1179
1104
  if (!m || !current.sceneData) return;
1180
1105
  if (m.width === 0 && m.height === 0) return;
1181
- const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
1106
+ const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
1182
1107
  const hits = scene.actorIdsInRect(m);
1183
1108
  const merged = new Set(drag.selectionBeforeMarquee);
1184
1109
  for (const id of hits) merged.add(id);
@@ -1207,21 +1132,22 @@ function useSelectionKeyboard(args) {
1207
1132
  const current = ref.current;
1208
1133
  if (current.isPlaying || isEditableTarget(event.target)) return;
1209
1134
  if (event.key === 'Escape') {
1210
- if (current.selectedActorIds.length || current.multiSelectMode) {
1135
+ if (current.selectedActorIds.length || current.multiSelectMode || current.selectedBlueprintPath) {
1211
1136
  event.preventDefault();
1137
+ // The wrapped setter also clears any hotbar blueprint selection.
1212
1138
  current.onSelectActorIds([]);
1213
1139
  current.onSetMultiSelectMode(false);
1214
1140
  }
1215
1141
  return;
1216
1142
  }
1217
1143
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
1218
- if (!current.sceneData) return;
1144
+ if (!current.sceneData || current.isBlueprintFile) return;
1219
1145
  event.preventDefault();
1220
1146
  current.onSelectActorIds(current.sceneData.actors.map((actor) => actor.id));
1221
1147
  return;
1222
1148
  }
1223
1149
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
1224
- if (!current.sceneData || current.selectedActorIds.length === 0) return;
1150
+ if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
1225
1151
  event.preventDefault();
1226
1152
  const { sceneData: next, newIds } = duplicateActors(
1227
1153
  current.sceneData,
@@ -1232,7 +1158,7 @@ function useSelectionKeyboard(args) {
1232
1158
  return;
1233
1159
  }
1234
1160
  if (event.key === 'Delete' || event.key === 'Backspace') {
1235
- if (!current.sceneData || current.selectedActorIds.length === 0) return;
1161
+ if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
1236
1162
  event.preventDefault();
1237
1163
  current.commitScene(removeActors(current.sceneData, current.selectedActorIds));
1238
1164
  current.onSelectActorIds([]);
@@ -1245,6 +1171,7 @@ function useSelectionKeyboard(args) {
1245
1171
  function useScenePlayLoop({
1246
1172
  sceneData,
1247
1173
  sprites,
1174
+ files,
1248
1175
  isPlaying,
1249
1176
  text,
1250
1177
  canvasRef,
@@ -1263,7 +1190,7 @@ function useScenePlayLoop({
1263
1190
  return;
1264
1191
  }
1265
1192
  if (!sceneData) return;
1266
- runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites).clone();
1193
+ runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites, files).clone();
1267
1194
  // eslint-disable-next-line react-hooks/exhaustive-deps
1268
1195
  }, [sprites, isPlaying, text, runtimeRef]);
1269
1196
  // Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
@@ -1288,7 +1215,7 @@ function useScenePlayLoop({
1288
1215
  : editViewportForZoom(editCameraRef.current.zoom);
1289
1216
  const scene = isPlaying
1290
1217
  ? runtimeRef.current
1291
- : makeScene(sceneData, behaviorClasses, sprites);
1218
+ : makeScene(sceneData, behaviorClasses, sprites, files);
1292
1219
  if (scene) {
1293
1220
  const snap = getSnapSettings(sceneData);
1294
1221
  if (isPlaying) scene.update(dt);
@@ -1315,7 +1242,7 @@ function useScenePlayLoop({
1315
1242
  raf = requestAnimationFrame(frame);
1316
1243
  return () => cancelAnimationFrame(raf);
1317
1244
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `text` proxies `sceneData` identity; resetting on every parse churns the loop and wipes runtime state.
1318
- }, [sprites, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
1245
+ }, [sprites, files, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
1319
1246
  }
1320
1247
  function useScenePlayKeys(runtimeRef) {
1321
1248
  useEffect(() => {
@@ -1406,7 +1333,56 @@ function SceneInspector({ sceneData, snapSettings, onChangeScene, onChangeEditor
1406
1333
  </Panel>
1407
1334
  );
1408
1335
  }
1409
- function ActorInspector({ selectedActor, files, onSetComponent, onAddBehavior, onRemoveBehavior }) {
1336
+ // Sidebar inspector for a hotbar-selected blueprint: badge + name header, then
1337
+ // the template's components through the same ActorInspector panels used for
1338
+ // actors -- the template is presented as a synthetic actor with no `blueprint`
1339
+ // ref, so ActorInspector treats it exactly like a blueprint file's template
1340
+ // (plain edits, no instance hint, Layout not removable). The action callbacks
1341
+ // drop the synthetic actor id and write the blueprint FILE (see
1342
+ // `updateBlueprintTemplate` in SceneEditor).
1343
+ function BlueprintInspector({
1344
+ name,
1345
+ template,
1346
+ files,
1347
+ onRename,
1348
+ onSetComponent,
1349
+ onAddBehavior,
1350
+ onRemoveBehavior,
1351
+ }) {
1352
+ const templateActor = { id: '__blueprint__', components: template.components };
1353
+ return (
1354
+ <>
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
+ />
1363
+ </div>
1364
+ <ActorInspector
1365
+ selectedActor={templateActor}
1366
+ rawActor={templateActor}
1367
+ files={files}
1368
+ onSetComponent={(actorId, behaviorName, nextProps) => onSetComponent(behaviorName, nextProps)}
1369
+ onAddBehavior={(actorId, behaviorName) => onAddBehavior(behaviorName)}
1370
+ onRemoveBehavior={(actorId, behaviorName) => onRemoveBehavior(behaviorName)}
1371
+ />
1372
+ </>
1373
+ );
1374
+ }
1375
+ function ActorInspector({
1376
+ selectedActor,
1377
+ rawActor,
1378
+ blueprintName,
1379
+ files,
1380
+ onSetComponent,
1381
+ onAddBehavior,
1382
+ onRemoveBehavior,
1383
+ onFork,
1384
+ }) {
1385
+ const isInstance = Boolean(rawActor?.blueprint);
1410
1386
  const presentNames = new Set(
1411
1387
  Object.entries(selectedActor.components)
1412
1388
  .filter(([, component]) => !!component)
@@ -1418,6 +1394,14 @@ function ActorInspector({ selectedActor, files, onSetComponent, onAddBehavior, o
1418
1394
  const behaviorEntries = Object.entries(selectedActor.components).filter((entry) => !!entry[1]);
1419
1395
  return (
1420
1396
  <>
1397
+ {isInstance ? (
1398
+ <div className={styles.instanceHint}>
1399
+ <span className={styles.instanceHintLabel}>Instance of {blueprintName}</span>
1400
+ {onFork ? (
1401
+ <IconButton icon="code-fork" label="New blueprint from this actor" onClick={onFork} />
1402
+ ) : null}
1403
+ </div>
1404
+ ) : null}
1421
1405
  <AddBehaviorPicker
1422
1406
  available={availableBehaviors}
1423
1407
  onAdd={(behaviorName) => onAddBehavior(selectedActor.id, behaviorName)}
@@ -1442,8 +1426,12 @@ function ActorInspector({ selectedActor, files, onSetComponent, onAddBehavior, o
1442
1426
  setComponent={(nextProps) => onSetComponent(selectedActor.id, behaviorName, nextProps)}
1443
1427
  />
1444
1428
  );
1445
- // Layout is core to every actor -- it cannot be removed.
1446
- const removable = behaviorName !== 'Layout';
1429
+ // Layout is core to every actor -- it cannot be removed. A component
1430
+ // an instance only has via its blueprint (no override of its own yet)
1431
+ // has nothing to remove either -- v1 has no per-instance "hide this
1432
+ // inherited component" concept (see propertyMeta punt list).
1433
+ const removable =
1434
+ behaviorName !== 'Layout' && (!isInstance || Boolean(rawActor.components?.[behaviorName]));
1447
1435
  const isLast = index === behaviorEntries.length - 1;
1448
1436
  // Trash renders before the panel so the panel stays the wrapper's
1449
1437
  // last child -- the panel's own `:last-child` border zeroes out, and