castle-web-cli 0.4.77 → 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 (53) 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/ide.js +2 -0
  6. package/dist/native/loop.d.ts +2 -0
  7. package/dist/native/loop.js +698 -0
  8. package/dist/native/openrouter.d.ts +55 -0
  9. package/dist/native/openrouter.js +354 -0
  10. package/dist/native/playtest-browser.d.ts +34 -0
  11. package/dist/native/playtest-browser.js +354 -0
  12. package/dist/native/playtest-executor.d.ts +3 -0
  13. package/dist/native/playtest-executor.js +156 -0
  14. package/dist/native/playtest.d.ts +131 -0
  15. package/dist/native/playtest.js +314 -0
  16. package/dist/native/tools.d.ts +38 -0
  17. package/dist/native/tools.js +630 -0
  18. package/dist/native/types.d.ts +40 -0
  19. package/dist/native/types.js +41 -0
  20. package/dist/serve.js +12 -0
  21. package/dist/shell/assets/{index-CvHiGhAV.js → index-CNT3KxJb.js} +37 -37
  22. package/dist/shell/assets/{index-QteLRDnK.css → index-RZrw5gQ2.css} +1 -1
  23. package/dist/shell/index.html +2 -2
  24. package/kits/basic-2d/CLAUDE.md +29 -3
  25. package/kits/basic-2d/behaviors/Layout.jsx +10 -0
  26. package/kits/basic-2d/behaviors/Sprite.jsx +16 -4
  27. package/kits/basic-2d/blueprints/cauldron.scene +22 -0
  28. package/kits/basic-2d/castle.json +5 -7
  29. package/kits/basic-2d/docs/pxart-format.md +83 -4
  30. package/kits/basic-2d/drawings/cauldron.pxart +113 -0
  31. package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
  32. package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
  33. package/kits/basic-2d/editors/PxArtEditor.jsx +68 -9
  34. package/kits/basic-2d/editors/SceneEditor.jsx +424 -418
  35. package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
  36. package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
  37. package/kits/basic-2d/editors/editorHistory.js +95 -17
  38. package/kits/basic-2d/editors/inspectorSheet.js +5 -19
  39. package/kits/basic-2d/editors/pixelEditorChrome.jsx +21 -8
  40. package/kits/basic-2d/editors/pixelInspector.jsx +39 -0
  41. package/kits/basic-2d/editors/pxArtEditorModel.js +15 -1
  42. package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
  43. package/kits/basic-2d/engine/blueprint.js +423 -0
  44. package/kits/basic-2d/engine/files.js +1 -1
  45. package/kits/basic-2d/engine/pxart.js +49 -2
  46. package/kits/basic-2d/engine/pxartSmooth.js +222 -0
  47. package/kits/basic-2d/engine/scene.js +29 -29
  48. package/kits/basic-2d/engine/ui.jsx +160 -21
  49. package/kits/basic-2d/engine/ui.module.css +263 -27
  50. package/kits/basic-2d/pnpm-workspace.yaml +3 -0
  51. package/kits/basic-2d/scenes/main.scene +3 -13
  52. package/package.json +2 -1
  53. 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,15 +141,54 @@ export function SceneEditor({
84
141
  const selectedActorIdsRef = useRef(selectedActorIds);
85
142
  selectedActorIdsRef.current = selectedActorIds;
86
143
  const [isPlaying, setIsPlaying] = useState(false);
87
- const history = useEditHistory(text, onChange);
88
- useUndoRedoShortcuts(history);
89
- const showMulti = selectedActorIds.length > 1 || multiSelectMode;
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);
169
+ const history = useEditHistory(text, onChange, path);
170
+ // Disabled during play: the header undo/redo buttons are already disabled
171
+ // then, but without this the keyboard shortcut could still rewrite the
172
+ // scene file out from under the running play-mode runtime.
173
+ useUndoRedoShortcuts(history, !isPlaying);
174
+ const showMulti = !isBlueprintFile && (selectedActorIds.length > 1 || multiSelectMode);
90
175
  const inspectorSheet = useSelectionInspectorSheet(true);
91
- 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
+ );
92
186
  const getRuntime = useCallback(() => runtimeRef.current, []);
187
+ useMigrateOrphanActors({ isBlueprintFile, sceneData, files, text, onChange });
93
188
  useScenePlayLoop({
94
189
  sceneData,
95
190
  sprites,
191
+ files,
96
192
  isPlaying,
97
193
  text,
98
194
  canvasRef,
@@ -107,25 +203,28 @@ export function SceneEditor({
107
203
  canvasRef,
108
204
  sceneData,
109
205
  sprites,
206
+ files,
110
207
  isPlaying,
111
208
  selectedActorIds,
112
209
  onSelectActorIds,
113
- multiSelectMode,
210
+ multiSelectMode: showMulti && multiSelectMode,
114
211
  onSetMultiSelectMode,
115
212
  marqueeRef,
116
213
  editCameraRef,
117
- applyScene: (next) => onChange(formatJson(next)),
214
+ applyScene: (next) => onChange(serialize(next)),
118
215
  recordSceneSnapshot: history.recordSnapshot,
119
216
  });
120
217
  const playPointer = usePlayPointerGesture({ canvasRef, runtimeRef });
121
218
  useSelectionKeyboard({
122
219
  sceneData,
123
220
  selectedActorIds,
221
+ selectedBlueprintPath,
124
222
  onSelectActorIds,
125
223
  multiSelectMode,
126
224
  onSetMultiSelectMode,
127
225
  isPlaying,
128
- commitScene: (next) => history.commit(formatJson(next)),
226
+ isBlueprintFile,
227
+ commitScene: (next) => history.commit(serialize(next)),
129
228
  });
130
229
  if (!sceneData) {
131
230
  return (
@@ -142,37 +241,194 @@ export function SceneEditor({
142
241
  </>
143
242
  );
144
243
  }
145
- const selectedActor =
244
+ const rawSelectedActor =
146
245
  selectedActorIds.length === 1
147
246
  ? sceneData.actors.find((actor) => actor.id === selectedActorIds[0])
148
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
+ };
149
304
  const actions = makeSceneActions({
150
305
  sceneData,
306
+ files,
307
+ serialize,
151
308
  commit: history.commit,
152
309
  selectedActorIds,
153
310
  onSelectActorIds,
311
+ isBlueprintFile,
154
312
  });
155
313
  const snapSettings = getSnapSettings(sceneData);
156
314
  const stageWrapStyle = {
157
315
  backgroundColor: darkenSceneBackground(sceneData.background),
158
316
  };
159
317
  const updateSceneSettings = (nextScene) => {
160
- history.commit(formatJson(setSceneSettings(sceneData, nextScene)));
318
+ history.commit(serialize(setSceneSettings(sceneData, nextScene)));
161
319
  };
162
320
  const updateSceneEditorSettings = (nextEditor) => {
163
- history.commit(formatJson(setSceneEditorSettings(sceneData, nextEditor)));
321
+ history.commit(serialize(setSceneEditorSettings(sceneData, nextEditor)));
164
322
  };
165
- const inspectorLabel = showMulti ? 'Multi-select' : selectedActor ? 'Inspector' : 'Scene';
166
- const inspectorHint = showMulti
167
- ? `${selectedActorIds.length} actor${selectedActorIds.length === 1 ? '' : 's'} selected`
168
- : selectedActor
169
- ? `actor ${selectedActor.id}`
170
- : 'scene settings';
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([]);
386
+ };
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';
171
412
  return (
172
413
  <>
173
414
  <EditorHeader
174
- title={sceneData.name ?? basename(path)}
175
- 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
+ }
176
432
  right={
177
433
  <HeaderPlaybackButtons
178
434
  isPlaying={isPlaying}
@@ -187,18 +443,20 @@ export function SceneEditor({
187
443
  <EditorBody>
188
444
  <div className={styles.sceneWorkspace}>
189
445
  <div className={styles.sceneTools}>
190
- <BlueprintHotbar
191
- sceneData={sceneData}
192
- files={files}
193
- sprites={sprites}
194
- canvasRef={canvasRef}
195
- editCameraRef={editCameraRef}
196
- selectedActorIds={selectedActorIds}
197
- onSelectActorIds={onSelectActorIds}
198
- isPlaying={isPlaying}
199
- onChange={onChange}
200
- history={history}
201
- />
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
+ )}
202
460
  </div>
203
461
  <div className={styles.stageWrap} style={stageWrapStyle}>
204
462
  <div className={styles.stageCard} data-castle-card>
@@ -206,13 +464,18 @@ export function SceneEditor({
206
464
  <canvas
207
465
  ref={canvasRef}
208
466
  className={cx(styles.stageCanvas, !isPlaying && styles.stageCanvasEdit)}
209
- onPointerDown={(event) =>
210
- isPlaying
211
- ? playPointer.onPointerDown(event)
212
- : panGesture.isSpacePanning()
213
- ? panGesture.onPointerDown(event)
214
- : gesture.onPointerDown(event)
215
- }
467
+ // Focusable so pointer-down pulls keyboard focus into this
468
+ // editor iframe and Cmd+Z reaches our undo handler instead of
469
+ // the browser's (see PxArtEditor startTool for the full
470
+ // rationale -- Safari won't keep focus on a clicked
471
+ // non-focusable canvas).
472
+ tabIndex={-1}
473
+ onPointerDown={(event) => {
474
+ event.currentTarget.focus({ preventScroll: true });
475
+ if (isPlaying) return playPointer.onPointerDown(event);
476
+ if (panGesture.isSpacePanning()) return panGesture.onPointerDown(event);
477
+ return gesture.onPointerDown(event);
478
+ }}
216
479
  onPointerMove={(event) =>
217
480
  isPlaying
218
481
  ? playPointer.onPointerMove(event)
@@ -242,12 +505,13 @@ export function SceneEditor({
242
505
  canvasRef={canvasRef}
243
506
  editCameraRef={editCameraRef}
244
507
  sceneData={sceneData}
508
+ previewSceneData={previewSceneData}
245
509
  selectedActorIds={selectedActorIds}
246
510
  snap={snapSettings}
247
- onArrange={actions.arrangeSelection}
248
- onClone={actions.duplicateSelection}
249
- onDelete={actions.deleteSelection}
250
- 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))}
251
515
  recordSnapshot={history.recordSnapshot}
252
516
  />
253
517
  )}
@@ -258,7 +522,17 @@ export function SceneEditor({
258
522
  <SheetGrabHandle label={inspectorLabel} hint={inspectorHint} />
259
523
  </div>
260
524
  <div className={cx(styles.sheetBody, styles.inspectorBody)}>
261
- {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 ? (
262
536
  <MultiSelectInspector
263
537
  count={selectedActorIds.length}
264
538
  onDeselectAll={() => {
@@ -269,10 +543,13 @@ export function SceneEditor({
269
543
  ) : selectedActor ? (
270
544
  <ActorInspector
271
545
  selectedActor={selectedActor}
546
+ rawActor={rawSelectedActor}
547
+ blueprintName={selectedBlueprintName}
272
548
  files={files}
273
549
  onSetComponent={actions.updateComponent}
274
550
  onAddBehavior={actions.addBehavior}
275
551
  onRemoveBehavior={actions.removeBehavior}
552
+ onFork={isBlueprintFile ? undefined : onForkSelection}
276
553
  />
277
554
  ) : (
278
555
  <SceneInspector
@@ -320,352 +597,23 @@ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
320
597
  </>
321
598
  );
322
599
  }
323
- // Blueprint hotbar -- lightweight actor stamps saved in scene.editor.blueprints.
324
- function BlueprintHotbar({
325
- sceneData,
326
- files,
327
- sprites,
328
- canvasRef,
329
- editCameraRef,
330
- selectedActorIds,
331
- onSelectActorIds,
332
- isPlaying,
333
- onChange,
334
- history,
335
- }) {
336
- const blueprints = sceneData.editor?.blueprints ?? [];
337
- const dragRef = useRef(null);
338
- const suppressClickRef = useRef(false);
339
- const [dragPreview, setDragPreview] = useState(null);
340
- // Right-click context menu anchored at the cursor: { blueprint, x, y }.
341
- const [contextMenu, setContextMenu] = useState(null);
342
- const closeContextMenu = useCallback(() => setContextMenu(null), []);
343
- const onAdd = () => {
344
- const { sceneData: next, newId } = addActor(sceneData, files, sprites);
345
- history.recordSnapshot();
346
- onChange(formatJson(next));
347
- onSelectActorIds([newId]);
348
- };
349
- const onSaveSelection = () => {
350
- const next = addSelectedActorBlueprint(sceneData, selectedActorIds);
351
- if (next === sceneData) return;
352
- history.commit(formatJson(next));
353
- };
354
- const onStamp = (blueprint, position) => {
355
- const { sceneData: next, newId } = stampActorBlueprint(sceneData, blueprint, position);
356
- if (next === sceneData) return;
357
- history.commit(formatJson(next));
358
- onSelectActorIds([newId]);
359
- };
360
- const onRemoveBlueprint = (blueprintId) => {
361
- const next = removeActorBlueprint(sceneData, blueprintId);
362
- if (next === sceneData) return;
363
- history.commit(formatJson(next));
364
- };
365
- const onBlueprintContextMenu = (event, blueprint) => {
366
- if (isPlaying) return;
367
- event.preventDefault();
368
- event.stopPropagation();
369
- setContextMenu({ blueprint, x: event.clientX, y: event.clientY });
370
- };
371
- const onBlueprintPointerDown = (event, blueprint) => {
372
- if (isPlaying || event.button !== 0) return;
373
- event.preventDefault();
374
- const drag = {
375
- blueprint,
376
- pointerId: event.pointerId,
377
- startX: event.clientX,
378
- startY: event.clientY,
379
- moved: false,
380
- };
381
- dragRef.current = drag;
382
- try {
383
- event.currentTarget.setPointerCapture(event.pointerId);
384
- } catch {
385
- // Window listeners still finish the drag if capture is unavailable.
386
- }
387
- const onMove = (moveEvent) => {
388
- if (moveEvent.pointerId !== drag.pointerId) return;
389
- if (!drag.moved && Math.hypot(moveEvent.clientX - drag.startX, moveEvent.clientY - drag.startY) > DRAG_THRESHOLD) {
390
- drag.moved = true;
391
- }
392
- if (drag.moved) {
393
- setDragPreview({
394
- blueprint,
395
- x: moveEvent.clientX,
396
- y: moveEvent.clientY,
397
- overStage: Boolean(eventToStagePoint(moveEvent, canvasRef, editCameraRef)),
398
- });
399
- }
400
- };
401
- const onUp = (upEvent) => {
402
- if (upEvent.pointerId !== drag.pointerId) return;
403
- window.removeEventListener('pointermove', onMove);
404
- window.removeEventListener('pointerup', onUp);
405
- window.removeEventListener('pointercancel', onUp);
406
- dragRef.current = null;
407
- setDragPreview(null);
408
- if (!drag.moved) return;
409
- suppressClickRef.current = true;
410
- const position = eventToStagePoint(upEvent, canvasRef, editCameraRef);
411
- if (position) onStamp(blueprint, position);
412
- };
413
- window.addEventListener('pointermove', onMove);
414
- window.addEventListener('pointerup', onUp);
415
- window.addEventListener('pointercancel', onUp);
416
- };
417
- return (
418
- <div className={styles.bpHotbar} aria-label="Blueprints">
419
- <button
420
- type="button"
421
- className={styles.bpActionSlot}
422
- aria-label="Save selected actor as blueprint"
423
- title="Save selected actor as blueprint"
424
- onClick={onSaveSelection}
425
- disabled={isPlaying || selectedActorIds.length !== 1}>
426
- <Icon name="stamp" />
427
- </button>
428
- <button
429
- type="button"
430
- className={styles.bpActionSlot}
431
- aria-label="Add actor"
432
- title="Add actor"
433
- onClick={onAdd}
434
- disabled={isPlaying}>
435
- <span className={styles.dashedSquareIcon} aria-hidden="true" />
436
- </button>
437
- {blueprints.map((blueprint) => (
438
- <button
439
- key={blueprint.id}
440
- type="button"
441
- className={styles.bpSlot}
442
- title={`Add ${blueprint.name}`}
443
- disabled={isPlaying}
444
- onPointerDown={(event) => onBlueprintPointerDown(event, blueprint)}
445
- onContextMenu={(event) => onBlueprintContextMenu(event, blueprint)}
446
- onClick={() => {
447
- if (suppressClickRef.current) {
448
- suppressClickRef.current = false;
449
- return;
450
- }
451
- onStamp(blueprint);
452
- }}>
453
- <BlueprintThumbnail blueprint={blueprint} sprites={sprites} />
454
- </button>
455
- ))}
456
- {dragPreview ? (
457
- <BlueprintDragPreview preview={dragPreview} sprites={sprites} />
458
- ) : null}
459
- {contextMenu ? (
460
- <BlueprintContextMenu
461
- x={contextMenu.x}
462
- y={contextMenu.y}
463
- onRemove={() => onRemoveBlueprint(contextMenu.blueprint.id)}
464
- onClose={closeContextMenu}
465
- />
466
- ) : null}
467
- </div>
468
- );
469
- }
470
- // Cursor-anchored right-click menu for a hotbar blueprint. Mirrors the
471
- // pxArtTimeline ContextMenu: reuses the shared `selArrangeMenu`/`selArrangeItem`
472
- // chrome, fixed-positions at the click point with viewport clamping, and closes
473
- // on outside pointerdown or Escape.
474
- function BlueprintContextMenu({ x, y, onRemove, onClose }) {
475
- const ref = useRef(null);
476
- const [pos, setPos] = useState({ left: x, top: y });
477
- useLayoutEffect(() => {
478
- const el = ref.current;
479
- if (!el) return;
480
- const rect = el.getBoundingClientRect();
481
- const margin = 8;
482
- const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin);
483
- const maxTop = Math.max(margin, window.innerHeight - rect.height - margin);
484
- setPos({ left: Math.min(x, maxLeft), top: Math.min(y, maxTop) });
485
- }, [x, y]);
486
- useEffect(() => {
487
- const onPointerDown = (event) => {
488
- if (!ref.current?.contains(event.target)) onClose();
489
- };
490
- const onKeyDown = (event) => {
491
- if (event.key === 'Escape') onClose();
492
- };
493
- window.addEventListener('pointerdown', onPointerDown);
494
- window.addEventListener('keydown', onKeyDown);
495
- return () => {
496
- window.removeEventListener('pointerdown', onPointerDown);
497
- window.removeEventListener('keydown', onKeyDown);
498
- };
499
- }, [onClose]);
500
- return (
501
- <div
502
- ref={ref}
503
- className={styles.selArrangeMenu}
504
- role="menu"
505
- aria-label="Blueprint"
506
- style={{ position: 'fixed', left: pos.left, top: pos.top, transform: 'none' }}
507
- onPointerDown={(event) => event.stopPropagation()}
508
- onContextMenu={(event) => event.preventDefault()}>
509
- <button
510
- type="button"
511
- role="menuitem"
512
- className={styles.selArrangeItem}
513
- onClick={() => {
514
- onRemove();
515
- onClose();
516
- }}>
517
- Remove blueprint
518
- </button>
519
- </div>
520
- );
521
- }
522
- function BlueprintDragPreview({ preview, sprites }) {
523
- const layout = preview.blueprint.actor?.components?.Layout;
524
- const width = Math.max(24, Math.min(80, layout?.width ?? 48));
525
- const height = Math.max(24, Math.min(80, layout?.height ?? 48));
526
- return (
527
- <div
528
- className={cx(styles.bpDragPreview, preview.overStage && styles.bpDragPreviewOverStage)}
529
- style={{
530
- left: preview.x,
531
- top: preview.y,
532
- width,
533
- height,
534
- transform: `translate(-50%, -50%) rotate(${layout?.rotation ?? 0}deg)`,
535
- }}>
536
- <BlueprintThumbnail blueprint={preview.blueprint} sprites={sprites} preview />
537
- </div>
538
- );
539
- }
540
- function BlueprintThumbnail({ blueprint, sprites, preview = false }) {
541
- const canvasRef = useRef(null);
542
- const spritePath = blueprint.actor?.components?.Sprite?.file;
543
- const sprite = spritePath ? sprites?.[spritePath] : null;
544
- useEffect(() => {
545
- const canvas = canvasRef.current;
546
- if (!canvas || !sprite) return;
547
- // A .pxart blueprint renders its first frame via the SDK.
548
- renderSpriteFrame(sprite, 0, canvas);
549
- }, [sprite]);
550
- if (!sprite) {
551
- return (
552
- <span className={styles.bpSlotFallback}>
553
- <Icon name="clone" />
554
- </span>
555
- );
556
- }
557
- return (
558
- <canvas
559
- ref={canvasRef}
560
- className={cx(styles.bpSlotThumb, preview && styles.bpPreviewThumb)}
561
- aria-hidden="true"
562
- />
563
- );
564
- }
565
- function addSelectedActorBlueprint(sceneData, selectedActorIds) {
566
- if (selectedActorIds.length !== 1) return sceneData;
567
- const actor = sceneData.actors.find((candidate) => candidate.id === selectedActorIds[0]);
568
- if (!actor) return sceneData;
569
- const next = structuredClone(sceneData);
570
- const copy = structuredClone(actor);
571
- delete copy.id;
572
- if (copy.components?.Layout) {
573
- copy.components.Layout = {
574
- ...copy.components.Layout,
575
- x: 0,
576
- y: 0,
577
- };
578
- }
579
- const blueprints = [...(next.editor?.blueprints ?? [])];
580
- const name = makeBlueprintName(actor, blueprints.length);
581
- blueprints.push({
582
- id: makeBlueprintId(blueprints),
583
- name,
584
- actor: copy,
585
- });
586
- next.editor = {
587
- ...(next.editor ?? {}),
588
- blueprints,
589
- };
590
- return next;
591
- }
592
- function stampActorBlueprint(sceneData, blueprint, position) {
593
- if (!blueprint?.actor) return { sceneData, newId: null };
594
- const next = structuredClone(sceneData);
595
- const existingIds = new Set(next.actors.map((actor) => actor.id));
596
- const actor = structuredClone(blueprint.actor);
597
- const newId = makeActorId(existingIds);
598
- actor.id = newId;
599
- actor.components = actor.components ?? {};
600
- const layout = actor.components.Layout ?? { width: 64, height: 64, z: 0, rotation: 0 };
601
- actor.components.Layout = {
602
- ...layout,
603
- x: Math.round((position?.x ?? cardSize.width / 2) - layout.width / 2),
604
- y: Math.round((position?.y ?? cardSize.height / 2) - layout.height / 2),
605
- };
606
- next.actors.push(actor);
607
- return { sceneData: next, newId };
608
- }
609
- function removeActorBlueprint(sceneData, blueprintId) {
610
- const blueprints = sceneData.editor?.blueprints ?? [];
611
- if (!blueprints.some((blueprint) => blueprint.id === blueprintId)) return sceneData;
612
- const next = structuredClone(sceneData);
613
- next.editor = {
614
- ...(next.editor ?? {}),
615
- blueprints: (next.editor?.blueprints ?? []).filter(
616
- (blueprint) => blueprint.id !== blueprintId
617
- ),
618
- };
619
- return next;
620
- }
621
- function eventToStagePoint(event, canvasRef, editCameraRef) {
622
- const canvas = canvasRef.current;
623
- if (!canvas) return null;
624
- const rect = canvas.getBoundingClientRect();
625
- if (
626
- event.clientX < rect.left ||
627
- event.clientX > rect.right ||
628
- event.clientY < rect.top ||
629
- event.clientY > rect.bottom
630
- ) {
631
- return null;
632
- }
633
- const raw = screenToCard(canvas, event.clientX, event.clientY);
634
- const camera = editCameraRef.current ?? { x: 0, y: 0 };
635
- return {
636
- x: raw.x + camera.x,
637
- y: raw.y + camera.y,
638
- };
639
- }
640
- function makeBlueprintName(actor, index) {
641
- const spriteFile = actor.components?.Sprite?.file;
642
- if (spriteFile) return basename(spriteFile).replace(/\.pxart$/i, '');
643
- return `Actor ${index + 1}`;
644
- }
645
- function makeBlueprintId(blueprints) {
646
- const existing = new Set(blueprints.map((blueprint) => blueprint.id));
647
- for (let index = 1; index < 1000; index++) {
648
- const candidate = `blueprint-${index}`;
649
- if (!existing.has(candidate)) return candidate;
650
- }
651
- return `blueprint-${Date.now()}`;
652
- }
653
- function makeActorId(existingIds) {
654
- for (let attempt = 0; attempt < 64; attempt++) {
655
- const candidate = Math.floor(Math.random() * 0xffffffff)
656
- .toString(16)
657
- .padStart(8, '0');
658
- if (!existingIds.has(candidate)) return candidate;
659
- }
660
- return `actor-${Date.now()}`;
661
- }
662
- function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorIds }) {
663
- function commitScene(next) {
664
- commit(formatJson(next));
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 }) {
609
+ function commitScene(next, options) {
610
+ commit(serialize(next), options);
665
611
  }
666
612
  return {
667
613
  updateComponent: (actorId, behaviorName, nextProps) =>
668
- commitScene(setActorComponent(sceneData, actorId, behaviorName, nextProps)),
614
+ commitScene(setInstanceComponent(files, behaviorClasses, sceneData, actorId, behaviorName, nextProps), {
615
+ coalesceKey: componentCoalesceKey(actorId, behaviorName, nextProps),
616
+ }),
669
617
  addBehavior: (actorId, behaviorName) => {
670
618
  const Behavior = findBehaviorClass(behaviorName);
671
619
  if (!Behavior) return;
@@ -676,23 +624,28 @@ function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorId
676
624
  removeBehavior: (actorId, behaviorName) =>
677
625
  commitScene(removeActorComponent(sceneData, actorId, behaviorName)),
678
626
  deleteSelection: () => {
679
- if (selectedActorIds.length === 0) return;
627
+ if (isBlueprintFile || selectedActorIds.length === 0) return;
680
628
  commitScene(removeActors(sceneData, selectedActorIds));
681
629
  onSelectActorIds([]);
682
630
  },
683
631
  duplicateSelection: () => {
684
- if (selectedActorIds.length === 0) return;
632
+ if (isBlueprintFile || selectedActorIds.length === 0) return;
685
633
  const { sceneData: next, newIds } = duplicateActors(sceneData, selectedActorIds);
686
634
  commitScene(next);
687
635
  if (newIds.length) onSelectActorIds(newIds);
688
636
  },
689
637
  arrangeSelection: (action) => {
690
- if (selectedActorIds.length === 0) return;
638
+ if (isBlueprintFile || selectedActorIds.length === 0) return;
691
639
  const next = arrangeActors(sceneData, selectedActorIds, action);
692
640
  if (next !== sceneData) commitScene(next);
693
641
  },
694
642
  };
695
643
  }
644
+ // setActorComponent merges nextProps into the actor's existing component, so
645
+ // its keys ARE the fields this call actually changed.
646
+ function componentCoalesceKey(actorId, behaviorName, nextProps) {
647
+ return `${actorId}:${behaviorName}:${Object.keys(nextProps).sort().join(',')}`;
648
+ }
696
649
  function setSceneSettings(sceneData, nextScene) {
697
650
  return {
698
651
  ...sceneData,
@@ -737,20 +690,10 @@ function parseHexColor(value) {
737
690
  };
738
691
  }
739
692
  function useSelectionInspectorSheet(open) {
740
- const [snap, setSnap] = useState('high');
741
- // Inspector visibility is driven by selection / multi-mode. Snap state
742
- // persists across selections so switching keeps the user's chosen snap.
743
- const effectiveSnap = open ? snap : 'hidden';
744
- return useMobileSheet({
745
- snap: effectiveSnap,
746
- baseClassName: styles.inspector,
747
- onTransition: (direction) => {
748
- if (!open) return;
749
- if (direction === 'tap') setSnap((prev) => (prev === 'high' ? 'low' : 'high'));
750
- else if (direction === 'down') setSnap('low');
751
- else if (direction === 'up') setSnap('high');
752
- },
753
- });
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 });
754
697
  }
755
698
  function useSelectionGesture(args) {
756
699
  const dragRef = useRef(null);
@@ -765,7 +708,7 @@ function useSelectionGesture(args) {
765
708
  const cam = current.editCameraRef.current;
766
709
  const point = { x: raw.x + cam.x, y: raw.y + cam.y };
767
710
  current.canvasRef.current.setPointerCapture(event.pointerId);
768
- const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
711
+ const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
769
712
  const actor = scene.actorAt(point.x, point.y);
770
713
  const drag = {
771
714
  pointerId: event.pointerId,
@@ -1160,7 +1103,7 @@ function finalizeMarquee(drag, current) {
1160
1103
  const m = current.marqueeRef.current;
1161
1104
  if (!m || !current.sceneData) return;
1162
1105
  if (m.width === 0 && m.height === 0) return;
1163
- const scene = makeScene(current.sceneData, behaviorClasses, current.sprites);
1106
+ const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
1164
1107
  const hits = scene.actorIdsInRect(m);
1165
1108
  const merged = new Set(drag.selectionBeforeMarquee);
1166
1109
  for (const id of hits) merged.add(id);
@@ -1189,21 +1132,22 @@ function useSelectionKeyboard(args) {
1189
1132
  const current = ref.current;
1190
1133
  if (current.isPlaying || isEditableTarget(event.target)) return;
1191
1134
  if (event.key === 'Escape') {
1192
- if (current.selectedActorIds.length || current.multiSelectMode) {
1135
+ if (current.selectedActorIds.length || current.multiSelectMode || current.selectedBlueprintPath) {
1193
1136
  event.preventDefault();
1137
+ // The wrapped setter also clears any hotbar blueprint selection.
1194
1138
  current.onSelectActorIds([]);
1195
1139
  current.onSetMultiSelectMode(false);
1196
1140
  }
1197
1141
  return;
1198
1142
  }
1199
1143
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
1200
- if (!current.sceneData) return;
1144
+ if (!current.sceneData || current.isBlueprintFile) return;
1201
1145
  event.preventDefault();
1202
1146
  current.onSelectActorIds(current.sceneData.actors.map((actor) => actor.id));
1203
1147
  return;
1204
1148
  }
1205
1149
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
1206
- if (!current.sceneData || current.selectedActorIds.length === 0) return;
1150
+ if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
1207
1151
  event.preventDefault();
1208
1152
  const { sceneData: next, newIds } = duplicateActors(
1209
1153
  current.sceneData,
@@ -1214,7 +1158,7 @@ function useSelectionKeyboard(args) {
1214
1158
  return;
1215
1159
  }
1216
1160
  if (event.key === 'Delete' || event.key === 'Backspace') {
1217
- if (!current.sceneData || current.selectedActorIds.length === 0) return;
1161
+ if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
1218
1162
  event.preventDefault();
1219
1163
  current.commitScene(removeActors(current.sceneData, current.selectedActorIds));
1220
1164
  current.onSelectActorIds([]);
@@ -1227,6 +1171,7 @@ function useSelectionKeyboard(args) {
1227
1171
  function useScenePlayLoop({
1228
1172
  sceneData,
1229
1173
  sprites,
1174
+ files,
1230
1175
  isPlaying,
1231
1176
  text,
1232
1177
  canvasRef,
@@ -1245,7 +1190,7 @@ function useScenePlayLoop({
1245
1190
  return;
1246
1191
  }
1247
1192
  if (!sceneData) return;
1248
- runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites).clone();
1193
+ runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites, files).clone();
1249
1194
  // eslint-disable-next-line react-hooks/exhaustive-deps
1250
1195
  }, [sprites, isPlaying, text, runtimeRef]);
1251
1196
  // Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
@@ -1270,7 +1215,7 @@ function useScenePlayLoop({
1270
1215
  : editViewportForZoom(editCameraRef.current.zoom);
1271
1216
  const scene = isPlaying
1272
1217
  ? runtimeRef.current
1273
- : makeScene(sceneData, behaviorClasses, sprites);
1218
+ : makeScene(sceneData, behaviorClasses, sprites, files);
1274
1219
  if (scene) {
1275
1220
  const snap = getSnapSettings(sceneData);
1276
1221
  if (isPlaying) scene.update(dt);
@@ -1297,7 +1242,7 @@ function useScenePlayLoop({
1297
1242
  raf = requestAnimationFrame(frame);
1298
1243
  return () => cancelAnimationFrame(raf);
1299
1244
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `text` proxies `sceneData` identity; resetting on every parse churns the loop and wipes runtime state.
1300
- }, [sprites, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
1245
+ }, [sprites, files, isPlaying, text, canvasRef, runtimeRef, marqueeRef, selectedActorIdsRef]);
1301
1246
  }
1302
1247
  function useScenePlayKeys(runtimeRef) {
1303
1248
  useEffect(() => {
@@ -1388,7 +1333,56 @@ function SceneInspector({ sceneData, snapSettings, onChangeScene, onChangeEditor
1388
1333
  </Panel>
1389
1334
  );
1390
1335
  }
1391
- 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);
1392
1386
  const presentNames = new Set(
1393
1387
  Object.entries(selectedActor.components)
1394
1388
  .filter(([, component]) => !!component)
@@ -1400,6 +1394,14 @@ function ActorInspector({ selectedActor, files, onSetComponent, onAddBehavior, o
1400
1394
  const behaviorEntries = Object.entries(selectedActor.components).filter((entry) => !!entry[1]);
1401
1395
  return (
1402
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}
1403
1405
  <AddBehaviorPicker
1404
1406
  available={availableBehaviors}
1405
1407
  onAdd={(behaviorName) => onAddBehavior(selectedActor.id, behaviorName)}
@@ -1424,8 +1426,12 @@ function ActorInspector({ selectedActor, files, onSetComponent, onAddBehavior, o
1424
1426
  setComponent={(nextProps) => onSetComponent(selectedActor.id, behaviorName, nextProps)}
1425
1427
  />
1426
1428
  );
1427
- // Layout is core to every actor -- it cannot be removed.
1428
- 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]));
1429
1435
  const isLast = index === behaviorEntries.length - 1;
1430
1436
  // Trash renders before the panel so the panel stays the wrapper's
1431
1437
  // last child -- the panel's own `:last-child` border zeroes out, and