castle-web-cli 0.4.129 → 0.4.130

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