castle-web-cli 0.4.1 → 0.4.3

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