castle-web-cli 0.4.84 → 0.4.86

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 (90) hide show
  1. package/dist/ide.js +35 -13
  2. package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
  3. package/dist/shell/index.html +1 -1
  4. package/kits/basic-2d/CLAUDE.md +3 -3
  5. package/kits/basic-2d/behaviors/Collider.jsx +172 -26
  6. package/kits/basic-2d/behaviors/Layout.jsx +24 -0
  7. package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
  8. package/kits/basic-2d/editors/SceneEditor.jsx +49 -16
  9. package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
  10. package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
  11. package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
  12. package/kits/basic-2d/engine/collider.js +60 -6
  13. package/kits/basic-2d/engine/scene.js +28 -2
  14. package/kits/basic-2d/engine/systemRegistry.js +12 -0
  15. package/kits/basic-2d/engine/ui.jsx +2 -0
  16. package/kits/basic-2d/main.jsx +3 -2
  17. package/kits/physics-2d/.prettierrc +8 -0
  18. package/kits/physics-2d/CLAUDE.md +329 -0
  19. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  20. package/kits/physics-2d/behaviors/Collider.jsx +213 -0
  21. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  22. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  23. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  24. package/kits/physics-2d/behaviors/tint.js +47 -0
  25. package/kits/physics-2d/blueprints/ball.scene +14 -0
  26. package/kits/physics-2d/blueprints/block.scene +12 -0
  27. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  28. package/kits/physics-2d/blueprints/crate.scene +14 -0
  29. package/kits/physics-2d/blueprints/goal.scene +12 -0
  30. package/kits/physics-2d/castle.json +13 -0
  31. package/kits/physics-2d/docs/pxart-format.md +377 -0
  32. package/kits/physics-2d/drawings/block.pxart +25 -0
  33. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  34. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  35. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  36. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  37. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  38. package/kits/physics-2d/editors/SceneEditor.jsx +1696 -0
  39. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  40. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  41. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  42. package/kits/physics-2d/editors/editorHistory.js +157 -0
  43. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  44. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  45. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  46. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  47. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  48. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  49. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  50. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  51. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  52. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  53. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  54. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  55. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  56. package/kits/physics-2d/engine/assets.js +15 -0
  57. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  58. package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
  59. package/kits/physics-2d/engine/blueprint.js +521 -0
  60. package/kits/physics-2d/engine/collider.js +200 -0
  61. package/kits/physics-2d/engine/files.js +117 -0
  62. package/kits/physics-2d/engine/liveReload.js +88 -0
  63. package/kits/physics-2d/engine/pxart.js +1032 -0
  64. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  65. package/kits/physics-2d/engine/scene.js +685 -0
  66. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  67. package/kits/physics-2d/engine/systemRegistry.js +12 -0
  68. package/kits/physics-2d/engine/ui.jsx +688 -0
  69. package/kits/physics-2d/engine/ui.module.css +2287 -0
  70. package/kits/physics-2d/eslint.config.js +71 -0
  71. package/kits/physics-2d/index.html +24 -0
  72. package/kits/physics-2d/main.jsx +24 -0
  73. package/kits/physics-2d/package-lock.json +2706 -0
  74. package/kits/physics-2d/package.json +42 -0
  75. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  76. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  77. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  78. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  79. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  80. package/kits/physics-2d/physics/controls.js +79 -0
  81. package/kits/physics-2d/physics/extensions/collider.js +15 -0
  82. package/kits/physics-2d/physics/index.js +26 -0
  83. package/kits/physics-2d/physics/matterBridge.js +126 -0
  84. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  85. package/kits/physics-2d/scenes/main.scene +12 -0
  86. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  87. package/kits/physics-2d/scripts/draw.mjs +121 -0
  88. package/kits/physics-2d/systems/physics.js +8 -0
  89. package/kits/physics-2d/vite.config.js +1 -0
  90. package/package.json +1 -1
@@ -0,0 +1,1696 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { onSaveReloadState, takeReloadState, writeFile } from 'castle-web-sdk';
3
+ import { basename, formatJson, parseJsonFile } from '../engine/files';
4
+ import {
5
+ arrangeActors,
6
+ cardSize,
7
+ configureSceneCanvas,
8
+ duplicateActors,
9
+ makeScene,
10
+ mintActorId,
11
+ removeActorComponent,
12
+ removeActors,
13
+ screenToCard,
14
+ setActorComponent,
15
+ } from '../engine/scene';
16
+ import {
17
+ addActorWithBlueprint,
18
+ blueprintDropXY,
19
+ cascadeDeleteBlueprint,
20
+ countBlueprintInstances,
21
+ forkActorToBlueprint,
22
+ getBlueprintTemplate,
23
+ isBlueprintPath,
24
+ isInheritedProp,
25
+ migrateOrphanActors,
26
+ placeBlueprintInstance,
27
+ setBlueprintName,
28
+ setBlueprintTemplateComponents,
29
+ setInstanceComponent,
30
+ } from '../engine/blueprint';
31
+ import {
32
+ cx,
33
+ CheckboxField,
34
+ ColorField,
35
+ ConfirmDialog,
36
+ EditorBody,
37
+ EditorHeader,
38
+ Icon,
39
+ IconButton,
40
+ NumberField,
41
+ Panel,
42
+ SheetGrabHandle,
43
+ styles,
44
+ TextField,
45
+ useMobileSheet,
46
+ } from '../engine/ui';
47
+ import { AutoInspector } from '../engine/autoInspector';
48
+ import { SceneUI } from '../engine/SceneUI';
49
+ import { SelectionOverlay } from './SelectionOverlay';
50
+ import { BlueprintLibrary, BlueprintThumbnail } from './BlueprintLibrary';
51
+ import { behaviorClasses, findBehaviorClass } from './behaviorRegistry';
52
+ import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
53
+ // A blueprint file's `actors[0]` has no `id` (it's the template, not an
54
+ // instance) -- injected/stripped at the read/write boundary below so the rest
55
+ // of this editor (selection, drag, inspector) can keep treating it like any
56
+ // other single-actor scene.
57
+ const BLUEPRINT_TEMPLATE_ACTOR_ID = '__template__';
58
+ const LONG_PRESS_MS = 500;
59
+ const DRAG_THRESHOLD = 4;
60
+ const DEFAULT_GRID_SIZE = 25;
61
+ const EDIT_VIEWPORT = {
62
+ width: cardSize.width * 3,
63
+ height: cardSize.height * 3,
64
+ originX: cardSize.width,
65
+ originY: cardSize.height,
66
+ };
67
+ // Edit-camera zoom bounds. zoom > 1 magnifies (the visible viewport shrinks);
68
+ // zoom < 1 pulls back to show more around the card.
69
+ const MIN_EDIT_ZOOM = 0.4;
70
+ const MAX_EDIT_ZOOM = 6;
71
+ // Wheel-delta -> zoom-factor sensitivity for trackpad pinch (ctrl+wheel).
72
+ const ZOOM_WHEEL_SPEED = 0.01;
73
+ const clampZoom = (zoom) =>
74
+ Math.min(MAX_EDIT_ZOOM, Math.max(MIN_EDIT_ZOOM, Number(zoom) || 1));
75
+ // Derive the effective edit viewport for a zoom level. Both extent and origin
76
+ // scale by 1/zoom, which keeps the canvas draw path, `screenToCard`, and the
77
+ // SelectionOverlay's `sx = box/card` projection mutually consistent (the overlay
78
+ // just multiplies its scale by the same zoom).
79
+ function editViewportForZoom(zoom) {
80
+ const z = clampZoom(zoom);
81
+ return {
82
+ width: EDIT_VIEWPORT.width / z,
83
+ height: EDIT_VIEWPORT.height / z,
84
+ originX: EDIT_VIEWPORT.originX / z,
85
+ originY: EDIT_VIEWPORT.originY / z,
86
+ };
87
+ }
88
+ // A blueprint file on disk has no `id` on its template actor -- inject a
89
+ // synthetic one so every other piece of editor code that expects `actor.id`
90
+ // (selection, drag, inspector lookups) keeps working unmodified. Stripped
91
+ // again by `stripTemplateId` right before a write (see `serialize` below).
92
+ function withTemplateId(sceneData, isBlueprintFile) {
93
+ if (!sceneData || !isBlueprintFile) return sceneData;
94
+ const next = structuredClone(sceneData);
95
+ next.actors = next.actors ?? [];
96
+ if (next.actors[0]) next.actors[0].id = BLUEPRINT_TEMPLATE_ACTOR_ID;
97
+ return next;
98
+ }
99
+ function stripTemplateId(sceneData) {
100
+ const next = structuredClone(sceneData);
101
+ if (next.actors?.[0]) delete next.actors[0].id;
102
+ return next;
103
+ }
104
+ // On opening a scene with orphan actors (no `blueprint` field -- the
105
+ // pre-blueprint shape), mint one blueprint per orphan and rewrite the scene
106
+ // in place. Runs once per distinct file text; the blueprint-file writes are
107
+ // fire-and-forget (they're brand new files, nothing else references them
108
+ // yet) while the scene rewrite goes through this file's own `onChange` so it
109
+ // picks up the normal history/live-reload path.
110
+ function useMigrateOrphanActors({ isBlueprintFile, sceneData, files, text, onChange }) {
111
+ useEffect(() => {
112
+ if (isBlueprintFile || !sceneData) return;
113
+ const hasOrphans = (sceneData.actors ?? []).some((actor) => !actor.blueprint);
114
+ if (!hasOrphans) return;
115
+ const { sceneData: migrated, newBlueprintFiles } = migrateOrphanActors(files, behaviorClasses, sceneData);
116
+ for (const blueprintFile of newBlueprintFiles) void writeFile(blueprintFile.path, blueprintFile.text);
117
+ onChange(formatJson(migrated));
118
+ // Re-run only when this file's own text changes (e.g. after the migration
119
+ // write lands, or the user opens a different orphaned file) -- `files`
120
+ // changes on every keystroke elsewhere and would otherwise loop.
121
+ // eslint-disable-next-line react-hooks/exhaustive-deps
122
+ }, [text, isBlueprintFile]);
123
+ }
124
+ export function SceneEditor({
125
+ path,
126
+ text,
127
+ files,
128
+ sprites,
129
+ onChange,
130
+ onChangeFile,
131
+ onToggleFiles,
132
+ filesOpen,
133
+ headerHost,
134
+ selectedActorIds,
135
+ onSelectActorIds: onSelectActorIdsRaw,
136
+ multiSelectMode,
137
+ onSetMultiSelectMode,
138
+ }) {
139
+ const canvasRef = useRef(null);
140
+ const runtimeRef = useRef(null);
141
+ const marqueeRef = useRef(null);
142
+ // Ephemeral editor state (camera, play mode, inspected blueprint) restored
143
+ // across a reload: an agent's CODE edit triggers requestReload, which would
144
+ // otherwise reset the viewport/play state on every change. Data edits fold in
145
+ // live without a reload, so they never need this. `takeReloadState` is
146
+ // one-shot (clears on read), so read it once here. Selection is stashed
147
+ // separately by SingleEditor.
148
+ const reloadStashKey = `scene-editor:${path}`;
149
+ const [reloadStash] = useState(() => takeReloadState(reloadStashKey));
150
+ const editCameraRef = useRef(reloadStash?.editCamera ?? { x: 0, y: 0, zoom: 1 });
151
+ const selectedActorIdsRef = useRef(selectedActorIds);
152
+ selectedActorIdsRef.current = selectedActorIds;
153
+ const [isPlaying, setIsPlaying] = useState(reloadStash?.isPlaying ?? false);
154
+ // Hotbar blueprint selection (tap a slot -> inspect/edit the blueprint in
155
+ // the sidebar; drag remains the placement gesture). Mutually exclusive with
156
+ // actor selection: ANY actor-selection change -- including clearing it via
157
+ // empty-canvas click or Escape -- also drops the blueprint selection, which
158
+ // is why every internal caller goes through the wrapper below.
159
+ const [selectedBlueprintPath, setSelectedBlueprintPath] = useState(
160
+ reloadStash?.selectedBlueprintPath ?? null
161
+ );
162
+ // Read live in the render loop (a ref so changing it doesn't restart the
163
+ // loop): while a blueprint is selected, its instances stay lit and every
164
+ // other actor dims. Assigned below once `hotbarBlueprint` resolves, so a
165
+ // stale/deleted selection (file vanished) doesn't dim the scene with nothing
166
+ // to highlight.
167
+ const selectedBlueprintPathRef = useRef(selectedBlueprintPath);
168
+ const onSelectActorIds = useCallback(
169
+ (ids) => {
170
+ setSelectedBlueprintPath(null);
171
+ onSelectActorIdsRaw(ids);
172
+ },
173
+ [onSelectActorIdsRaw]
174
+ );
175
+ const onSelectBlueprint = useCallback(
176
+ (blueprintPath) => {
177
+ onSelectActorIdsRaw([]);
178
+ onSetMultiSelectMode(false);
179
+ setSelectedBlueprintPath(blueprintPath);
180
+ },
181
+ [onSelectActorIdsRaw, onSetMultiSelectMode]
182
+ );
183
+ const isBlueprintFile = isBlueprintPath(path);
184
+ // Working state for an in-progress hotbar drag-placement (see `dragPlace`
185
+ // below). A ref, not state: pointer moves can outpace re-renders.
186
+ const dragPlaceRef = useRef(null);
187
+ const history = useEditHistory(text, onChange, path);
188
+ // Disabled during play: the header undo/redo buttons are already disabled
189
+ // then, but without this the keyboard shortcut could still rewrite the
190
+ // scene file out from under the running play-mode runtime.
191
+ useUndoRedoShortcuts(history, !isPlaying);
192
+ // Stash ephemeral editor state right before a reload so it survives an agent
193
+ // code edit. `editCamera` is read live from the ref at save time (a reload is
194
+ // imminent, so the latest value is what matters); isPlaying / blueprint
195
+ // selection re-register whenever they change.
196
+ useEffect(
197
+ () =>
198
+ onSaveReloadState(reloadStashKey, () => ({
199
+ isPlaying,
200
+ selectedBlueprintPath,
201
+ editCamera: editCameraRef.current,
202
+ })),
203
+ [reloadStashKey, isPlaying, selectedBlueprintPath]
204
+ );
205
+ const showMulti = !isBlueprintFile && (selectedActorIds.length > 1 || multiSelectMode);
206
+ const inspectorSheet = useSelectionInspectorSheet(true);
207
+ const { value: parsedSceneData, error } = parseJsonFile(path, text);
208
+ const sceneData = withTemplateId(parsedSceneData, isBlueprintFile);
209
+ // Sparse per-instance overrides merged with each actor's blueprint template
210
+ // -- what hit-testing, drag-start snapshots, and the inspector should read.
211
+ // Never the basis for a write (see engine/blueprint.js).
212
+ const previewSceneData = sceneData ? makeScene(sceneData, behaviorClasses, sprites, files).data : null;
213
+ const serialize = useCallback(
214
+ (next) => formatJson(isBlueprintFile ? stripTemplateId(next) : next),
215
+ [isBlueprintFile]
216
+ );
217
+ const getRuntime = useCallback(() => runtimeRef.current, []);
218
+ useMigrateOrphanActors({ isBlueprintFile, sceneData, files, text, onChange });
219
+ useScenePlayLoop({
220
+ sceneData,
221
+ sprites,
222
+ files,
223
+ isPlaying,
224
+ text,
225
+ canvasRef,
226
+ runtimeRef,
227
+ editCameraRef,
228
+ marqueeRef,
229
+ selectedActorIdsRef,
230
+ selectedBlueprintPathRef,
231
+ });
232
+ const panGesture = usePanGesture({ canvasRef, editCameraRef, isPlaying });
233
+ useScenePlayKeys(runtimeRef);
234
+ const gesture = useSelectionGesture({
235
+ canvasRef,
236
+ sceneData,
237
+ sprites,
238
+ files,
239
+ isPlaying,
240
+ selectedActorIds,
241
+ onSelectActorIds,
242
+ multiSelectMode: showMulti && multiSelectMode,
243
+ onSetMultiSelectMode,
244
+ marqueeRef,
245
+ editCameraRef,
246
+ applyScene: (next) => onChange(serialize(next)),
247
+ recordSceneSnapshot: history.recordSnapshot,
248
+ });
249
+ const playPointer = usePlayPointerGesture({ canvasRef, runtimeRef });
250
+ useSelectionKeyboard({
251
+ sceneData,
252
+ selectedActorIds,
253
+ selectedBlueprintPath,
254
+ onSelectActorIds,
255
+ multiSelectMode,
256
+ onSetMultiSelectMode,
257
+ isPlaying,
258
+ isBlueprintFile,
259
+ commitScene: (next) => history.commit(serialize(next)),
260
+ });
261
+ if (!sceneData) {
262
+ return (
263
+ <>
264
+ <EditorHeader
265
+ title={basename(path)}
266
+ onToggleFiles={onToggleFiles}
267
+ filesOpen={filesOpen}
268
+ headerHost={headerHost}
269
+ />
270
+ <EditorBody>
271
+ <div className={styles.inspector}>{error}</div>
272
+ </EditorBody>
273
+ </>
274
+ );
275
+ }
276
+ const rawSelectedActor =
277
+ selectedActorIds.length === 1
278
+ ? sceneData.actors.find((actor) => actor.id === selectedActorIds[0])
279
+ : undefined;
280
+ const selectedActor =
281
+ selectedActorIds.length === 1
282
+ ? previewSceneData.actors.find((actor) => actor.id === selectedActorIds[0])
283
+ : undefined;
284
+ const selectedBlueprintTemplate = rawSelectedActor?.blueprint
285
+ ? getBlueprintTemplate(files, rawSelectedActor.blueprint)
286
+ : undefined;
287
+ const selectedBlueprintName = rawSelectedActor?.blueprint
288
+ ? selectedBlueprintTemplate?.name ?? basename(rawSelectedActor.blueprint)
289
+ : undefined;
290
+ // Hotbar-selected blueprint, resolved fresh from live files each render. A
291
+ // selection whose file vanished (cascade delete, external edit) degrades to
292
+ // "nothing selected" rather than a broken inspector.
293
+ const hotbarBlueprint =
294
+ !isBlueprintFile && selectedBlueprintPath
295
+ ? getBlueprintTemplate(files, selectedBlueprintPath)
296
+ : null;
297
+ // Only dim for a blueprint that actually resolves to an inspectable template
298
+ // (mirrors the sidebar): a vanished/stale selection highlights nothing.
299
+ selectedBlueprintPathRef.current = hotbarBlueprint ? selectedBlueprintPath : null;
300
+ // Template edits from the sidebar write the BLUEPRINT file, not this scene:
301
+ // optimistic fold into live files state (instant merged-instance updates
302
+ // here) + debounced writeFile via SingleEditor's saver, which also makes
303
+ // `shouldSkipPath` ignore the fs echo of our own write. Same plain
304
+ // per-property merge semantics as editing the blueprint's own file. Outside
305
+ // this scene file's undo history (accepted v1 cross-file gap, like fork).
306
+ const updateBlueprintTemplate = (mutate) => {
307
+ if (!selectedBlueprintPath || !hotbarBlueprint) return;
308
+ const components = structuredClone(hotbarBlueprint.components);
309
+ mutate(components);
310
+ const nextText = setBlueprintTemplateComponents(files, selectedBlueprintPath, components);
311
+ if (nextText == null) return;
312
+ if (onChangeFile) onChangeFile(selectedBlueprintPath, nextText);
313
+ else void writeFile(selectedBlueprintPath, nextText);
314
+ };
315
+ const blueprintActions = {
316
+ setComponent: (behaviorName, nextProps) =>
317
+ updateBlueprintTemplate((components) => {
318
+ components[behaviorName] = { ...(components[behaviorName] ?? {}), ...nextProps };
319
+ }),
320
+ addBehavior: (behaviorName) => {
321
+ const Behavior = findBehaviorClass(behaviorName);
322
+ if (!Behavior) return;
323
+ updateBlueprintTemplate((components) => {
324
+ // The template's own components (Layout/Sprite) are the actor context an
325
+ // initialProps hook (e.g. Collider auto-fit) reads from.
326
+ components[behaviorName] = initialBehaviorProps(Behavior, { components }, sprites);
327
+ });
328
+ },
329
+ removeBehavior: (behaviorName) =>
330
+ updateBlueprintTemplate((components) => {
331
+ delete components[behaviorName];
332
+ }),
333
+ // Renaming writes only the blueprint file's top-level `name` (not this
334
+ // scene), so it flows through the same optimistic files update + debounced
335
+ // writeFile path as template edits above.
336
+ rename: (name) => {
337
+ if (!selectedBlueprintPath) return;
338
+ const nextText = setBlueprintName(files, selectedBlueprintPath, name);
339
+ if (nextText == null) return;
340
+ if (onChangeFile) onChangeFile(selectedBlueprintPath, nextText);
341
+ else void writeFile(selectedBlueprintPath, nextText);
342
+ },
343
+ };
344
+ const actions = makeSceneActions({
345
+ sceneData,
346
+ files,
347
+ serialize,
348
+ commit: history.commit,
349
+ selectedActorIds,
350
+ onSelectActorIds,
351
+ isBlueprintFile,
352
+ sprites,
353
+ resolvedActors: previewSceneData?.actors,
354
+ });
355
+ const snapSettings = getSnapSettings(sceneData);
356
+ const stageWrapStyle = {
357
+ backgroundColor: darkenSceneBackground(sceneData.background),
358
+ };
359
+ const updateSceneSettings = (nextScene) => {
360
+ history.commit(serialize(setSceneSettings(sceneData, nextScene)));
361
+ };
362
+ const updateSceneEditorSettings = (nextEditor) => {
363
+ history.commit(serialize(setSceneEditorSettings(sceneData, nextEditor)));
364
+ };
365
+ const onAddActor = () => {
366
+ const { sceneData: next, newId, blueprintFile, drawingFile } = addActorWithBlueprint(
367
+ sceneData,
368
+ files
369
+ );
370
+ void writeFile(drawingFile.path, drawingFile.text);
371
+ void writeFile(blueprintFile.path, blueprintFile.text);
372
+ history.commit(serialize(next));
373
+ onSelectActorIds([newId]);
374
+ };
375
+ // Drag-out from the hotbar places a REAL instance the moment the pointer
376
+ // enters the stage, so the rest of the drag is a normal grid-snapped actor
377
+ // move and the drop is just "let go" (castle-client belt behavior). One
378
+ // undo entry per gesture: a snapshot at entry, then plain onChange while
379
+ // the drag is live. Leaving the stage removes the actor again (back to the
380
+ // floating preview), which lands the text back at the snapshot -- the
381
+ // history's no-op trimming keeps undo clean. `dragPlaceRef` carries the
382
+ // working scene between pointer events so a move never applies to a parse
383
+ // that predates the placement.
384
+ const dragPlace = {
385
+ enter: (blueprintPath, position) => {
386
+ history.recordSnapshot();
387
+ const { sceneData: next, newId } = placeBlueprintInstance(sceneData, files, blueprintPath, position, snapSettings);
388
+ const layout = next.actors.find((actor) => actor.id === newId)?.components?.Layout ?? {};
389
+ dragPlaceRef.current = { actorId: newId, blueprintPath, sceneData: next, x: layout.x, y: layout.y };
390
+ onChange(serialize(next));
391
+ onSelectActorIds([newId]);
392
+ },
393
+ move: (position) => {
394
+ const drag = dragPlaceRef.current;
395
+ if (!drag) return;
396
+ const { x, y } = blueprintDropXY(files, drag.blueprintPath, position, snapSettings);
397
+ if (x === drag.x && y === drag.y) return;
398
+ const next = structuredClone(drag.sceneData);
399
+ const actor = next.actors.find((candidate) => candidate.id === drag.actorId);
400
+ if (!actor) return;
401
+ actor.components.Layout = { ...actor.components.Layout, x, y };
402
+ Object.assign(drag, { sceneData: next, x, y });
403
+ onChange(serialize(next));
404
+ },
405
+ leave: () => {
406
+ const drag = dragPlaceRef.current;
407
+ if (!drag) return;
408
+ dragPlaceRef.current = null;
409
+ onChange(serialize(removeActors(drag.sceneData, [drag.actorId])));
410
+ onSelectActorIds([]);
411
+ },
412
+ drop: () => {
413
+ const drag = dragPlaceRef.current;
414
+ if (!drag) return;
415
+ dragPlaceRef.current = null;
416
+ onSelectActorIds([drag.actorId]);
417
+ },
418
+ };
419
+ const onDeleteBlueprint = (blueprintPath) => {
420
+ // Every write here targets a plain `scenes/*.scene` (cascaded instance
421
+ // removal) or the blueprint file itself (tombstoned) -- never a
422
+ // blueprint's own template id, so no `serialize`/strip step is needed.
423
+ for (const write of cascadeDeleteBlueprint(files, blueprintPath)) {
424
+ if (write.path === path) history.commit(write.text);
425
+ else void writeFile(write.path, write.text);
426
+ }
427
+ onSelectActorIds([]);
428
+ };
429
+ const onForkSelection = () => {
430
+ if (isBlueprintFile || !rawSelectedActor?.blueprint) return;
431
+ const result = forkActorToBlueprint(files, behaviorClasses, sceneData, rawSelectedActor.id);
432
+ if (!result) return;
433
+ void writeFile(result.blueprintFile.path, result.blueprintFile.text);
434
+ history.commit(serialize(result.sceneData));
435
+ };
436
+ return (
437
+ <>
438
+ <EditorHeader
439
+ title={
440
+ isBlueprintFile ? (
441
+ <span className={styles.editorHeaderTitleRow}>
442
+ <span className={styles.blueprintBadge}>Blueprint</span>
443
+ {sceneData.name ?? basename(path)}
444
+ </span>
445
+ ) : (
446
+ sceneData.name ?? basename(path)
447
+ )
448
+ }
449
+ subtitle={
450
+ isPlaying
451
+ ? 'WASD / arrows'
452
+ : isBlueprintFile
453
+ ? 'editing template -- changes apply to every placed instance'
454
+ : `${sceneData.actors.length} actors`
455
+ }
456
+ right={
457
+ <HeaderPlaybackButtons
458
+ isPlaying={isPlaying}
459
+ setIsPlaying={setIsPlaying}
460
+ history={history}
461
+ />
462
+ }
463
+ onToggleFiles={onToggleFiles}
464
+ filesOpen={filesOpen}
465
+ headerHost={headerHost}
466
+ />
467
+ <EditorBody>
468
+ <div className={styles.sceneWorkspace}>
469
+ <div className={styles.sceneTools}>
470
+ {isBlueprintFile ? null : (
471
+ <>
472
+ {/* Open (or focus) a Play panel in the shell for this scene.
473
+ Lives here in the visible toolbar because the scene editor's
474
+ header only renders in the combined editor, not the shell. */}
475
+ <IconButton
476
+ icon="external"
477
+ label="Open play preview"
478
+ onClick={(event) => {
479
+ event.currentTarget.blur();
480
+ window.parent.postMessage({ type: 'castle-open-playtest', scene: path }, '*');
481
+ }}
482
+ />
483
+ <BlueprintLibrary
484
+ files={files}
485
+ sprites={sprites}
486
+ canvasRef={canvasRef}
487
+ editCameraRef={editCameraRef}
488
+ isPlaying={isPlaying}
489
+ selectedBlueprintPath={selectedBlueprintPath}
490
+ instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
491
+ onSelectBlueprint={onSelectBlueprint}
492
+ onAddActor={onAddActor}
493
+ dragPlace={dragPlace}
494
+ onDeleteBlueprint={onDeleteBlueprint}
495
+ />
496
+ </>
497
+ )}
498
+ </div>
499
+ <div className={styles.stageWrap} style={stageWrapStyle}>
500
+ <div className={styles.stageCard} data-castle-card>
501
+ <div className={cx(styles.stageCardClip, !isPlaying && styles.stageCardEditSurface)}>
502
+ <canvas
503
+ ref={canvasRef}
504
+ className={cx(styles.stageCanvas, !isPlaying && styles.stageCanvasEdit)}
505
+ // Focusable so pointer-down pulls keyboard focus into this
506
+ // editor iframe and Cmd+Z reaches our undo handler instead of
507
+ // the browser's (see PxArtEditor startTool for the full
508
+ // rationale -- Safari won't keep focus on a clicked
509
+ // non-focusable canvas).
510
+ tabIndex={-1}
511
+ onPointerDown={(event) => {
512
+ event.currentTarget.focus({ preventScroll: true });
513
+ if (isPlaying) return playPointer.onPointerDown(event);
514
+ if (panGesture.isSpacePanning()) return panGesture.onPointerDown(event);
515
+ return gesture.onPointerDown(event);
516
+ }}
517
+ onPointerMove={(event) =>
518
+ isPlaying
519
+ ? playPointer.onPointerMove(event)
520
+ : panGesture.isActive()
521
+ ? panGesture.onPointerMove(event)
522
+ : gesture.onPointerMove(event)
523
+ }
524
+ onPointerUp={(event) =>
525
+ isPlaying
526
+ ? playPointer.onPointerUp(event)
527
+ : panGesture.isActive()
528
+ ? panGesture.onPointerUp(event)
529
+ : gesture.onPointerUp(event)
530
+ }
531
+ onPointerCancel={(event) =>
532
+ isPlaying
533
+ ? playPointer.onPointerUp(event)
534
+ : panGesture.isActive()
535
+ ? panGesture.onPointerUp(event)
536
+ : gesture.onPointerUp(event)
537
+ }
538
+ />
539
+ {isPlaying && <SceneUI getRuntime={getRuntime} />}
540
+ </div>
541
+ {!isPlaying && selectedActorIds.length >= 1 && (
542
+ <SelectionOverlay
543
+ canvasRef={canvasRef}
544
+ editCameraRef={editCameraRef}
545
+ sceneData={sceneData}
546
+ previewSceneData={previewSceneData}
547
+ sprites={sprites}
548
+ selectedActorIds={selectedActorIds}
549
+ snap={snapSettings}
550
+ onArrange={isBlueprintFile ? undefined : actions.arrangeSelection}
551
+ onClone={isBlueprintFile ? undefined : actions.duplicateSelection}
552
+ onDelete={isBlueprintFile ? undefined : actions.deleteSelection}
553
+ applyScene={(next) => onChange(serialize(next))}
554
+ recordSnapshot={history.recordSnapshot}
555
+ />
556
+ )}
557
+ </div>
558
+ </div>
559
+ <aside {...inspectorSheet.rootProps}>
560
+ <div {...inspectorSheet.grabProps}>
561
+ <SheetGrabHandle />
562
+ </div>
563
+ <div className={cx(styles.sheetBody, styles.inspectorBody)}>
564
+ {hotbarBlueprint ? (
565
+ <BlueprintInspector
566
+ name={hotbarBlueprint.name}
567
+ template={hotbarBlueprint}
568
+ sprites={sprites}
569
+ blueprintPath={selectedBlueprintPath}
570
+ files={files}
571
+ onRename={blueprintActions.rename}
572
+ onSetComponent={blueprintActions.setComponent}
573
+ onAddBehavior={blueprintActions.addBehavior}
574
+ onRemoveBehavior={blueprintActions.removeBehavior}
575
+ />
576
+ ) : showMulti ? (
577
+ <MultiSelectInspector
578
+ count={selectedActorIds.length}
579
+ onDeselectAll={() => {
580
+ onSelectActorIds([]);
581
+ onSetMultiSelectMode(false);
582
+ }}
583
+ />
584
+ ) : selectedActor ? (
585
+ <ActorInspector
586
+ key={selectedActor.id}
587
+ selectedActor={selectedActor}
588
+ rawActor={rawSelectedActor}
589
+ blueprintName={selectedBlueprintName}
590
+ blueprintTemplate={selectedBlueprintTemplate}
591
+ sprites={sprites}
592
+ files={files}
593
+ showAddBehavior={isBlueprintFile}
594
+ onSetComponent={actions.updateComponent}
595
+ onAddBehavior={actions.addBehavior}
596
+ onRemoveBehavior={actions.removeBehavior}
597
+ onFork={isBlueprintFile ? undefined : onForkSelection}
598
+ onEditBlueprint={
599
+ isBlueprintFile || !rawSelectedActor?.blueprint
600
+ ? undefined
601
+ : () => onSelectBlueprint(rawSelectedActor.blueprint)
602
+ }
603
+ />
604
+ ) : (
605
+ <SceneInspector
606
+ sceneData={sceneData}
607
+ snapSettings={snapSettings}
608
+ onChangeScene={updateSceneSettings}
609
+ onChangeEditor={updateSceneEditorSettings}
610
+ />
611
+ )}
612
+ </div>
613
+ </aside>
614
+ </div>
615
+ </EditorBody>
616
+ </>
617
+ );
618
+ }
619
+ // Header-right playback controls, in the mockup's order: Undo, Redo, Play.
620
+ // Play stays a play/stop toggle. Duplicate/remove live on the on-canvas toolbar.
621
+ // "Open preview" asks the shell (parent window) to open/focus a Play panel for
622
+ // THIS scene -- a separate, persistent preview alongside the editor (vs the
623
+ // in-panel play toggle). Defaults to an already-open preview for this scene,
624
+ // else opens a new one (handled shell-side, keyed by scene).
625
+ function HeaderPlaybackButtons({ isPlaying, setIsPlaying, history }) {
626
+ return (
627
+ <>
628
+ <IconButton
629
+ icon="undo"
630
+ label="Undo"
631
+ onClick={history.undo}
632
+ disabled={!history.canUndo || isPlaying}
633
+ />
634
+ <IconButton
635
+ icon="redo"
636
+ label="Redo"
637
+ onClick={history.redo}
638
+ disabled={!history.canRedo || isPlaying}
639
+ />
640
+ <IconButton
641
+ icon={isPlaying ? 'stop' : 'play'}
642
+ label={isPlaying ? 'Stop' : 'Play'}
643
+ active={isPlaying}
644
+ onClick={(event) => {
645
+ // Drop focus so a subsequent Space (a game input / pan modifier)
646
+ // doesn't re-activate this button and toggle play.
647
+ event.currentTarget.blur();
648
+ setIsPlaying(!isPlaying);
649
+ }}
650
+ />
651
+ </>
652
+ );
653
+ }
654
+ // `updateComponent` goes through `setInstanceComponent`, which already
655
+ // branches on whether the target actor has a `blueprint` ref: for a
656
+ // blueprint's own template actor (no ref) it's a plain merge; for a normal
657
+ // instance it diffs each patched prop against the blueprint and records only
658
+ // what actually differs, dropping an override that becomes redundant. Add/
659
+ // remove-behavior stay whole-component operations straight against the
660
+ // instance's own sparse `components` -- there's no per-property baseline to
661
+ // diff when a component doesn't exist on one side at all.
662
+ // Props to seed a behavior with when it's freshly added to `actor`. A behavior
663
+ // may define `static initialProps(actor, { sprites })` to derive props from the
664
+ // actor it lands on (e.g. Collider snaps to the sprite's opaque-pixel bounds);
665
+ // otherwise it starts from `defaultProps`. `actor` is the resolved actor
666
+ // (Layout/Sprite merged) or, for a blueprint template, `{ components }`.
667
+ function initialBehaviorProps(Behavior, actor, sprites) {
668
+ if (Behavior.initialProps && actor) return Behavior.initialProps(actor, { sprites });
669
+ return { ...Behavior.defaultProps };
670
+ }
671
+
672
+ function makeSceneActions({ sceneData, files, serialize, commit, selectedActorIds, onSelectActorIds, isBlueprintFile, sprites, resolvedActors }) {
673
+ function commitScene(next, options) {
674
+ commit(serialize(next), options);
675
+ }
676
+ return {
677
+ updateComponent: (actorId, behaviorName, nextProps) =>
678
+ commitScene(setInstanceComponent(files, behaviorClasses, sceneData, actorId, behaviorName, nextProps), {
679
+ coalesceKey: componentCoalesceKey(actorId, behaviorName, nextProps),
680
+ }),
681
+ addBehavior: (actorId, behaviorName) => {
682
+ const Behavior = findBehaviorClass(behaviorName);
683
+ if (!Behavior) return;
684
+ const resolved = resolvedActors?.find((actor) => actor.id === actorId);
685
+ commitScene(
686
+ setActorComponent(sceneData, actorId, behaviorName, initialBehaviorProps(Behavior, resolved, sprites))
687
+ );
688
+ },
689
+ removeBehavior: (actorId, behaviorName) =>
690
+ commitScene(removeActorComponent(sceneData, actorId, behaviorName)),
691
+ deleteSelection: () => {
692
+ if (isBlueprintFile || selectedActorIds.length === 0) return;
693
+ commitScene(removeActors(sceneData, selectedActorIds));
694
+ onSelectActorIds([]);
695
+ },
696
+ duplicateSelection: () => {
697
+ if (isBlueprintFile || selectedActorIds.length === 0) return;
698
+ const { sceneData: next, newIds } = duplicateActors(sceneData, selectedActorIds);
699
+ commitScene(next);
700
+ if (newIds.length) onSelectActorIds(newIds);
701
+ },
702
+ arrangeSelection: (action) => {
703
+ if (isBlueprintFile || selectedActorIds.length === 0) return;
704
+ const next = arrangeActors(sceneData, selectedActorIds, action);
705
+ if (next !== sceneData) commitScene(next);
706
+ },
707
+ };
708
+ }
709
+ // setActorComponent merges nextProps into the actor's existing component, so
710
+ // its keys ARE the fields this call actually changed.
711
+ function componentCoalesceKey(actorId, behaviorName, nextProps) {
712
+ return `${actorId}:${behaviorName}:${Object.keys(nextProps).sort().join(',')}`;
713
+ }
714
+ function setSceneSettings(sceneData, nextScene) {
715
+ return {
716
+ ...sceneData,
717
+ ...nextScene,
718
+ };
719
+ }
720
+ function setSceneEditorSettings(sceneData, nextEditor) {
721
+ return {
722
+ ...sceneData,
723
+ editor: {
724
+ ...(sceneData.editor ?? {}),
725
+ ...nextEditor,
726
+ },
727
+ };
728
+ }
729
+ function darkenSceneBackground(background) {
730
+ const parsed = parseHexColor(background ?? '#121213');
731
+ if (!parsed) return 'var(--castle-stage-workspace-bg)';
732
+ const amount = 0.42;
733
+ const darken = (channel) => Math.max(0, Math.round(channel * (1 - amount)));
734
+ return `rgb(${darken(parsed.r)}, ${darken(parsed.g)}, ${darken(parsed.b)})`;
735
+ }
736
+ function parseHexColor(value) {
737
+ if (typeof value !== 'string') return null;
738
+ const hex = value.trim().replace(/^#/, '');
739
+ const expand = (text) =>
740
+ text
741
+ .split('')
742
+ .map((char) => `${char}${char}`)
743
+ .join('');
744
+ const normalized =
745
+ hex.length === 3 || hex.length === 4
746
+ ? expand(hex.slice(0, 3))
747
+ : hex.length === 6 || hex.length === 8
748
+ ? hex.slice(0, 6)
749
+ : null;
750
+ if (!normalized || !/^[0-9a-f]{6}$/i.test(normalized)) return null;
751
+ return {
752
+ r: parseInt(normalized.slice(0, 2), 16),
753
+ g: parseInt(normalized.slice(2, 4), 16),
754
+ b: parseInt(normalized.slice(4, 6), 16),
755
+ };
756
+ }
757
+ function useSelectionInspectorSheet(open) {
758
+ // Inspector visibility is driven by selection / multi-mode; the sheet hook
759
+ // owns the drag-resize height and remembers it across selections while
760
+ // mounted (and restores it when the sheet reopens), plus persists it across
761
+ // reloads via `storageKey`.
762
+ return useMobileSheet({ open, baseClassName: styles.inspector, storageKey: 'scene-inspector' });
763
+ }
764
+ function useSelectionGesture(args) {
765
+ const dragRef = useRef(null);
766
+ // Pin args in a ref so the timer callback reads the latest values without
767
+ // re-creating the handlers each render.
768
+ const argsRef = useRef(args);
769
+ argsRef.current = args;
770
+ const onPointerDown = useCallback((event) => {
771
+ const current = argsRef.current;
772
+ if (current.isPlaying || !current.canvasRef.current || !current.sceneData) return;
773
+ const raw = screenToCard(current.canvasRef.current, event.clientX, event.clientY);
774
+ const cam = current.editCameraRef.current;
775
+ const point = { x: raw.x + cam.x, y: raw.y + cam.y };
776
+ current.canvasRef.current.setPointerCapture(event.pointerId);
777
+ const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
778
+ const actor = scene.actorAt(point.x, point.y);
779
+ const drag = {
780
+ pointerId: event.pointerId,
781
+ startPoint: point,
782
+ lastPoint: point,
783
+ startedOnActorId: actor?.id ?? null,
784
+ modeAtStart: current.multiSelectMode,
785
+ movedFar: false,
786
+ longPressTimer: null,
787
+ longPressFired: false,
788
+ kind: 'idle',
789
+ pendingMarquee: false,
790
+ movingActorIds: [],
791
+ moveStarts: {},
792
+ recordedMoveSnapshot: false,
793
+ lastMoveKey: '0,0',
794
+ selectionBeforeMarquee: [...current.selectedActorIds],
795
+ };
796
+ if (event.shiftKey) {
797
+ handleShiftPointerDown(drag, actor, current);
798
+ } else if (current.multiSelectMode) {
799
+ handleModePointerDown(drag, actor, current);
800
+ } else {
801
+ handleDefaultPointerDown(drag, actor, point, current);
802
+ }
803
+ drag.moveStarts = collectMoveStarts(current.sceneData, drag.movingActorIds);
804
+ dragRef.current = drag;
805
+ }, []);
806
+ const onPointerMove = useCallback((event) => {
807
+ const drag = dragRef.current;
808
+ const current = argsRef.current;
809
+ if (!drag || drag.pointerId !== event.pointerId) return;
810
+ if (!current.canvasRef.current || !current.sceneData) return;
811
+ const raw = screenToCard(current.canvasRef.current, event.clientX, event.clientY);
812
+ const cam = current.editCameraRef.current;
813
+ const point = { x: raw.x + cam.x, y: raw.y + cam.y };
814
+ const dxTotal = point.x - drag.startPoint.x;
815
+ const dyTotal = point.y - drag.startPoint.y;
816
+ if (!drag.movedFar && Math.hypot(dxTotal, dyTotal) > DRAG_THRESHOLD) {
817
+ drag.movedFar = true;
818
+ if (drag.longPressTimer !== null) {
819
+ window.clearTimeout(drag.longPressTimer);
820
+ drag.longPressTimer = null;
821
+ }
822
+ if (drag.pendingMarquee && drag.kind === 'idle') drag.kind = 'marquee';
823
+ }
824
+ if (drag.kind === 'move' && drag.movedFar) {
825
+ const snap = getSnapSettings(current.sceneData);
826
+ const dx = snapDelta(dxTotal, snap.gridSize, snap.enabled);
827
+ const dy = snapDelta(dyTotal, snap.gridSize, snap.enabled);
828
+ drag.lastPoint = point;
829
+ // Key the commit on the snapped delta, not on `nextScene === sceneData`.
830
+ // `moveActorsFromStarts` returns the pointer-down scene unchanged when the
831
+ // delta lands back on the start, and an identity guard would skip applying
832
+ // it -- stranding the actors on the last grid step instead of letting them
833
+ // return to origin. Re-apply whenever the snapped delta changes; the
834
+ // identity check now only gates recording the undo snapshot.
835
+ const moveKey = `${dx},${dy}`;
836
+ if (moveKey === drag.lastMoveKey) return;
837
+ drag.lastMoveKey = moveKey;
838
+ const nextScene = moveActorsFromStarts(
839
+ current.sceneData,
840
+ drag.movingActorIds,
841
+ drag.moveStarts,
842
+ dx,
843
+ dy
844
+ );
845
+ if (nextScene !== current.sceneData && !drag.recordedMoveSnapshot) {
846
+ current.recordSceneSnapshot();
847
+ drag.recordedMoveSnapshot = true;
848
+ }
849
+ current.applyScene(nextScene);
850
+ } else if (drag.kind === 'marquee') {
851
+ drag.lastPoint = point;
852
+ current.marqueeRef.current = {
853
+ x: Math.min(drag.startPoint.x, point.x),
854
+ y: Math.min(drag.startPoint.y, point.y),
855
+ width: Math.abs(point.x - drag.startPoint.x),
856
+ height: Math.abs(point.y - drag.startPoint.y),
857
+ };
858
+ }
859
+ }, []);
860
+ const onPointerUp = useCallback((event) => {
861
+ const drag = dragRef.current;
862
+ const current = argsRef.current;
863
+ if (!drag || drag.pointerId !== event.pointerId) return;
864
+ if (drag.longPressTimer !== null) {
865
+ window.clearTimeout(drag.longPressTimer);
866
+ drag.longPressTimer = null;
867
+ }
868
+ if (drag.kind === 'marquee') {
869
+ finalizeMarquee(drag, current);
870
+ } else if (drag.kind === 'idle' && !drag.movedFar && !drag.longPressFired) {
871
+ handleTap(drag, current);
872
+ }
873
+ current.marqueeRef.current = null;
874
+ dragRef.current = null;
875
+ }, []);
876
+ return { onPointerDown, onPointerMove, onPointerUp };
877
+ }
878
+ function getSnapSettings(sceneData) {
879
+ const rawSize = sceneData.editor?.gridSize;
880
+ return {
881
+ enabled: sceneData.editor?.snapToGrid ?? true,
882
+ gridSize: Number.isFinite(rawSize) && rawSize && rawSize > 0 ? rawSize : DEFAULT_GRID_SIZE,
883
+ };
884
+ }
885
+ function snapDelta(delta, gridSize, enabled) {
886
+ if (!enabled) return delta;
887
+ return Math.round(delta / gridSize) * gridSize;
888
+ }
889
+ function collectMoveStarts(sceneData, actorIds) {
890
+ const starts = {};
891
+ const movingIds = new Set(actorIds);
892
+ for (const actor of sceneData.actors) {
893
+ if (!movingIds.has(actor.id)) continue;
894
+ const layout = actor.components.Layout;
895
+ if (!layout) continue;
896
+ starts[actor.id] = { x: layout.x, y: layout.y };
897
+ }
898
+ return starts;
899
+ }
900
+ function moveActorsFromStarts(sceneData, actorIds, starts, dx, dy) {
901
+ if (actorIds.length === 0) return sceneData;
902
+ const movingIds = new Set(actorIds);
903
+ const next = structuredClone(sceneData);
904
+ let changed = false;
905
+ for (const actor of next.actors) {
906
+ if (!movingIds.has(actor.id)) continue;
907
+ const start = starts[actor.id];
908
+ const layout = actor.components.Layout;
909
+ if (!start || !layout) continue;
910
+ const nextX = Math.round(start.x + dx);
911
+ const nextY = Math.round(start.y + dy);
912
+ if (layout.x === nextX && layout.y === nextY) continue;
913
+ actor.components.Layout = {
914
+ ...layout,
915
+ x: nextX,
916
+ y: nextY,
917
+ };
918
+ changed = true;
919
+ }
920
+ return changed ? next : sceneData;
921
+ }
922
+ function usePlayPointerGesture({ canvasRef, runtimeRef }) {
923
+ const setPointer = useCallback(
924
+ (event, down) => {
925
+ const canvas = canvasRef.current;
926
+ const runtime = runtimeRef.current;
927
+ if (!canvas || !runtime) return;
928
+ runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, down);
929
+ },
930
+ [canvasRef, runtimeRef]
931
+ );
932
+ const onPointerDown = useCallback(
933
+ (event) => {
934
+ event.currentTarget.setPointerCapture(event.pointerId);
935
+ setPointer(event, true);
936
+ },
937
+ [setPointer]
938
+ );
939
+ const onPointerMove = useCallback(
940
+ (event) => {
941
+ setPointer(event);
942
+ },
943
+ [setPointer]
944
+ );
945
+ const onPointerUp = useCallback(
946
+ (event) => {
947
+ setPointer(event, false);
948
+ try {
949
+ event.currentTarget.releasePointerCapture(event.pointerId);
950
+ } catch {
951
+ // Pointer capture may already be gone after cancel/blur.
952
+ }
953
+ },
954
+ [setPointer]
955
+ );
956
+ return { onPointerDown, onPointerMove, onPointerUp };
957
+ }
958
+ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
959
+ const dragRef = useRef(null);
960
+ const spaceDownRef = useRef(false);
961
+ // Native wheel/gesture listeners (below) read play state through a ref so they
962
+ // never need to detach/reattach when play toggles.
963
+ const isPlayingRef = useRef(isPlaying);
964
+ isPlayingRef.current = isPlaying;
965
+ useEffect(() => {
966
+ function onKeyDown(event) {
967
+ if (event.key !== ' ' || isEditableTarget(event.target)) return;
968
+ // Swallow Space's default: it would scroll the panel and re-activate a
969
+ // focused button (e.g. the Play/Stop toggle that keeps focus after a
970
+ // click), so Space only ever arms the pan modifier here.
971
+ event.preventDefault();
972
+ spaceDownRef.current = true;
973
+ }
974
+ function onKeyUp(event) {
975
+ if (event.key === ' ') spaceDownRef.current = false;
976
+ }
977
+ window.addEventListener('keydown', onKeyDown);
978
+ window.addEventListener('keyup', onKeyUp);
979
+ return () => {
980
+ window.removeEventListener('keydown', onKeyDown);
981
+ window.removeEventListener('keyup', onKeyUp);
982
+ };
983
+ }, []);
984
+ const panByScreenDelta = useCallback(
985
+ (dx, dy) => {
986
+ const canvas = canvasRef.current;
987
+ if (!canvas) return;
988
+ const rect = canvas.getBoundingClientRect();
989
+ const viewportWidth = Number(canvas.dataset.viewportWidth) || cardSize.width;
990
+ const viewportHeight = Number(canvas.dataset.viewportHeight) || cardSize.height;
991
+ editCameraRef.current = {
992
+ ...editCameraRef.current,
993
+ x: editCameraRef.current.x + (dx * viewportWidth) / rect.width,
994
+ y: editCameraRef.current.y + (dy * viewportHeight) / rect.height,
995
+ };
996
+ },
997
+ [canvasRef, editCameraRef]
998
+ );
999
+ // Zoom about the pointer by a multiplicative factor: keep the world point
1000
+ // under the cursor pinned while the zoom level changes. Because the effective
1001
+ // origin scales as EDIT_VIEWPORT.origin / zoom, the camera shift to re-pin the
1002
+ // cursor is (1/z0 - 1/z1) * (frac * EDIT_VIEWPORT.extent - EDIT_VIEWPORT.origin).
1003
+ const zoomByFactorAtPointer = useCallback(
1004
+ (clientX, clientY, factor) => {
1005
+ const canvas = canvasRef.current;
1006
+ if (!canvas || !Number.isFinite(factor) || factor <= 0) return;
1007
+ const cam = editCameraRef.current;
1008
+ const z0 = cam.zoom ?? 1;
1009
+ const z1 = clampZoom(z0 * factor);
1010
+ if (z1 === z0) return;
1011
+ const rect = canvas.getBoundingClientRect();
1012
+ const fracX = (clientX - rect.left) / rect.width;
1013
+ const fracY = (clientY - rect.top) / rect.height;
1014
+ const k = 1 / z0 - 1 / z1;
1015
+ editCameraRef.current = {
1016
+ x: cam.x + k * (fracX * EDIT_VIEWPORT.width - EDIT_VIEWPORT.originX),
1017
+ y: cam.y + k * (fracY * EDIT_VIEWPORT.height - EDIT_VIEWPORT.originY),
1018
+ zoom: z1,
1019
+ };
1020
+ },
1021
+ [canvasRef, editCameraRef]
1022
+ );
1023
+ // Wheel + pinch are bound as NATIVE, non-passive listeners (not React props):
1024
+ // React attaches `wheel` passively at its root, so a React onWheel can't call
1025
+ // preventDefault and the browser's own page-zoom wins. We also need Safari's
1026
+ // proprietary `gesture*` events, which is how it reports trackpad pinch (it
1027
+ // does not emit ctrl+wheel like Chromium). Both paths preventDefault to stop
1028
+ // the browser from zooming the page underneath the editor.
1029
+ useEffect(() => {
1030
+ const canvas = canvasRef.current;
1031
+ if (!canvas) return undefined;
1032
+ let gestureScale = 1;
1033
+ // Set while a Safari pinch gesture is in flight, so we don't double-apply
1034
+ // zoom if Safari also emits ctrl+wheel for the same pinch.
1035
+ let gestureActive = false;
1036
+ const onWheel = (event) => {
1037
+ if (isPlayingRef.current) return;
1038
+ event.preventDefault();
1039
+ // Trackpad pinch (and Ctrl+wheel) surface as wheel events with ctrlKey set
1040
+ // in Chromium; route those to zoom and leave plain scroll as pan.
1041
+ if (event.ctrlKey) {
1042
+ if (gestureActive) return;
1043
+ zoomByFactorAtPointer(
1044
+ event.clientX,
1045
+ event.clientY,
1046
+ Math.exp(-event.deltaY * ZOOM_WHEEL_SPEED)
1047
+ );
1048
+ } else {
1049
+ panByScreenDelta(event.deltaX, event.deltaY);
1050
+ }
1051
+ };
1052
+ const onGestureStart = (event) => {
1053
+ if (isPlayingRef.current) return;
1054
+ event.preventDefault();
1055
+ gestureActive = true;
1056
+ gestureScale = event.scale || 1;
1057
+ };
1058
+ const onGestureChange = (event) => {
1059
+ if (isPlayingRef.current) return;
1060
+ event.preventDefault();
1061
+ // Safari reports `scale` cumulatively from gesturestart (1.0 at start), so
1062
+ // zoom by the ratio against the last reading.
1063
+ const prev = gestureScale || 1;
1064
+ const next = event.scale || prev;
1065
+ gestureScale = next;
1066
+ // GestureEvent carries clientX/clientY (the pinch centroid); fall back to
1067
+ // the canvas center if a browser omits them.
1068
+ const rect = canvas.getBoundingClientRect();
1069
+ const px = Number.isFinite(event.clientX) ? event.clientX : rect.left + rect.width / 2;
1070
+ const py = Number.isFinite(event.clientY) ? event.clientY : rect.top + rect.height / 2;
1071
+ zoomByFactorAtPointer(px, py, next / prev);
1072
+ };
1073
+ const onGestureEnd = (event) => {
1074
+ if (isPlayingRef.current) return;
1075
+ event.preventDefault();
1076
+ gestureActive = false;
1077
+ };
1078
+ const opts = { passive: false };
1079
+ canvas.addEventListener('wheel', onWheel, opts);
1080
+ canvas.addEventListener('gesturestart', onGestureStart, opts);
1081
+ canvas.addEventListener('gesturechange', onGestureChange, opts);
1082
+ canvas.addEventListener('gestureend', onGestureEnd, opts);
1083
+ return () => {
1084
+ canvas.removeEventListener('wheel', onWheel, opts);
1085
+ canvas.removeEventListener('gesturestart', onGestureStart, opts);
1086
+ canvas.removeEventListener('gesturechange', onGestureChange, opts);
1087
+ canvas.removeEventListener('gestureend', onGestureEnd, opts);
1088
+ };
1089
+ }, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
1090
+ const onPointerDown = useCallback((event) => {
1091
+ if (isPlaying || !spaceDownRef.current) return;
1092
+ event.preventDefault();
1093
+ event.currentTarget.setPointerCapture(event.pointerId);
1094
+ dragRef.current = { id: event.pointerId, lastX: event.clientX, lastY: event.clientY };
1095
+ }, [isPlaying]);
1096
+ const onPointerMove = useCallback(
1097
+ (event) => {
1098
+ const drag = dragRef.current;
1099
+ if (!drag || drag.id !== event.pointerId) return;
1100
+ const dx = event.clientX - drag.lastX;
1101
+ const dy = event.clientY - drag.lastY;
1102
+ drag.lastX = event.clientX;
1103
+ drag.lastY = event.clientY;
1104
+ panByScreenDelta(-dx, -dy);
1105
+ },
1106
+ [panByScreenDelta]
1107
+ );
1108
+ const onPointerUp = useCallback((event) => {
1109
+ const drag = dragRef.current;
1110
+ if (!drag || drag.id !== event.pointerId) return;
1111
+ try {
1112
+ event.currentTarget.releasePointerCapture(event.pointerId);
1113
+ } catch {
1114
+ // pointer capture may already be gone
1115
+ }
1116
+ dragRef.current = null;
1117
+ }, []);
1118
+ const isSpacePanning = useCallback(() => spaceDownRef.current, []);
1119
+ const isActive = useCallback(() => Boolean(dragRef.current), []);
1120
+ return { onPointerDown, onPointerMove, onPointerUp, isSpacePanning, isActive };
1121
+ }
1122
+ function handleShiftPointerDown(drag, actor, current) {
1123
+ if (actor) {
1124
+ const wasSelected = current.selectedActorIds.includes(actor.id);
1125
+ const nextIds = wasSelected
1126
+ ? current.selectedActorIds.filter((id) => id !== actor.id)
1127
+ : [...current.selectedActorIds, actor.id];
1128
+ current.onSelectActorIds(nextIds);
1129
+ if (!wasSelected) {
1130
+ drag.kind = 'move';
1131
+ drag.movingActorIds = nextIds;
1132
+ }
1133
+ } else {
1134
+ drag.pendingMarquee = true;
1135
+ }
1136
+ }
1137
+ function handleModePointerDown(drag, actor, current) {
1138
+ if (actor) {
1139
+ if (current.selectedActorIds.includes(actor.id)) {
1140
+ drag.kind = 'move';
1141
+ drag.movingActorIds = [...current.selectedActorIds];
1142
+ }
1143
+ // else: leave kind='idle' so pointerup triggers the tap-toggle path.
1144
+ } else {
1145
+ // Defer marquee until movement, so a stationary press/release reaches
1146
+ // the tap handler (which exits multi mode on empty tap).
1147
+ drag.pendingMarquee = true;
1148
+ }
1149
+ }
1150
+ function handleDefaultPointerDown(drag, actor, point, current) {
1151
+ if (actor) {
1152
+ if (!current.selectedActorIds.includes(actor.id)) {
1153
+ current.onSelectActorIds([actor.id]);
1154
+ drag.movingActorIds = [actor.id];
1155
+ } else {
1156
+ drag.movingActorIds = [...current.selectedActorIds];
1157
+ }
1158
+ drag.kind = 'move';
1159
+ drag.longPressTimer = window.setTimeout(() => {
1160
+ if (drag.movedFar) return;
1161
+ drag.longPressFired = true;
1162
+ current.onSetMultiSelectMode(true);
1163
+ }, LONG_PRESS_MS);
1164
+ } else {
1165
+ current.onSelectActorIds([]);
1166
+ drag.selectionBeforeMarquee = [];
1167
+ drag.pendingMarquee = true;
1168
+ drag.longPressTimer = window.setTimeout(() => {
1169
+ if (drag.movedFar) return;
1170
+ drag.longPressFired = true;
1171
+ current.onSetMultiSelectMode(true);
1172
+ drag.kind = 'marquee';
1173
+ current.marqueeRef.current = { x: point.x, y: point.y, width: 0, height: 0 };
1174
+ }, LONG_PRESS_MS);
1175
+ }
1176
+ }
1177
+ function finalizeMarquee(drag, current) {
1178
+ const m = current.marqueeRef.current;
1179
+ if (!m || !current.sceneData) return;
1180
+ if (m.width === 0 && m.height === 0) return;
1181
+ const scene = makeScene(current.sceneData, behaviorClasses, current.sprites, current.files);
1182
+ const hits = scene.actorIdsInRect(m);
1183
+ const merged = new Set(drag.selectionBeforeMarquee);
1184
+ for (const id of hits) merged.add(id);
1185
+ current.onSelectActorIds([...merged]);
1186
+ }
1187
+ function handleTap(drag, current) {
1188
+ if (!drag.modeAtStart) return;
1189
+ if (drag.startedOnActorId !== null) {
1190
+ const id = drag.startedOnActorId;
1191
+ const wasSelected = current.selectedActorIds.includes(id);
1192
+ current.onSelectActorIds(
1193
+ wasSelected
1194
+ ? current.selectedActorIds.filter((x) => x !== id)
1195
+ : [...current.selectedActorIds, id]
1196
+ );
1197
+ } else {
1198
+ current.onSetMultiSelectMode(false);
1199
+ current.onSelectActorIds([]);
1200
+ }
1201
+ }
1202
+ function useSelectionKeyboard(args) {
1203
+ const ref = useRef(args);
1204
+ ref.current = args;
1205
+ useEffect(() => {
1206
+ function onKeyDown(event) {
1207
+ const current = ref.current;
1208
+ if (current.isPlaying || isEditableTarget(event.target)) return;
1209
+ if (event.key === 'Escape') {
1210
+ if (current.selectedActorIds.length || current.multiSelectMode || current.selectedBlueprintPath) {
1211
+ event.preventDefault();
1212
+ // The wrapped setter also clears any hotbar blueprint selection.
1213
+ current.onSelectActorIds([]);
1214
+ current.onSetMultiSelectMode(false);
1215
+ }
1216
+ return;
1217
+ }
1218
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
1219
+ if (!current.sceneData || current.isBlueprintFile) return;
1220
+ event.preventDefault();
1221
+ current.onSelectActorIds(current.sceneData.actors.map((actor) => actor.id));
1222
+ return;
1223
+ }
1224
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
1225
+ if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
1226
+ event.preventDefault();
1227
+ const { sceneData: next, newIds } = duplicateActors(
1228
+ current.sceneData,
1229
+ current.selectedActorIds
1230
+ );
1231
+ current.commitScene(next);
1232
+ if (newIds.length) current.onSelectActorIds(newIds);
1233
+ return;
1234
+ }
1235
+ if (event.key === 'Delete' || event.key === 'Backspace') {
1236
+ if (!current.sceneData || current.isBlueprintFile || current.selectedActorIds.length === 0) return;
1237
+ event.preventDefault();
1238
+ current.commitScene(removeActors(current.sceneData, current.selectedActorIds));
1239
+ current.onSelectActorIds([]);
1240
+ }
1241
+ }
1242
+ window.addEventListener('keydown', onKeyDown);
1243
+ return () => window.removeEventListener('keydown', onKeyDown);
1244
+ }, []);
1245
+ }
1246
+ function useScenePlayLoop({
1247
+ sceneData,
1248
+ sprites,
1249
+ files,
1250
+ isPlaying,
1251
+ text,
1252
+ canvasRef,
1253
+ runtimeRef,
1254
+ editCameraRef,
1255
+ marqueeRef,
1256
+ selectedActorIdsRef,
1257
+ selectedBlueprintPathRef,
1258
+ }) {
1259
+ // Spin up / tear down the play-mode runtime as the user toggles play. `text`
1260
+ // is the stable identity for `sceneData` (which is re-parsed every render);
1261
+ // depending on `sceneData` here would reset the runtime — and any
1262
+ // behavior-mutated state like Layout.rotation — on every parent re-render.
1263
+ useEffect(() => {
1264
+ if (!isPlaying) {
1265
+ runtimeRef.current = null;
1266
+ return;
1267
+ }
1268
+ if (!sceneData) return;
1269
+ runtimeRef.current = makeScene(sceneData, behaviorClasses, sprites, files).clone();
1270
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1271
+ }, [sprites, isPlaying, text, runtimeRef]);
1272
+ // Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
1273
+ useEffect(() => {
1274
+ if (!sceneData || !canvasRef.current) return undefined;
1275
+ const canvas = canvasRef.current;
1276
+ const ctx = canvas.getContext('2d');
1277
+ if (!ctx) return undefined;
1278
+ const viewport = isPlaying
1279
+ ? undefined
1280
+ : editViewportForZoom(editCameraRef.current.zoom);
1281
+ configureSceneCanvas(canvas, ctx, viewport);
1282
+ let raf = 0;
1283
+ let last = performance.now();
1284
+ const frame = (now) => {
1285
+ const dt = Math.min(0.033, (now - last) / 1000);
1286
+ last = now;
1287
+ // Recompute each frame so the edit zoom (mutated on the camera ref by the
1288
+ // pinch gesture) takes effect without restarting the loop.
1289
+ const frameViewport = isPlaying
1290
+ ? undefined
1291
+ : editViewportForZoom(editCameraRef.current.zoom);
1292
+ const scene = isPlaying
1293
+ ? runtimeRef.current
1294
+ : makeScene(sceneData, behaviorClasses, sprites, files);
1295
+ if (scene) {
1296
+ const snap = getSnapSettings(sceneData);
1297
+ if (isPlaying) scene.update(dt);
1298
+ if (!isPlaying) scene.camera = { ...editCameraRef.current };
1299
+ configureSceneCanvas(canvas, ctx, frameViewport);
1300
+ // Read selection from the ref each frame so the grid/box gating tracks
1301
+ // the current selection without resetting this loop.
1302
+ const editSelectedActorIds = isPlaying ? [] : selectedActorIdsRef.current ?? [];
1303
+ scene.draw(ctx, {
1304
+ selectedActorIds: [],
1305
+ marquee: isPlaying ? null : marqueeRef.current,
1306
+ showGrid: !isPlaying && snap.enabled && editSelectedActorIds.length > 0,
1307
+ gridSize: snap.gridSize,
1308
+ showDebugColliders: false,
1309
+ showCropOutline: !isPlaying,
1310
+ viewport: frameViewport,
1311
+ useCamera: true,
1312
+ editPlaceholders: !isPlaying,
1313
+ dimBlueprintPath: isPlaying ? null : selectedBlueprintPathRef.current,
1314
+ });
1315
+ }
1316
+ raf = requestAnimationFrame(frame);
1317
+ };
1318
+ raf = requestAnimationFrame(frame);
1319
+ return () => cancelAnimationFrame(raf);
1320
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `text` proxies `sceneData` identity; resetting on every parse churns the loop and wipes runtime state.
1321
+ }, [
1322
+ sprites,
1323
+ files,
1324
+ isPlaying,
1325
+ text,
1326
+ canvasRef,
1327
+ runtimeRef,
1328
+ marqueeRef,
1329
+ selectedActorIdsRef,
1330
+ selectedBlueprintPathRef,
1331
+ ]);
1332
+ }
1333
+ function useScenePlayKeys(runtimeRef) {
1334
+ useEffect(() => {
1335
+ const down = (event) => {
1336
+ if (!runtimeRef.current) return;
1337
+ // Space is a game input while playing — stop the browser from scrolling
1338
+ // the panel or re-triggering a focused button (the Play/Stop toggle keeps
1339
+ // focus after a click) instead of reaching gameplay.
1340
+ if (event.key === ' ') event.preventDefault();
1341
+ runtimeRef.current.keys.add(event.key);
1342
+ };
1343
+ const up = (event) => {
1344
+ if (!runtimeRef.current) return;
1345
+ runtimeRef.current.keys.delete(event.key);
1346
+ };
1347
+ window.addEventListener('keydown', down);
1348
+ window.addEventListener('keyup', up);
1349
+ return () => {
1350
+ window.removeEventListener('keydown', down);
1351
+ window.removeEventListener('keyup', up);
1352
+ };
1353
+ }, [runtimeRef]);
1354
+ }
1355
+ function isEditableTarget(target) {
1356
+ return (
1357
+ target instanceof Element &&
1358
+ !!target.closest('input, textarea, select, [contenteditable="true"]')
1359
+ );
1360
+ }
1361
+ // Clone/Delete moved to the on-canvas SelectionOverlay toolbar; this inspector
1362
+ // now only carries the multi-select status text and "Deselect all".
1363
+ function MultiSelectInspector({ count, onDeselectAll }) {
1364
+ return (
1365
+ <div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
1366
+ <div style={{ fontSize: 13, opacity: 0.8 }}>
1367
+ {count === 0
1368
+ ? 'Multi-select mode -- tap actors to add'
1369
+ : `${count} actor${count === 1 ? '' : 's'} selected`}
1370
+ </div>
1371
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
1372
+ <button type="button" onClick={onDeselectAll} style={inspectorActionStyle(false)}>
1373
+ <Icon name="times" /> Deselect all
1374
+ </button>
1375
+ </div>
1376
+ </div>
1377
+ );
1378
+ }
1379
+ function inspectorActionStyle(disabled) {
1380
+ return {
1381
+ display: 'inline-flex',
1382
+ alignItems: 'center',
1383
+ gap: 6,
1384
+ padding: '6px 10px',
1385
+ background: 'transparent',
1386
+ color: 'inherit',
1387
+ border: '1px solid var(--castle-inspector-divider)',
1388
+ borderRadius: 6,
1389
+ cursor: disabled ? 'default' : 'pointer',
1390
+ opacity: disabled ? 0.5 : 1,
1391
+ fontSize: 13,
1392
+ };
1393
+ }
1394
+ function SceneInspector({ sceneData, snapSettings, onChangeScene, onChangeEditor }) {
1395
+ return (
1396
+ <Panel title="Scene">
1397
+ <TextField
1398
+ label="Name"
1399
+ value={sceneData.name ?? ''}
1400
+ onChange={(name) => onChangeScene({ name })}
1401
+ />
1402
+ <ColorField
1403
+ label="Background"
1404
+ value={sceneData.background ?? '#121213'}
1405
+ onChange={(background) => onChangeScene({ background })}
1406
+ />
1407
+ <CheckboxField
1408
+ label="Snap to grid"
1409
+ checked={snapSettings.enabled}
1410
+ onChange={(snapToGrid) => onChangeEditor({ snapToGrid })}
1411
+ />
1412
+ <NumberField
1413
+ label="Grid size"
1414
+ value={snapSettings.gridSize}
1415
+ min={1}
1416
+ step={1}
1417
+ onChange={(gridSize) => onChangeEditor({ gridSize: Math.round(gridSize) })}
1418
+ />
1419
+ </Panel>
1420
+ );
1421
+ }
1422
+ // Sidebar inspector for a hotbar-selected blueprint: badge + name header, then
1423
+ // the template's components through the same ActorInspector panels used for
1424
+ // actors -- the template is presented as a synthetic actor with no `blueprint`
1425
+ // ref, so ActorInspector treats it exactly like a blueprint file's template
1426
+ // (plain edits, no instance hint, Layout not removable). The action callbacks
1427
+ // drop the synthetic actor id and write the blueprint FILE (see
1428
+ // `updateBlueprintTemplate` in SceneEditor).
1429
+ function BlueprintInspector({
1430
+ name,
1431
+ template,
1432
+ sprites,
1433
+ blueprintPath,
1434
+ files,
1435
+ onRename,
1436
+ onSetComponent,
1437
+ onAddBehavior,
1438
+ onRemoveBehavior,
1439
+ }) {
1440
+ const templateActor = { id: '__blueprint__', components: template.components };
1441
+ const instanceCount = countBlueprintInstances(files, blueprintPath);
1442
+ return (
1443
+ <>
1444
+ <div className={styles.instanceHeader}>
1445
+ <div className={styles.instanceHeaderName}>
1446
+ <BlueprintThumbnail blueprint={template} sprites={sprites} />
1447
+ <div className={styles.instanceHeaderNameCol}>
1448
+ <input
1449
+ className={styles.blueprintNameInput}
1450
+ value={name}
1451
+ onChange={(event) => onRename?.(event.target.value)}
1452
+ aria-label="Blueprint name"
1453
+ spellCheck={false}
1454
+ />
1455
+ <span className={styles.instanceHeaderId}>
1456
+ {instanceCount} {instanceCount === 1 ? 'actor' : 'actors'}
1457
+ </span>
1458
+ </div>
1459
+ </div>
1460
+ </div>
1461
+ <ActorInspector
1462
+ selectedActor={templateActor}
1463
+ rawActor={templateActor}
1464
+ files={files}
1465
+ onSetComponent={(actorId, behaviorName, nextProps) => onSetComponent(behaviorName, nextProps)}
1466
+ onAddBehavior={(actorId, behaviorName) => onAddBehavior(behaviorName)}
1467
+ onRemoveBehavior={(actorId, behaviorName) => onRemoveBehavior(behaviorName)}
1468
+ />
1469
+ </>
1470
+ );
1471
+ }
1472
+ // Per-behavior helper the inspector fields use to render instance overrides.
1473
+ // `isOverridden` is true only for inherited props the instance has its own
1474
+ // value for (position/rotation are `inherit: false` and always instance-local,
1475
+ // so they never read as "overriding" a blueprint default). `baseline` is what
1476
+ // the prop falls back to when reset -- the blueprint template value if present,
1477
+ // else the behavior's default. `reset` writes that baseline; for inherited
1478
+ // props `applyComponentPatch` then drops the now-redundant override.
1479
+ function makeOverrideContext({ Behavior, rawComponent, templateProps, setComponent }) {
1480
+ const baselineOf = (prop) =>
1481
+ templateProps && prop in templateProps ? templateProps[prop] : Behavior?.defaultProps?.[prop];
1482
+ return {
1483
+ isOverridden: (prop) =>
1484
+ isInheritedProp(Behavior, prop) && !!rawComponent && prop in rawComponent,
1485
+ // True when the behavior overrides at least one inherited prop -- i.e. it
1486
+ // has a field that renders with the purple override tint. Drives the
1487
+ // matching tint on the panel's title. Position/rotation are inherit:false,
1488
+ // so a position-only instance override doesn't count (nor does it tint any
1489
+ // field), matching isOverridden's semantics.
1490
+ anyOverridden: () =>
1491
+ !!rawComponent && Object.keys(rawComponent).some((prop) => isInheritedProp(Behavior, prop)),
1492
+ baseline: baselineOf,
1493
+ reset: (prop) => setComponent({ [prop]: baselineOf(prop) }),
1494
+ };
1495
+ }
1496
+ function ActorInspector({
1497
+ selectedActor,
1498
+ rawActor,
1499
+ blueprintName,
1500
+ blueprintTemplate,
1501
+ sprites,
1502
+ showAddBehavior = true,
1503
+ files,
1504
+ onSetComponent,
1505
+ onAddBehavior,
1506
+ onRemoveBehavior,
1507
+ onFork,
1508
+ onEditBlueprint,
1509
+ }) {
1510
+ const isInstance = Boolean(rawActor?.blueprint);
1511
+ const presentNames = new Set(
1512
+ Object.entries(selectedActor.components)
1513
+ .filter(([, component]) => !!component)
1514
+ .map(([behaviorName]) => behaviorName)
1515
+ );
1516
+ const availableBehaviors = behaviorClasses.filter(
1517
+ (candidate) => !presentNames.has(candidate.behaviorName)
1518
+ );
1519
+ const behaviorEntries = Object.entries(selectedActor.components).filter((entry) => !!entry[1]);
1520
+ // For an instance, behaviors it doesn't already override are collapsed behind
1521
+ // a reveal button; Layout and any behavior with an instance override stay
1522
+ // visible below the header. Revealing is a pure view toggle -- it never
1523
+ // touches the actor, it just exposes the inherited behaviors' fields so they
1524
+ // can be overridden. Keyed by actor id at the call site, so the collapsed
1525
+ // state resets when a different actor is selected.
1526
+ const [showAllBehaviors, setShowAllBehaviors] = useState(false);
1527
+ const isPinnedBehavior = (behaviorName) =>
1528
+ behaviorName === 'Layout' || Boolean(rawActor?.components?.[behaviorName]);
1529
+ const collapsed = isInstance && !showAllBehaviors;
1530
+ const visibleEntries = collapsed
1531
+ ? behaviorEntries.filter(([behaviorName]) => isPinnedBehavior(behaviorName))
1532
+ : behaviorEntries;
1533
+ const showReveal = collapsed && visibleEntries.length < behaviorEntries.length;
1534
+ return (
1535
+ <>
1536
+ {isInstance ? (
1537
+ <div className={styles.instanceHeader}>
1538
+ <div className={styles.instanceHeaderName}>
1539
+ <BlueprintThumbnail
1540
+ blueprint={blueprintTemplate ?? { components: selectedActor.components }}
1541
+ sprites={sprites}
1542
+ />
1543
+ <div className={styles.instanceHeaderNameCol}>
1544
+ <span className={styles.instanceHeaderNameText}>
1545
+ <strong className={styles.instanceHeaderNameStrong}>{blueprintName}</strong> Actor
1546
+ </span>
1547
+ <span className={styles.instanceHeaderId}>id: {selectedActor.id}</span>
1548
+ </div>
1549
+ </div>
1550
+ {onEditBlueprint ? (
1551
+ <button
1552
+ type="button"
1553
+ className={styles.instanceForkButton}
1554
+ onClick={onEditBlueprint}
1555
+ title="Select this blueprint in the hotbar">
1556
+ <Icon name="pencil" />
1557
+ <span>Edit blueprint</span>
1558
+ </button>
1559
+ ) : null}
1560
+ {onFork ? (
1561
+ <button
1562
+ type="button"
1563
+ className={styles.instanceForkButton}
1564
+ onClick={onFork}
1565
+ title="New blueprint from this actor">
1566
+ <Icon name="code-fork" />
1567
+ <span>Fork new blueprint</span>
1568
+ </button>
1569
+ ) : null}
1570
+ </div>
1571
+ ) : null}
1572
+ {showAddBehavior ? (
1573
+ <AddBehaviorPicker
1574
+ available={availableBehaviors}
1575
+ onAdd={(behaviorName) => onAddBehavior(selectedActor.id, behaviorName)}
1576
+ />
1577
+ ) : null}
1578
+ {visibleEntries.map(([behaviorName, component], index) => {
1579
+ const Behavior = behaviorClasses.find(
1580
+ (candidate) => candidate.behaviorName === behaviorName
1581
+ );
1582
+ const Inspector = Behavior?.Inspector;
1583
+ const setComponent = (nextProps) =>
1584
+ onSetComponent(selectedActor.id, behaviorName, nextProps);
1585
+ // Instance overrides get a purple tint + "Default: X [Reset]" sub-line.
1586
+ // Null when editing a blueprint template (nothing to override against).
1587
+ const override = isInstance
1588
+ ? makeOverrideContext({
1589
+ Behavior,
1590
+ rawComponent: rawActor?.components?.[behaviorName],
1591
+ templateProps: blueprintTemplate?.components?.[behaviorName],
1592
+ setComponent,
1593
+ })
1594
+ : null;
1595
+ const body = Inspector ? (
1596
+ <Inspector
1597
+ actor={selectedActor}
1598
+ component={component}
1599
+ files={files}
1600
+ sprites={sprites}
1601
+ setComponent={setComponent}
1602
+ override={override}
1603
+ />
1604
+ ) : (
1605
+ <AutoInspector
1606
+ behaviorName={behaviorName}
1607
+ defaultProps={Behavior?.defaultProps ?? component}
1608
+ component={component}
1609
+ setComponent={setComponent}
1610
+ override={override}
1611
+ />
1612
+ );
1613
+ // Layout is core to every actor -- it cannot be removed. A component
1614
+ // an instance only has via its blueprint (no override of its own yet)
1615
+ // has nothing to remove either -- v1 has no per-instance "hide this
1616
+ // inherited component" concept (see propertyMeta punt list).
1617
+ const removable =
1618
+ behaviorName !== 'Layout' && (!isInstance || Boolean(rawActor.components?.[behaviorName]));
1619
+ // When the reveal button follows, keep the last visible panel's divider
1620
+ // so it doesn't merge into the button row.
1621
+ const isLast = index === visibleEntries.length - 1 && !showReveal;
1622
+ // Trash renders before the panel so the panel stays the wrapper's
1623
+ // last child -- the panel's own `:last-child` border zeroes out, and
1624
+ // this wrapper owns the divider between panels deterministically.
1625
+ return (
1626
+ <div
1627
+ key={behaviorName}
1628
+ style={{
1629
+ position: 'relative',
1630
+ borderBottom: isLast ? undefined : '1px solid var(--castle-inspector-divider)',
1631
+ }}>
1632
+ {removable ? (
1633
+ <button
1634
+ type="button"
1635
+ aria-label={`Remove ${behaviorName}`}
1636
+ title={`Remove ${behaviorName}`}
1637
+ onClick={() => onRemoveBehavior(selectedActor.id, behaviorName)}
1638
+ style={{
1639
+ position: 'absolute',
1640
+ top: 14,
1641
+ right: 16,
1642
+ border: 'none',
1643
+ background: 'transparent',
1644
+ padding: 0,
1645
+ margin: 0,
1646
+ cursor: 'pointer',
1647
+ color: 'inherit',
1648
+ fontSize: 16,
1649
+ lineHeight: 1,
1650
+ }}>
1651
+ <Icon name="trash" />
1652
+ </button>
1653
+ ) : null}
1654
+ {body}
1655
+ </div>
1656
+ );
1657
+ })}
1658
+ {showReveal ? (
1659
+ <button
1660
+ type="button"
1661
+ className={styles.revealOverridesButton}
1662
+ onClick={() => setShowAllBehaviors(true)}>
1663
+ <Icon name="chevron-down" />
1664
+ <span>Override behavior properties</span>
1665
+ </button>
1666
+ ) : null}
1667
+ </>
1668
+ );
1669
+ }
1670
+ function AddBehaviorPicker({ available, onAdd }) {
1671
+ if (!available.length) return null;
1672
+ return (
1673
+ <div
1674
+ style={{
1675
+ height: 'var(--castle-hotbar-row-full)',
1676
+ padding: '0 16px',
1677
+ display: 'flex',
1678
+ alignItems: 'center',
1679
+ borderBottom: '1px solid var(--castle-inspector-divider)',
1680
+ }}>
1681
+ <select
1682
+ className={styles.select}
1683
+ value=""
1684
+ onChange={(event) => {
1685
+ if (event.target.value) onAdd(event.target.value);
1686
+ }}>
1687
+ <option value="">+ Add behavior</option>
1688
+ {available.map((behavior) => (
1689
+ <option key={behavior.behaviorName} value={behavior.behaviorName}>
1690
+ {behavior.behaviorName}
1691
+ </option>
1692
+ ))}
1693
+ </select>
1694
+ </div>
1695
+ );
1696
+ }