castle-web-cli 0.4.84 → 0.4.85

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