castle-web-cli 0.4.1 → 0.4.2

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 (158) hide show
  1. package/dist/api.d.ts +53 -5
  2. package/dist/api.js +42 -15
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.js +25 -11
  5. package/dist/get-deck.d.ts +3 -0
  6. package/dist/get-deck.js +64 -0
  7. package/dist/ide-client.d.ts +1 -0
  8. package/dist/ide-client.js +537 -0
  9. package/dist/ide.d.ts +16 -0
  10. package/dist/ide.js +546 -0
  11. package/dist/index.js +36 -41
  12. package/dist/init.d.ts +3 -1
  13. package/dist/init.js +170 -24
  14. package/dist/localPaths.d.ts +6 -0
  15. package/dist/localPaths.js +33 -0
  16. package/dist/login.js +1 -1
  17. package/dist/preview.d.ts +3 -0
  18. package/dist/preview.js +53 -34
  19. package/dist/save-deck.d.ts +2 -0
  20. package/dist/{push.js → save-deck.js} +66 -5
  21. package/dist/serve.d.ts +2 -0
  22. package/dist/serve.js +290 -27
  23. package/kits/basic-2d/.prettierrc +8 -0
  24. package/kits/basic-2d/CLAUDE.md +131 -0
  25. package/kits/basic-2d/behaviors/Camera.jsx +43 -0
  26. package/kits/basic-2d/behaviors/Collider.jsx +71 -0
  27. package/kits/basic-2d/behaviors/Drawing.jsx +139 -0
  28. package/kits/basic-2d/behaviors/Layout.jsx +16 -0
  29. package/kits/basic-2d/drawings/floor.drawing +70 -0
  30. package/kits/basic-2d/editors/App.jsx +152 -0
  31. package/kits/basic-2d/editors/CodeEditor.jsx +112 -0
  32. package/kits/basic-2d/editors/DrawingEditor.jsx +222 -0
  33. package/kits/basic-2d/editors/FileBrowser.jsx +143 -0
  34. package/kits/basic-2d/editors/PlayOnly.jsx +21 -0
  35. package/kits/basic-2d/editors/SceneEditor.jsx +1012 -0
  36. package/kits/basic-2d/editors/behaviorRegistry.js +24 -0
  37. package/kits/basic-2d/editors/editorHistory.js +52 -0
  38. package/kits/basic-2d/engine/ScenePlayer.jsx +83 -0
  39. package/kits/basic-2d/engine/SceneUI.jsx +67 -0
  40. package/kits/basic-2d/engine/TouchControls.jsx +136 -0
  41. package/kits/basic-2d/engine/autoInspector.jsx +51 -0
  42. package/kits/basic-2d/engine/files.js +62 -0
  43. package/kits/basic-2d/engine/scene.js +420 -0
  44. package/kits/basic-2d/engine/ui.jsx +344 -0
  45. package/kits/basic-2d/engine/ui.module.css +928 -0
  46. package/kits/basic-2d/eslint.config.js +50 -0
  47. package/kits/basic-2d/index.html +11 -0
  48. package/kits/basic-2d/main.jsx +10 -0
  49. package/kits/basic-2d/package-lock.json +2706 -0
  50. package/kits/basic-2d/package.json +41 -0
  51. package/kits/basic-2d/scenes/main.scene +108 -0
  52. package/kits/basic-2d/vite.config.js +1 -0
  53. package/kits/basic-2d-frozen/.prettierrc +8 -0
  54. package/kits/basic-2d-frozen/CLAUDE.md +131 -0
  55. package/kits/basic-2d-frozen/behaviors/Camera.jsx +43 -0
  56. package/kits/basic-2d-frozen/behaviors/Collider.jsx +71 -0
  57. package/kits/basic-2d-frozen/behaviors/Drawing.jsx +139 -0
  58. package/kits/basic-2d-frozen/behaviors/Layout.jsx +16 -0
  59. package/kits/basic-2d-frozen/drawings/floor.drawing +70 -0
  60. package/kits/basic-2d-frozen/editors/App.jsx +152 -0
  61. package/kits/basic-2d-frozen/editors/CodeEditor.jsx +112 -0
  62. package/kits/basic-2d-frozen/editors/DrawingEditor.jsx +222 -0
  63. package/kits/basic-2d-frozen/editors/FileBrowser.jsx +143 -0
  64. package/kits/basic-2d-frozen/editors/PlayOnly.jsx +21 -0
  65. package/kits/basic-2d-frozen/editors/SceneEditor.jsx +1012 -0
  66. package/kits/basic-2d-frozen/editors/behaviorRegistry.js +24 -0
  67. package/kits/basic-2d-frozen/editors/editorHistory.js +52 -0
  68. package/kits/basic-2d-frozen/engine/ScenePlayer.jsx +83 -0
  69. package/kits/basic-2d-frozen/engine/SceneUI.jsx +67 -0
  70. package/kits/basic-2d-frozen/engine/TouchControls.jsx +136 -0
  71. package/kits/basic-2d-frozen/engine/autoInspector.jsx +51 -0
  72. package/kits/basic-2d-frozen/engine/files.js +62 -0
  73. package/kits/basic-2d-frozen/engine/scene.js +420 -0
  74. package/kits/basic-2d-frozen/engine/ui.jsx +344 -0
  75. package/kits/basic-2d-frozen/engine/ui.module.css +928 -0
  76. package/kits/basic-2d-frozen/eslint.config.js +50 -0
  77. package/kits/basic-2d-frozen/index.html +11 -0
  78. package/kits/basic-2d-frozen/main.jsx +10 -0
  79. package/kits/basic-2d-frozen/package-lock.json +2706 -0
  80. package/kits/basic-2d-frozen/package.json +41 -0
  81. package/kits/basic-2d-frozen/scenes/main.scene +108 -0
  82. package/kits/basic-2d-frozen/vite.config.js +1 -0
  83. package/kits/rpg-2d/.prettierrc +8 -0
  84. package/kits/rpg-2d/behaviors/Camera.tsx +52 -0
  85. package/kits/rpg-2d/behaviors/Collider.tsx +98 -0
  86. package/kits/rpg-2d/behaviors/Dialog.tsx +184 -0
  87. package/kits/rpg-2d/behaviors/Drawing.tsx +161 -0
  88. package/kits/rpg-2d/behaviors/Friend.tsx +45 -0
  89. package/kits/rpg-2d/behaviors/Layout.tsx +29 -0
  90. package/kits/rpg-2d/behaviors/PlayerController.tsx +255 -0
  91. package/kits/rpg-2d/behaviors/Portal.tsx +60 -0
  92. package/kits/rpg-2d/behaviors/QuestLog.tsx +90 -0
  93. package/kits/rpg-2d/behaviors/SaveMenu.tsx +123 -0
  94. package/kits/rpg-2d/behaviors/Tilemap.tsx +90 -0
  95. package/kits/rpg-2d/drawings/bld-home.drawing +8136 -0
  96. package/kits/rpg-2d/drawings/env-crate.drawing +509 -0
  97. package/kits/rpg-2d/drawings/env-fence.drawing +536 -0
  98. package/kits/rpg-2d/drawings/env-flower-bed.drawing +607 -0
  99. package/kits/rpg-2d/drawings/env-fountain.drawing +2622 -0
  100. package/kits/rpg-2d/drawings/env-hedge.drawing +601 -0
  101. package/kits/rpg-2d/drawings/env-house-blue.drawing +1 -0
  102. package/kits/rpg-2d/drawings/env-house-green.drawing +1 -0
  103. package/kits/rpg-2d/drawings/env-tree-oak.drawing +1540 -0
  104. package/kits/rpg-2d/drawings/env-tree-pine.drawing +1315 -0
  105. package/kits/rpg-2d/drawings/floor.drawing +70 -0
  106. package/kits/rpg-2d/drawings/fx-sparkle.drawing +926 -0
  107. package/kits/rpg-2d/drawings/npc-juno-idle-down.drawing +1099 -0
  108. package/kits/rpg-2d/drawings/npc-juno-walk-down.drawing +4177 -0
  109. package/kits/rpg-2d/drawings/npc-opal-idle-down.drawing +1099 -0
  110. package/kits/rpg-2d/drawings/npc-opal-walk-down.drawing +4177 -0
  111. package/kits/rpg-2d/drawings/player-idle-down.drawing +1070 -0
  112. package/kits/rpg-2d/drawings/player-idle-left.drawing +1070 -0
  113. package/kits/rpg-2d/drawings/player-idle-right.drawing +1070 -0
  114. package/kits/rpg-2d/drawings/player-idle-up.drawing +1070 -0
  115. package/kits/rpg-2d/drawings/player-walk-down.drawing +4148 -0
  116. package/kits/rpg-2d/drawings/player-walk-left.drawing +4148 -0
  117. package/kits/rpg-2d/drawings/player-walk-right.drawing +4148 -0
  118. package/kits/rpg-2d/drawings/player-walk-up.drawing +4148 -0
  119. package/kits/rpg-2d/editors/App.tsx +163 -0
  120. package/kits/rpg-2d/editors/CodeEditor.tsx +120 -0
  121. package/kits/rpg-2d/editors/DrawingEditor.tsx +278 -0
  122. package/kits/rpg-2d/editors/FileBrowser.tsx +191 -0
  123. package/kits/rpg-2d/editors/PlayOnly.tsx +26 -0
  124. package/kits/rpg-2d/editors/SceneEditor.tsx +1093 -0
  125. package/kits/rpg-2d/editors/behaviorRegistry.ts +33 -0
  126. package/kits/rpg-2d/editors/editorHistory.ts +75 -0
  127. package/kits/rpg-2d/editors/editorProps.ts +10 -0
  128. package/kits/rpg-2d/engine/ScenePlayer.tsx +130 -0
  129. package/kits/rpg-2d/engine/SceneUI.tsx +74 -0
  130. package/kits/rpg-2d/engine/TouchControls.tsx +157 -0
  131. package/kits/rpg-2d/engine/autoInspector.tsx +111 -0
  132. package/kits/rpg-2d/engine/drawing.ts +81 -0
  133. package/kits/rpg-2d/engine/files.ts +215 -0
  134. package/kits/rpg-2d/engine/scene.ts +484 -0
  135. package/kits/rpg-2d/engine/ui.module.css +928 -0
  136. package/kits/rpg-2d/engine/ui.tsx +483 -0
  137. package/kits/rpg-2d/eslint.config.js +46 -0
  138. package/kits/rpg-2d/index.html +11 -0
  139. package/kits/rpg-2d/main.tsx +14 -0
  140. package/kits/rpg-2d/package-lock.json +3149 -0
  141. package/kits/rpg-2d/package.json +46 -0
  142. package/kits/rpg-2d/scenes/main.scene +203 -0
  143. package/kits/rpg-2d/tsconfig.json +17 -0
  144. package/kits/rpg-2d/vite-env.d.ts +7 -0
  145. package/kits/rpg-2d/vite.config.js +1 -0
  146. package/package.json +27 -5
  147. package/AGENTS.md +0 -25
  148. package/dist/push.d.ts +0 -1
  149. package/src/api.ts +0 -160
  150. package/src/bundle.ts +0 -28
  151. package/src/config.ts +0 -36
  152. package/src/index.ts +0 -143
  153. package/src/init.ts +0 -71
  154. package/src/login.ts +0 -24
  155. package/src/preview.ts +0 -94
  156. package/src/push.ts +0 -118
  157. package/src/serve.ts +0 -134
  158. package/tsconfig.json +0 -13
@@ -0,0 +1,1093 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { PointerEvent } from 'react';
3
+ import { basename, formatJson, parseJsonFile } from '../engine/files';
4
+ import { TouchControls } from '../engine/TouchControls';
5
+ import type { DrawingData, FileMap } from '../engine/files';
6
+ import {
7
+ addActor,
8
+ configureSceneCanvas,
9
+ duplicateActors,
10
+ makeScene,
11
+ moveActors,
12
+ removeActorComponent,
13
+ removeActors,
14
+ screenToCard,
15
+ setActorComponent,
16
+ SceneRuntime,
17
+ } from '../engine/scene';
18
+ import type { SelectionRect } from '../engine/scene';
19
+ import {
20
+ cx,
21
+ EditorBody,
22
+ EditorHeader,
23
+ Icon,
24
+ IconButton,
25
+ SheetGrabHandle,
26
+ styles,
27
+ useMobileSheet,
28
+ } from '../engine/ui';
29
+ import type { SheetSnap } from '../engine/ui';
30
+ import { AutoInspector } from '../engine/autoInspector';
31
+ import { SceneUI } from '../engine/SceneUI';
32
+ import type { ActorData, BehaviorClass, ComponentProps, SceneData } from '../engine/scene';
33
+ import { behaviorClasses, findBehaviorClass } from './behaviorRegistry';
34
+ import type { FileEditorProps } from './editorProps';
35
+ import { useEditHistory } from './editorHistory';
36
+
37
+ type InspectorComponent = (props: {
38
+ actor: ActorData;
39
+ component: ComponentProps;
40
+ files: FileMap;
41
+ setComponent: (nextProps: Partial<ComponentProps>) => void;
42
+ }) => React.ReactNode;
43
+
44
+ interface SceneEditorProps extends FileEditorProps {
45
+ files: FileMap;
46
+ drawings: Record<string, DrawingData>;
47
+ selectedActorIds: string[];
48
+ onSelectActorIds: (ids: string[]) => void;
49
+ multiSelectMode: boolean;
50
+ onSetMultiSelectMode: (active: boolean) => void;
51
+ }
52
+
53
+ const LONG_PRESS_MS = 500;
54
+ const DRAG_THRESHOLD = 4;
55
+
56
+ type DragKind = 'idle' | 'move' | 'marquee';
57
+
58
+ interface DragState {
59
+ pointerId: number;
60
+ startPoint: { x: number; y: number };
61
+ lastPoint: { x: number; y: number };
62
+ startedOnActorId: string | null;
63
+ modeAtStart: boolean;
64
+ movedFar: boolean;
65
+ longPressTimer: number | null;
66
+ longPressFired: boolean;
67
+ kind: DragKind;
68
+ // When true, a pointer-move past the drag threshold promotes kind to
69
+ // 'marquee'. Keeps a stationary press/release reaching the tap handler
70
+ // instead of being eaten by finalizeMarquee as a zero-area release.
71
+ pendingMarquee: boolean;
72
+ movingActorIds: string[];
73
+ recordedMoveSnapshot: boolean;
74
+ selectionBeforeMarquee: string[];
75
+ }
76
+
77
+ // eslint-disable-next-line max-lines-per-function
78
+ export function SceneEditor({
79
+ path,
80
+ text,
81
+ files,
82
+ drawings,
83
+ onChange,
84
+ onToggleFiles,
85
+ filesOpen,
86
+ selectedActorIds,
87
+ onSelectActorIds,
88
+ multiSelectMode,
89
+ onSetMultiSelectMode,
90
+ }: SceneEditorProps) {
91
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
92
+ const runtimeRef = useRef<SceneRuntime | null>(null);
93
+ const marqueeRef = useRef<SelectionRect | null>(null);
94
+ const editCameraRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
95
+ const selectedActorIdsRef = useRef<string[]>(selectedActorIds);
96
+ selectedActorIdsRef.current = selectedActorIds;
97
+ const [isPlaying, setIsPlaying] = useState(false);
98
+ const [panMode, setPanMode] = useState(false);
99
+ const history = useEditHistory(text, onChange);
100
+ const showMulti = selectedActorIds.length > 1 || multiSelectMode;
101
+ const inspectorSheet = useSelectionInspectorSheet(selectedActorIds.length > 0 || multiSelectMode);
102
+ const { value: sceneData, error } = parseJsonFile<SceneData>(path, text);
103
+ const getRuntimeKeys = useCallback(() => runtimeRef.current?.keys ?? null, []);
104
+ const getRuntime = useCallback(() => runtimeRef.current, []);
105
+
106
+ useScenePlayLoop({
107
+ sceneData,
108
+ drawings,
109
+ isPlaying,
110
+ text,
111
+ canvasRef,
112
+ runtimeRef,
113
+ editCameraRef,
114
+ selectedActorIds,
115
+ marqueeRef,
116
+ });
117
+ useScenePlayKeys(runtimeRef, setIsPlaying);
118
+
119
+ const gesture = useSelectionGesture({
120
+ canvasRef,
121
+ sceneData,
122
+ drawings,
123
+ isPlaying,
124
+ selectedActorIds,
125
+ onSelectActorIds,
126
+ multiSelectMode,
127
+ onSetMultiSelectMode,
128
+ marqueeRef,
129
+ editCameraRef,
130
+ applyScene: (next) => onChange(formatJson(next)),
131
+ recordSceneSnapshot: history.recordSnapshot,
132
+ });
133
+ const playPointer = usePlayPointerGesture({ canvasRef, runtimeRef });
134
+ const panGesture = usePanGesture({ canvasRef, editCameraRef, enabled: panMode && !isPlaying });
135
+
136
+ useSelectionKeyboard({
137
+ sceneData,
138
+ selectedActorIds,
139
+ onSelectActorIds,
140
+ multiSelectMode,
141
+ onSetMultiSelectMode,
142
+ isPlaying,
143
+ commitScene: (next) => history.commit(formatJson(next)),
144
+ });
145
+
146
+ if (!sceneData) {
147
+ return (
148
+ <>
149
+ <EditorHeader title={basename(path)} onToggleFiles={onToggleFiles} filesOpen={filesOpen} />
150
+ <EditorBody>
151
+ <div className={styles.inspector}>{error}</div>
152
+ </EditorBody>
153
+ </>
154
+ );
155
+ }
156
+ // Re-bind under a non-nullable type so the handlers below close over the
157
+ // narrowed value -- TS won't propagate the early-return narrowing into
158
+ // closures captured by inner functions.
159
+ const data: SceneData = sceneData;
160
+
161
+ const selectedActor =
162
+ data.actors.find((actor) => actor.id === selectedActorIds[0]) ?? data.actors[0];
163
+
164
+ const actions = makeSceneActions({
165
+ sceneData: data,
166
+ commit: history.commit,
167
+ selectedActorIds,
168
+ onSelectActorIds,
169
+ });
170
+
171
+ function applyScene(next: SceneData): void {
172
+ onChange(formatJson(next));
173
+ }
174
+
175
+ function handleAddActor(): void {
176
+ const { sceneData: next, newId } = addActor(data, files, drawings);
177
+ history.recordSnapshot();
178
+ applyScene(next);
179
+ onSelectActorIds([newId]);
180
+ }
181
+
182
+ function handleDuplicateSelection(): void {
183
+ if (selectedActorIds.length === 0) return;
184
+ const { sceneData: next, newIds } = duplicateActors(data, selectedActorIds);
185
+ history.recordSnapshot();
186
+ applyScene(next);
187
+ if (newIds.length) onSelectActorIds(newIds);
188
+ }
189
+
190
+ function handleRemoveSelection(): void {
191
+ if (selectedActorIds.length === 0) return;
192
+ history.recordSnapshot();
193
+ applyScene(removeActors(data, selectedActorIds));
194
+ onSelectActorIds([]);
195
+ }
196
+
197
+ const hasSelection = selectedActorIds.length > 0;
198
+ // Playback group: centered. Actor-tool group: right-aligned. Empty span
199
+ // on the left of the 3-column grid keeps the playback group visually
200
+ // centered. Same layout as basic-2d.
201
+ const playbackButtons = (
202
+ <>
203
+ <IconButton
204
+ icon={isPlaying ? 'stop' : 'play'}
205
+ label={isPlaying ? 'Stop' : 'Play'}
206
+ active={isPlaying}
207
+ variant={isPlaying ? 'primary' : ''}
208
+ onClick={() => setIsPlaying(!isPlaying)}
209
+ />
210
+ <IconButton
211
+ icon="undo"
212
+ label="Undo"
213
+ onClick={history.undo}
214
+ disabled={!history.canUndo || isPlaying}
215
+ />
216
+ <IconButton
217
+ icon="redo"
218
+ label="Redo"
219
+ onClick={history.redo}
220
+ disabled={!history.canRedo || isPlaying}
221
+ />
222
+ <span style={{ width: 16 }} aria-hidden="true" />
223
+ <IconButton
224
+ icon="camera"
225
+ label="Pan camera"
226
+ active={panMode}
227
+ onClick={() => setPanMode((value) => !value)}
228
+ disabled={isPlaying}
229
+ />
230
+ </>
231
+ );
232
+ const actorButtons = (
233
+ <>
234
+ <IconButton icon="plus" label="Add actor" onClick={handleAddActor} disabled={isPlaying} />
235
+ <IconButton
236
+ icon="clone"
237
+ label="Duplicate"
238
+ onClick={handleDuplicateSelection}
239
+ disabled={!hasSelection || isPlaying}
240
+ />
241
+ <IconButton
242
+ icon="trash"
243
+ label="Remove"
244
+ onClick={handleRemoveSelection}
245
+ disabled={!hasSelection || isPlaying}
246
+ />
247
+ </>
248
+ );
249
+
250
+ return (
251
+ <>
252
+ <EditorHeader
253
+ title={sceneData.name ?? basename(path)}
254
+ subtitle={isPlaying ? 'WASD / arrows' : `${sceneData.actors.length} actors`}
255
+ right={
256
+ <span className={styles.mobileOnly}>
257
+ {playbackButtons}
258
+ {actorButtons}
259
+ </span>
260
+ }
261
+ onToggleFiles={onToggleFiles}
262
+ filesOpen={filesOpen}
263
+ />
264
+ <EditorBody>
265
+ <div className={styles.sceneWorkspace}>
266
+ <div className={styles.sceneTools}>
267
+ <span aria-hidden="true" />
268
+ <div className={styles.sceneToolsGroup}>{playbackButtons}</div>
269
+ <div className={styles.sceneToolsGroup}>{actorButtons}</div>
270
+ </div>
271
+ <div className={styles.stageWrap}>
272
+ <div className={styles.stageCard}>
273
+ <canvas
274
+ ref={canvasRef}
275
+ className={styles.stageCanvas}
276
+ onPointerDown={
277
+ isPlaying
278
+ ? playPointer.onPointerDown
279
+ : panMode
280
+ ? panGesture.onPointerDown
281
+ : gesture.onPointerDown
282
+ }
283
+ onPointerMove={
284
+ isPlaying
285
+ ? playPointer.onPointerMove
286
+ : panMode
287
+ ? panGesture.onPointerMove
288
+ : gesture.onPointerMove
289
+ }
290
+ onPointerUp={
291
+ isPlaying
292
+ ? playPointer.onPointerUp
293
+ : panMode
294
+ ? panGesture.onPointerUp
295
+ : gesture.onPointerUp
296
+ }
297
+ onPointerCancel={
298
+ isPlaying
299
+ ? playPointer.onPointerUp
300
+ : panMode
301
+ ? panGesture.onPointerUp
302
+ : gesture.onPointerUp
303
+ }
304
+ />
305
+ {isPlaying && <SceneUI getRuntime={getRuntime} />}
306
+ </div>
307
+ </div>
308
+ <aside {...inspectorSheet.rootProps}>
309
+ <div {...inspectorSheet.grabProps}>
310
+ <SheetGrabHandle
311
+ label={showMulti ? 'Multi-select' : 'Inspector'}
312
+ hint={
313
+ showMulti
314
+ ? `${selectedActorIds.length} actor${selectedActorIds.length === 1 ? '' : 's'} selected`
315
+ : selectedActor
316
+ ? `actor ${selectedActor.id}`
317
+ : 'tap an actor'
318
+ }
319
+ />
320
+ </div>
321
+ <div className={cx(styles.sheetBody, styles.inspectorBody)}>
322
+ {showMulti ? (
323
+ <MultiSelectInspector
324
+ count={selectedActorIds.length}
325
+ onDelete={actions.deleteSelection}
326
+ onDuplicate={actions.duplicateSelection}
327
+ onDeselectAll={() => {
328
+ onSelectActorIds([]);
329
+ onSetMultiSelectMode(false);
330
+ }}
331
+ />
332
+ ) : (
333
+ <SceneInspector
334
+ selectedActor={selectedActor}
335
+ files={files}
336
+ onSetComponent={actions.updateComponent}
337
+ onAddBehavior={actions.addBehavior}
338
+ onRemoveBehavior={actions.removeBehavior}
339
+ />
340
+ )}
341
+ </div>
342
+ </aside>
343
+ </div>
344
+ </EditorBody>
345
+ <TouchControls getKeys={getRuntimeKeys} visible={isPlaying} />
346
+ </>
347
+ );
348
+ }
349
+
350
+ interface SceneActions {
351
+ updateComponent: (
352
+ actorId: string,
353
+ behaviorName: string,
354
+ nextProps: Partial<ComponentProps>
355
+ ) => void;
356
+ addBehavior: (actorId: string, behaviorName: string) => void;
357
+ removeBehavior: (actorId: string, behaviorName: string) => void;
358
+ deleteSelection: () => void;
359
+ duplicateSelection: () => void;
360
+ }
361
+
362
+ function makeSceneActions({
363
+ sceneData,
364
+ commit,
365
+ selectedActorIds,
366
+ onSelectActorIds,
367
+ }: {
368
+ sceneData: SceneData;
369
+ commit: (nextText: string) => void;
370
+ selectedActorIds: string[];
371
+ onSelectActorIds: (ids: string[]) => void;
372
+ }): SceneActions {
373
+ function commitScene(next: SceneData): void {
374
+ commit(formatJson(next));
375
+ }
376
+ return {
377
+ updateComponent: (actorId, behaviorName, nextProps) =>
378
+ commitScene(setActorComponent(sceneData, actorId, behaviorName, nextProps)),
379
+ addBehavior: (actorId, behaviorName) => {
380
+ const Behavior = findBehaviorClass(behaviorName);
381
+ if (!Behavior) return;
382
+ commitScene(
383
+ setActorComponent(sceneData, actorId, behaviorName, { ...Behavior.defaultProps })
384
+ );
385
+ },
386
+ removeBehavior: (actorId, behaviorName) =>
387
+ commitScene(removeActorComponent(sceneData, actorId, behaviorName)),
388
+ deleteSelection: () => {
389
+ if (selectedActorIds.length === 0) return;
390
+ commitScene(removeActors(sceneData, selectedActorIds));
391
+ onSelectActorIds([]);
392
+ },
393
+ duplicateSelection: () => {
394
+ if (selectedActorIds.length === 0) return;
395
+ const { sceneData: next, newIds } = duplicateActors(sceneData, selectedActorIds);
396
+ commitScene(next);
397
+ if (newIds.length) onSelectActorIds(newIds);
398
+ },
399
+ };
400
+ }
401
+
402
+ function useSelectionInspectorSheet(open: boolean): ReturnType<typeof useMobileSheet> {
403
+ const [snap, setSnap] = useState<'high' | 'low'>('high');
404
+ // Inspector visibility is driven by selection / multi-mode. Snap state
405
+ // persists across selections so switching keeps the user's chosen snap.
406
+ const effectiveSnap: SheetSnap = open ? snap : 'hidden';
407
+ return useMobileSheet({
408
+ snap: effectiveSnap,
409
+ baseClassName: styles.inspector,
410
+ onTransition: (direction) => {
411
+ if (!open) return;
412
+ if (direction === 'tap') setSnap((prev) => (prev === 'high' ? 'low' : 'high'));
413
+ else if (direction === 'down') setSnap('low');
414
+ else if (direction === 'up') setSnap('high');
415
+ },
416
+ });
417
+ }
418
+
419
+ interface SelectionGestureArgs {
420
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
421
+ sceneData: SceneData | null;
422
+ drawings: Record<string, DrawingData>;
423
+ isPlaying: boolean;
424
+ selectedActorIds: string[];
425
+ onSelectActorIds: (ids: string[]) => void;
426
+ multiSelectMode: boolean;
427
+ onSetMultiSelectMode: (active: boolean) => void;
428
+ marqueeRef: React.RefObject<SelectionRect | null>;
429
+ editCameraRef: React.RefObject<{ x: number; y: number }>;
430
+ applyScene: (next: SceneData) => void;
431
+ recordSceneSnapshot: () => void;
432
+ }
433
+
434
+ function useSelectionGesture(args: SelectionGestureArgs): {
435
+ onPointerDown: (event: PointerEvent<HTMLCanvasElement>) => void;
436
+ onPointerMove: (event: PointerEvent<HTMLCanvasElement>) => void;
437
+ onPointerUp: (event: PointerEvent<HTMLCanvasElement>) => void;
438
+ } {
439
+ const dragRef = useRef<DragState | null>(null);
440
+ // Pin args in a ref so the timer callback reads the latest values without
441
+ // re-creating the handlers each render.
442
+ const argsRef = useRef(args);
443
+ argsRef.current = args;
444
+
445
+ const onPointerDown = useCallback((event: PointerEvent<HTMLCanvasElement>) => {
446
+ const current = argsRef.current;
447
+ if (current.isPlaying || !current.canvasRef.current || !current.sceneData) return;
448
+ const raw = screenToCard(current.canvasRef.current, event.clientX, event.clientY);
449
+ const cam = current.editCameraRef.current;
450
+ const point = { x: raw.x + cam.x, y: raw.y + cam.y };
451
+ current.canvasRef.current.setPointerCapture(event.pointerId);
452
+ const scene = makeScene(current.sceneData, behaviorClasses, current.drawings);
453
+ const actor = scene.actorAt(point.x, point.y);
454
+ const drag: DragState = {
455
+ pointerId: event.pointerId,
456
+ startPoint: point,
457
+ lastPoint: point,
458
+ startedOnActorId: actor?.id ?? null,
459
+ modeAtStart: current.multiSelectMode,
460
+ movedFar: false,
461
+ longPressTimer: null,
462
+ longPressFired: false,
463
+ kind: 'idle',
464
+ pendingMarquee: false,
465
+ movingActorIds: [],
466
+ recordedMoveSnapshot: false,
467
+ selectionBeforeMarquee: [...current.selectedActorIds],
468
+ };
469
+ if (event.shiftKey) {
470
+ handleShiftPointerDown(drag, actor, current);
471
+ } else if (current.multiSelectMode) {
472
+ handleModePointerDown(drag, actor, current);
473
+ } else {
474
+ handleDefaultPointerDown(drag, actor, point, current);
475
+ }
476
+ dragRef.current = drag;
477
+ }, []);
478
+
479
+ const onPointerMove = useCallback((event: PointerEvent<HTMLCanvasElement>) => {
480
+ const drag = dragRef.current;
481
+ const current = argsRef.current;
482
+ if (!drag || drag.pointerId !== event.pointerId) return;
483
+ if (!current.canvasRef.current || !current.sceneData) return;
484
+ const raw = screenToCard(current.canvasRef.current, event.clientX, event.clientY);
485
+ const cam = current.editCameraRef.current;
486
+ const point = { x: raw.x + cam.x, y: raw.y + cam.y };
487
+ const dxTotal = point.x - drag.startPoint.x;
488
+ const dyTotal = point.y - drag.startPoint.y;
489
+ if (!drag.movedFar && Math.hypot(dxTotal, dyTotal) > DRAG_THRESHOLD) {
490
+ drag.movedFar = true;
491
+ if (drag.longPressTimer !== null) {
492
+ window.clearTimeout(drag.longPressTimer);
493
+ drag.longPressTimer = null;
494
+ }
495
+ if (drag.pendingMarquee && drag.kind === 'idle') drag.kind = 'marquee';
496
+ }
497
+ if (drag.kind === 'move' && drag.movedFar) {
498
+ const dx = point.x - drag.lastPoint.x;
499
+ const dy = point.y - drag.lastPoint.y;
500
+ drag.lastPoint = point;
501
+ if (!drag.recordedMoveSnapshot) {
502
+ current.recordSceneSnapshot();
503
+ drag.recordedMoveSnapshot = true;
504
+ }
505
+ current.applyScene(moveActors(current.sceneData, drag.movingActorIds, dx, dy));
506
+ } else if (drag.kind === 'marquee') {
507
+ drag.lastPoint = point;
508
+ current.marqueeRef.current = {
509
+ x: Math.min(drag.startPoint.x, point.x),
510
+ y: Math.min(drag.startPoint.y, point.y),
511
+ width: Math.abs(point.x - drag.startPoint.x),
512
+ height: Math.abs(point.y - drag.startPoint.y),
513
+ };
514
+ }
515
+ }, []);
516
+
517
+ const onPointerUp = useCallback((event: PointerEvent<HTMLCanvasElement>) => {
518
+ const drag = dragRef.current;
519
+ const current = argsRef.current;
520
+ if (!drag || drag.pointerId !== event.pointerId) return;
521
+ if (drag.longPressTimer !== null) {
522
+ window.clearTimeout(drag.longPressTimer);
523
+ drag.longPressTimer = null;
524
+ }
525
+ if (drag.kind === 'marquee') {
526
+ finalizeMarquee(drag, current);
527
+ } else if (drag.kind === 'idle' && !drag.movedFar && !drag.longPressFired) {
528
+ handleTap(drag, current);
529
+ }
530
+ current.marqueeRef.current = null;
531
+ dragRef.current = null;
532
+ }, []);
533
+
534
+ return { onPointerDown, onPointerMove, onPointerUp };
535
+ }
536
+
537
+ function usePlayPointerGesture({
538
+ canvasRef,
539
+ runtimeRef,
540
+ }: {
541
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
542
+ runtimeRef: React.RefObject<SceneRuntime | null>;
543
+ }): {
544
+ onPointerDown: (event: PointerEvent<HTMLCanvasElement>) => void;
545
+ onPointerMove: (event: PointerEvent<HTMLCanvasElement>) => void;
546
+ onPointerUp: (event: PointerEvent<HTMLCanvasElement>) => void;
547
+ } {
548
+ const setPointer = useCallback(
549
+ (event: PointerEvent<HTMLCanvasElement>, down?: boolean): void => {
550
+ const canvas = canvasRef.current;
551
+ const runtime = runtimeRef.current;
552
+ if (!canvas || !runtime) return;
553
+ runtime.setPointerFromScreen(canvas, event.clientX, event.clientY, down);
554
+ },
555
+ [canvasRef, runtimeRef]
556
+ );
557
+
558
+ const onPointerDown = useCallback(
559
+ (event: PointerEvent<HTMLCanvasElement>): void => {
560
+ event.currentTarget.setPointerCapture(event.pointerId);
561
+ setPointer(event, true);
562
+ },
563
+ [setPointer]
564
+ );
565
+
566
+ const onPointerMove = useCallback(
567
+ (event: PointerEvent<HTMLCanvasElement>): void => {
568
+ setPointer(event);
569
+ },
570
+ [setPointer]
571
+ );
572
+
573
+ const onPointerUp = useCallback(
574
+ (event: PointerEvent<HTMLCanvasElement>): void => {
575
+ setPointer(event, false);
576
+ try {
577
+ event.currentTarget.releasePointerCapture(event.pointerId);
578
+ } catch {
579
+ // Pointer capture may already be gone after cancel/blur.
580
+ }
581
+ },
582
+ [setPointer]
583
+ );
584
+
585
+ return { onPointerDown, onPointerMove, onPointerUp };
586
+ }
587
+
588
+ function usePanGesture({
589
+ canvasRef,
590
+ editCameraRef,
591
+ enabled,
592
+ }: {
593
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
594
+ editCameraRef: React.RefObject<{ x: number; y: number }>;
595
+ enabled: boolean;
596
+ }): {
597
+ onPointerDown: (event: PointerEvent<HTMLCanvasElement>) => void;
598
+ onPointerMove: (event: PointerEvent<HTMLCanvasElement>) => void;
599
+ onPointerUp: (event: PointerEvent<HTMLCanvasElement>) => void;
600
+ } {
601
+ const dragRef = useRef<{ id: number; last: { x: number; y: number } } | null>(null);
602
+
603
+ const onPointerDown = useCallback(
604
+ (event: PointerEvent<HTMLCanvasElement>): void => {
605
+ if (!enabled) return;
606
+ const canvas = canvasRef.current;
607
+ if (!canvas) return;
608
+ const point = screenToCard(canvas, event.clientX, event.clientY);
609
+ event.currentTarget.setPointerCapture(event.pointerId);
610
+ dragRef.current = { id: event.pointerId, last: point };
611
+ },
612
+ [canvasRef, enabled]
613
+ );
614
+
615
+ const onPointerMove = useCallback(
616
+ (event: PointerEvent<HTMLCanvasElement>): void => {
617
+ const drag = dragRef.current;
618
+ if (!drag || drag.id !== event.pointerId) return;
619
+ const canvas = canvasRef.current;
620
+ if (!canvas) return;
621
+ const point = screenToCard(canvas, event.clientX, event.clientY);
622
+ const dx = point.x - drag.last.x;
623
+ const dy = point.y - drag.last.y;
624
+ drag.last = point;
625
+ editCameraRef.current = {
626
+ x: editCameraRef.current.x - dx,
627
+ y: editCameraRef.current.y - dy,
628
+ };
629
+ },
630
+ [canvasRef, editCameraRef]
631
+ );
632
+
633
+ const onPointerUp = useCallback(
634
+ (event: PointerEvent<HTMLCanvasElement>): void => {
635
+ const drag = dragRef.current;
636
+ if (!drag || drag.id !== event.pointerId) return;
637
+ try {
638
+ event.currentTarget.releasePointerCapture(event.pointerId);
639
+ } catch {
640
+ // pointer capture may already be gone
641
+ }
642
+ dragRef.current = null;
643
+ },
644
+ []
645
+ );
646
+
647
+ return { onPointerDown, onPointerMove, onPointerUp };
648
+ }
649
+
650
+ function handleShiftPointerDown(
651
+ drag: DragState,
652
+ actor: ActorData | null,
653
+ current: SelectionGestureArgs
654
+ ): void {
655
+ if (actor) {
656
+ const wasSelected = current.selectedActorIds.includes(actor.id);
657
+ const nextIds = wasSelected
658
+ ? current.selectedActorIds.filter((id) => id !== actor.id)
659
+ : [...current.selectedActorIds, actor.id];
660
+ current.onSelectActorIds(nextIds);
661
+ if (!wasSelected) {
662
+ drag.kind = 'move';
663
+ drag.movingActorIds = nextIds;
664
+ }
665
+ } else {
666
+ drag.pendingMarquee = true;
667
+ }
668
+ }
669
+
670
+ function handleModePointerDown(
671
+ drag: DragState,
672
+ actor: ActorData | null,
673
+ current: SelectionGestureArgs
674
+ ): void {
675
+ if (actor) {
676
+ if (current.selectedActorIds.includes(actor.id)) {
677
+ drag.kind = 'move';
678
+ drag.movingActorIds = [...current.selectedActorIds];
679
+ }
680
+ // else: leave kind='idle' so pointerup triggers the tap-toggle path.
681
+ } else {
682
+ // Defer marquee until movement, so a stationary press/release reaches
683
+ // the tap handler (which exits multi mode on empty tap).
684
+ drag.pendingMarquee = true;
685
+ }
686
+ }
687
+
688
+ function handleDefaultPointerDown(
689
+ drag: DragState,
690
+ actor: ActorData | null,
691
+ point: { x: number; y: number },
692
+ current: SelectionGestureArgs
693
+ ): void {
694
+ if (actor) {
695
+ if (!current.selectedActorIds.includes(actor.id)) {
696
+ current.onSelectActorIds([actor.id]);
697
+ drag.movingActorIds = [actor.id];
698
+ } else {
699
+ drag.movingActorIds = [...current.selectedActorIds];
700
+ }
701
+ drag.kind = 'move';
702
+ drag.longPressTimer = window.setTimeout(() => {
703
+ if (drag.movedFar) return;
704
+ drag.longPressFired = true;
705
+ current.onSetMultiSelectMode(true);
706
+ }, LONG_PRESS_MS);
707
+ } else {
708
+ current.onSelectActorIds([]);
709
+ drag.longPressTimer = window.setTimeout(() => {
710
+ if (drag.movedFar) return;
711
+ drag.longPressFired = true;
712
+ current.onSetMultiSelectMode(true);
713
+ drag.kind = 'marquee';
714
+ current.marqueeRef.current = { x: point.x, y: point.y, width: 0, height: 0 };
715
+ }, LONG_PRESS_MS);
716
+ }
717
+ }
718
+
719
+ function finalizeMarquee(drag: DragState, current: SelectionGestureArgs): void {
720
+ const m = current.marqueeRef.current;
721
+ if (!m || !current.sceneData) return;
722
+ if (m.width === 0 && m.height === 0) return;
723
+ const scene = makeScene(current.sceneData, behaviorClasses, current.drawings);
724
+ const hits = scene.actorIdsInRect(m);
725
+ const merged = new Set(drag.selectionBeforeMarquee);
726
+ for (const id of hits) merged.add(id);
727
+ current.onSelectActorIds([...merged]);
728
+ }
729
+
730
+ function handleTap(drag: DragState, current: SelectionGestureArgs): void {
731
+ if (!drag.modeAtStart) return;
732
+ if (drag.startedOnActorId !== null) {
733
+ const id = drag.startedOnActorId;
734
+ const wasSelected = current.selectedActorIds.includes(id);
735
+ current.onSelectActorIds(
736
+ wasSelected
737
+ ? current.selectedActorIds.filter((x) => x !== id)
738
+ : [...current.selectedActorIds, id]
739
+ );
740
+ } else {
741
+ current.onSetMultiSelectMode(false);
742
+ current.onSelectActorIds([]);
743
+ }
744
+ }
745
+
746
+ interface SelectionKeyboardArgs {
747
+ sceneData: SceneData | null;
748
+ selectedActorIds: string[];
749
+ onSelectActorIds: (ids: string[]) => void;
750
+ multiSelectMode: boolean;
751
+ onSetMultiSelectMode: (active: boolean) => void;
752
+ isPlaying: boolean;
753
+ commitScene: (next: SceneData) => void;
754
+ }
755
+
756
+ function useSelectionKeyboard(args: SelectionKeyboardArgs): void {
757
+ const ref = useRef(args);
758
+ ref.current = args;
759
+ useEffect(() => {
760
+ function onKeyDown(event: KeyboardEvent): void {
761
+ const current = ref.current;
762
+ if (current.isPlaying || isEditableTarget(event.target)) return;
763
+ if (event.key === 'Escape') {
764
+ if (current.selectedActorIds.length || current.multiSelectMode) {
765
+ event.preventDefault();
766
+ current.onSelectActorIds([]);
767
+ current.onSetMultiSelectMode(false);
768
+ }
769
+ return;
770
+ }
771
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
772
+ if (!current.sceneData) return;
773
+ event.preventDefault();
774
+ current.onSelectActorIds(current.sceneData.actors.map((actor) => actor.id));
775
+ return;
776
+ }
777
+ if (event.key === 'Delete' || event.key === 'Backspace') {
778
+ if (!current.sceneData || current.selectedActorIds.length === 0) return;
779
+ event.preventDefault();
780
+ current.commitScene(removeActors(current.sceneData, current.selectedActorIds));
781
+ current.onSelectActorIds([]);
782
+ }
783
+ }
784
+ window.addEventListener('keydown', onKeyDown);
785
+ return () => window.removeEventListener('keydown', onKeyDown);
786
+ }, []);
787
+ }
788
+
789
+ function useScenePlayLoop({
790
+ sceneData,
791
+ drawings,
792
+ isPlaying,
793
+ text,
794
+ canvasRef,
795
+ runtimeRef,
796
+ editCameraRef,
797
+ selectedActorIds,
798
+ marqueeRef,
799
+ }: {
800
+ sceneData: SceneData | null;
801
+ drawings: Record<string, DrawingData>;
802
+ isPlaying: boolean;
803
+ text: string;
804
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
805
+ runtimeRef: React.RefObject<SceneRuntime | null>;
806
+ editCameraRef: React.RefObject<{ x: number; y: number }>;
807
+ selectedActorIds: string[];
808
+ marqueeRef: React.RefObject<SelectionRect | null>;
809
+ }): void {
810
+ // Spin up / tear down the play-mode runtime as the user toggles play.
811
+ useEffect(() => {
812
+ if (!sceneData) return;
813
+ if (!isPlaying) {
814
+ runtimeRef.current = null;
815
+ return;
816
+ }
817
+ runtimeRef.current = makeScene(sceneData, behaviorClasses, drawings).clone();
818
+ }, [drawings, isPlaying, sceneData, text, runtimeRef]);
819
+
820
+ // Animation loop -- draws edit-mode previews and ticks the play-mode runtime.
821
+ useEffect(() => {
822
+ if (!sceneData || !canvasRef.current) return undefined;
823
+ const canvas = canvasRef.current;
824
+ const ctx = canvas.getContext('2d');
825
+ if (!ctx) return undefined;
826
+ configureSceneCanvas(canvas, ctx);
827
+ let raf = 0;
828
+ let last = performance.now();
829
+ const frame = (now: number): void => {
830
+ const dt = Math.min(0.033, (now - last) / 1000);
831
+ last = now;
832
+ const scene = isPlaying
833
+ ? runtimeRef.current
834
+ : makeScene(sceneData, behaviorClasses, drawings);
835
+ if (scene) {
836
+ if (isPlaying) scene.update(dt);
837
+ if (!isPlaying) scene.camera = { ...editCameraRef.current };
838
+ configureSceneCanvas(canvas, ctx);
839
+ scene.draw(ctx, {
840
+ selectedActorIds: isPlaying ? [] : selectedActorIds,
841
+ marquee: isPlaying ? null : marqueeRef.current,
842
+ showGrid: false,
843
+ showDebugColliders: false,
844
+ useCamera: true,
845
+ editPlaceholders: !isPlaying,
846
+ });
847
+ }
848
+ raf = requestAnimationFrame(frame);
849
+ };
850
+ raf = requestAnimationFrame(frame);
851
+ return () => cancelAnimationFrame(raf);
852
+ }, [drawings, isPlaying, sceneData, selectedActorIds, text, canvasRef, runtimeRef, marqueeRef]);
853
+ }
854
+
855
+ function useScenePlayKeys(
856
+ runtimeRef: React.RefObject<SceneRuntime | null>,
857
+ setIsPlaying: React.Dispatch<React.SetStateAction<boolean>>
858
+ ): void {
859
+ useEffect(() => {
860
+ const down = (event: KeyboardEvent): void => {
861
+ if (event.key === ' ' && !isEditableTarget(event.target)) {
862
+ event.preventDefault();
863
+ setIsPlaying((current) => !current);
864
+ return;
865
+ }
866
+ if (!runtimeRef.current) return;
867
+ runtimeRef.current.keys.add(event.key);
868
+ };
869
+ const up = (event: KeyboardEvent): void => {
870
+ if (!runtimeRef.current) return;
871
+ runtimeRef.current.keys.delete(event.key);
872
+ };
873
+ window.addEventListener('keydown', down);
874
+ window.addEventListener('keyup', up);
875
+ return () => {
876
+ window.removeEventListener('keydown', down);
877
+ window.removeEventListener('keyup', up);
878
+ };
879
+ }, [runtimeRef, setIsPlaying]);
880
+ }
881
+
882
+ function isEditableTarget(target: EventTarget | null): boolean {
883
+ return (
884
+ target instanceof Element &&
885
+ !!target.closest('input, textarea, select, [contenteditable="true"]')
886
+ );
887
+ }
888
+
889
+ function MultiSelectInspector({
890
+ count,
891
+ onDelete,
892
+ onDuplicate,
893
+ onDeselectAll,
894
+ }: {
895
+ count: number;
896
+ onDelete: () => void;
897
+ onDuplicate: () => void;
898
+ onDeselectAll: () => void;
899
+ }) {
900
+ return (
901
+ <div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
902
+ <div style={{ fontSize: 13, opacity: 0.8 }}>
903
+ {count === 0
904
+ ? 'Multi-select mode -- tap actors to add'
905
+ : `${count} actor${count === 1 ? '' : 's'} selected`}
906
+ </div>
907
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
908
+ <button
909
+ type="button"
910
+ onClick={onDuplicate}
911
+ disabled={count === 0}
912
+ style={inspectorActionStyle(count === 0)}>
913
+ <Icon name="clone" /> Duplicate
914
+ </button>
915
+ <button
916
+ type="button"
917
+ onClick={onDelete}
918
+ disabled={count === 0}
919
+ style={inspectorActionStyle(count === 0)}>
920
+ <Icon name="trash" /> Delete
921
+ </button>
922
+ <button type="button" onClick={onDeselectAll} style={inspectorActionStyle(false)}>
923
+ <Icon name="times" /> Deselect all
924
+ </button>
925
+ </div>
926
+ </div>
927
+ );
928
+ }
929
+
930
+ function inspectorActionStyle(disabled: boolean): React.CSSProperties {
931
+ return {
932
+ display: 'inline-flex',
933
+ alignItems: 'center',
934
+ gap: 6,
935
+ padding: '6px 10px',
936
+ background: 'transparent',
937
+ color: 'inherit',
938
+ border: '1px solid var(--castle-inspector-divider)',
939
+ borderRadius: 6,
940
+ cursor: disabled ? 'default' : 'pointer',
941
+ opacity: disabled ? 0.5 : 1,
942
+ fontSize: 13,
943
+ };
944
+ }
945
+
946
+ function SceneInspector({
947
+ selectedActor,
948
+ files,
949
+ onSetComponent,
950
+ onAddBehavior,
951
+ onRemoveBehavior,
952
+ }: {
953
+ selectedActor: ActorData | undefined;
954
+ files: FileMap;
955
+ onSetComponent: (
956
+ actorId: string,
957
+ behaviorName: string,
958
+ nextProps: Partial<ComponentProps>
959
+ ) => void;
960
+ onAddBehavior: (actorId: string, behaviorName: string) => void;
961
+ onRemoveBehavior: (actorId: string, behaviorName: string) => void;
962
+ }) {
963
+ if (!selectedActor) return null;
964
+ const presentNames = new Set(
965
+ Object.entries(selectedActor.components)
966
+ .filter(([, component]) => !!component)
967
+ .map(([behaviorName]) => behaviorName)
968
+ );
969
+ const availableBehaviors = behaviorClasses.filter(
970
+ (candidate) => !presentNames.has(candidate.behaviorName)
971
+ );
972
+ const behaviorEntries = Object.entries(selectedActor.components).filter(
973
+ (entry): entry is [string, ComponentProps] => !!entry[1]
974
+ );
975
+ return (
976
+ <>
977
+ <AddBehaviorPicker
978
+ available={availableBehaviors}
979
+ onAdd={(behaviorName) => onAddBehavior(selectedActor.id, behaviorName)}
980
+ />
981
+ {behaviorEntries.map(([behaviorName, component], index) => {
982
+ const Behavior = behaviorClasses.find(
983
+ (candidate) => candidate.behaviorName === behaviorName
984
+ );
985
+ const Inspector = Behavior?.Inspector as InspectorComponent | undefined;
986
+ const body = Inspector ? (
987
+ <Inspector
988
+ actor={selectedActor}
989
+ component={component}
990
+ files={files}
991
+ setComponent={(nextProps) => onSetComponent(selectedActor.id, behaviorName, nextProps)}
992
+ />
993
+ ) : (
994
+ <AutoInspector
995
+ behaviorName={behaviorName}
996
+ defaultProps={Behavior?.defaultProps ?? component}
997
+ component={component}
998
+ setComponent={(nextProps) => onSetComponent(selectedActor.id, behaviorName, nextProps)}
999
+ />
1000
+ );
1001
+ // Layout is core to every actor -- it cannot be removed.
1002
+ const removable = behaviorName !== 'Layout';
1003
+ const isLast = index === behaviorEntries.length - 1;
1004
+ // Trash renders before the panel so the panel stays the wrapper's
1005
+ // last child -- the panel's own `:last-child` border zeroes out, and
1006
+ // this wrapper owns the divider between panels deterministically.
1007
+ return (
1008
+ <div
1009
+ key={behaviorName}
1010
+ style={{
1011
+ position: 'relative',
1012
+ borderBottom: isLast ? undefined : '1px solid var(--castle-inspector-divider)',
1013
+ }}>
1014
+ {removable ? (
1015
+ <button
1016
+ type="button"
1017
+ aria-label={`Remove ${behaviorName}`}
1018
+ title={`Remove ${behaviorName}`}
1019
+ onClick={() => onRemoveBehavior(selectedActor.id, behaviorName)}
1020
+ style={{
1021
+ position: 'absolute',
1022
+ top: 14,
1023
+ right: 16,
1024
+ border: 'none',
1025
+ background: 'transparent',
1026
+ padding: 0,
1027
+ margin: 0,
1028
+ cursor: 'pointer',
1029
+ color: 'inherit',
1030
+ fontSize: 16,
1031
+ lineHeight: 1,
1032
+ }}>
1033
+ <Icon name="trash" />
1034
+ </button>
1035
+ ) : null}
1036
+ {body}
1037
+ </div>
1038
+ );
1039
+ })}
1040
+ </>
1041
+ );
1042
+ }
1043
+
1044
+ function AddBehaviorPicker({
1045
+ available,
1046
+ onAdd,
1047
+ }: {
1048
+ available: BehaviorClass[];
1049
+ onAdd: (behaviorName: string) => void;
1050
+ }) {
1051
+ if (!available.length) return null;
1052
+ return (
1053
+ <div
1054
+ style={{
1055
+ padding: '12px 16px',
1056
+ borderBottom: '1px solid var(--castle-inspector-divider)',
1057
+ }}>
1058
+ <div style={{ position: 'relative' }}>
1059
+ <select
1060
+ className={styles.select}
1061
+ value=""
1062
+ onChange={(event) => {
1063
+ if (event.target.value) onAdd(event.target.value);
1064
+ }}
1065
+ style={{
1066
+ appearance: 'none',
1067
+ WebkitAppearance: 'none',
1068
+ MozAppearance: 'none',
1069
+ paddingRight: 30,
1070
+ }}>
1071
+ <option value="">+ Add behavior</option>
1072
+ {available.map((behavior) => (
1073
+ <option key={behavior.behaviorName} value={behavior.behaviorName}>
1074
+ {behavior.behaviorName}
1075
+ </option>
1076
+ ))}
1077
+ </select>
1078
+ <span
1079
+ style={{
1080
+ position: 'absolute',
1081
+ right: 11,
1082
+ top: '50%',
1083
+ transform: 'translateY(-50%)',
1084
+ pointerEvents: 'none',
1085
+ fontSize: 12,
1086
+ opacity: 0.6,
1087
+ }}>
1088
+ <Icon name="chevron-down" />
1089
+ </span>
1090
+ </div>
1091
+ </div>
1092
+ );
1093
+ }