castle-web-cli 0.4.10 → 0.4.12

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 (50) hide show
  1. package/dist/agent-prompts.d.ts +31 -0
  2. package/dist/agent-prompts.js +100 -0
  3. package/dist/agent.d.ts +17 -0
  4. package/dist/agent.js +894 -0
  5. package/dist/chat-client.d.ts +1 -0
  6. package/dist/chat-client.js +398 -0
  7. package/dist/commonInstructions.d.ts +1 -0
  8. package/dist/commonInstructions.js +8 -0
  9. package/dist/ide-client.js +46 -14
  10. package/dist/ide.d.ts +2 -0
  11. package/dist/ide.js +321 -36
  12. package/dist/init.js +12 -2
  13. package/dist/serve.js +62 -3
  14. package/kits/basic-2d/CLAUDE.md +3 -1
  15. package/kits/basic-2d/package.json +0 -1
  16. package/kits/basic-3d/.prettierrc +8 -0
  17. package/kits/basic-3d/CLAUDE.md +162 -0
  18. package/kits/basic-3d/behaviors/Camera.jsx +56 -0
  19. package/kits/basic-3d/behaviors/Collider.jsx +78 -0
  20. package/kits/basic-3d/behaviors/Mesh.jsx +82 -0
  21. package/kits/basic-3d/behaviors/Model.jsx +61 -0
  22. package/kits/basic-3d/behaviors/Transform.jsx +35 -0
  23. package/kits/basic-3d/editors/App.jsx +147 -0
  24. package/kits/basic-3d/editors/CodeEditor.jsx +112 -0
  25. package/kits/basic-3d/editors/FileBrowser.jsx +143 -0
  26. package/kits/basic-3d/editors/ModelEditor.jsx +400 -0
  27. package/kits/basic-3d/editors/PlayOnly.jsx +14 -0
  28. package/kits/basic-3d/editors/SceneEditor.jsx +1087 -0
  29. package/kits/basic-3d/editors/behaviorRegistry.js +24 -0
  30. package/kits/basic-3d/editors/editorHistory.js +52 -0
  31. package/kits/basic-3d/editors/viewportRig.js +90 -0
  32. package/kits/basic-3d/engine/ScenePlayer.jsx +55 -0
  33. package/kits/basic-3d/engine/SceneUI.jsx +67 -0
  34. package/kits/basic-3d/engine/SceneViewport.jsx +102 -0
  35. package/kits/basic-3d/engine/TouchControls.jsx +136 -0
  36. package/kits/basic-3d/engine/autoInspector.jsx +51 -0
  37. package/kits/basic-3d/engine/files.js +73 -0
  38. package/kits/basic-3d/engine/scene.js +502 -0
  39. package/kits/basic-3d/engine/threeUtil.js +260 -0
  40. package/kits/basic-3d/engine/ui.jsx +352 -0
  41. package/kits/basic-3d/engine/ui.module.css +944 -0
  42. package/kits/basic-3d/eslint.config.js +51 -0
  43. package/kits/basic-3d/index.html +11 -0
  44. package/kits/basic-3d/main.jsx +10 -0
  45. package/kits/basic-3d/models/block.model +14 -0
  46. package/kits/basic-3d/package-lock.json +2713 -0
  47. package/kits/basic-3d/package.json +41 -0
  48. package/kits/basic-3d/scenes/main.scene +76 -0
  49. package/kits/basic-3d/vite.config.js +1 -0
  50. package/package.json +6 -1
@@ -0,0 +1,1087 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import * as THREE from 'three';
3
+ import { basename, formatJson, parseJsonFile } from '../engine/files';
4
+ import { TouchControls } from '../engine/TouchControls';
5
+ import {
6
+ addActor,
7
+ defaultCameraSpec,
8
+ duplicateActors,
9
+ makeScene,
10
+ removeActorComponent,
11
+ removeActors,
12
+ roundUnits,
13
+ sceneCameraSpec,
14
+ setActorComponent,
15
+ } from '../engine/scene';
16
+ import {
17
+ applyCameraSpec,
18
+ defaultLighting,
19
+ fitRendererToCanvas,
20
+ radiansToDegrees,
21
+ } from '../engine/threeUtil';
22
+ import {
23
+ attachSceneKeys,
24
+ isEditableTarget,
25
+ makePlayPointerHandlers,
26
+ ThreeCanvas,
27
+ } from '../engine/SceneViewport';
28
+ import {
29
+ cx,
30
+ CheckboxField,
31
+ ColorField,
32
+ EditorBody,
33
+ EditorHeader,
34
+ Icon,
35
+ IconButton,
36
+ NumberField,
37
+ Panel,
38
+ SheetGrabHandle,
39
+ styles,
40
+ TextField,
41
+ useMobileSheet,
42
+ } from '../engine/ui';
43
+ import { AutoFields, AutoInspector } from '../engine/autoInspector';
44
+ import { SceneUI } from '../engine/SceneUI';
45
+ import { behaviorClasses, findBehaviorClass } from './behaviorRegistry';
46
+ import { useEditHistory } from './editorHistory';
47
+ import { applyGizmoSnap, createViewportRig, GIZMO_MODES } from './viewportRig';
48
+
49
+ const LONG_PRESS_MS = 500;
50
+ const DRAG_THRESHOLD_PX = 5;
51
+ const DEFAULT_GRID_SIZE = 1;
52
+
53
+ export function SceneEditor({
54
+ path,
55
+ text,
56
+ files,
57
+ models,
58
+ onChange,
59
+ onToggleFiles,
60
+ filesOpen,
61
+ selectedActorIds,
62
+ onSelectActorIds,
63
+ multiSelectMode,
64
+ onSetMultiSelectMode,
65
+ }) {
66
+ const [isPlaying, setIsPlaying] = useState(false);
67
+ const [navMode, setNavMode] = useState(false);
68
+ const [gizmoMode, setGizmoMode] = useState('translate');
69
+ const [marquee, setMarquee] = useState(null);
70
+ const history = useEditHistory(text, onChange);
71
+ const showMulti = selectedActorIds.length > 1 || multiSelectMode;
72
+ const inspectorSheet = useSelectionInspectorSheet(true);
73
+ const { value: sceneData, error } = parseJsonFile(path, text);
74
+ const applyScene = (next) => onChange(formatJson(next));
75
+ const viewport = useSceneViewport({
76
+ text,
77
+ sceneData,
78
+ models,
79
+ isPlaying,
80
+ navMode,
81
+ gizmoMode,
82
+ selectedActorIds,
83
+ applyScene,
84
+ recordSnapshot: history.recordSnapshot,
85
+ });
86
+ const getRuntimeKeys = useCallback(
87
+ () => viewport.playRuntimeRef.current?.keys ?? null,
88
+ [viewport.playRuntimeRef]
89
+ );
90
+ const getPlayRuntime = useCallback(
91
+ () => viewport.playRuntimeRef.current,
92
+ [viewport.playRuntimeRef]
93
+ );
94
+ useEffect(
95
+ () => attachSceneKeys(getPlayRuntime, { onSpace: () => setIsPlaying((value) => !value) }),
96
+ [getPlayRuntime]
97
+ );
98
+ const gesture = useSelectionGesture({
99
+ viewport,
100
+ sceneData,
101
+ isPlaying,
102
+ navMode,
103
+ selectedActorIds,
104
+ onSelectActorIds,
105
+ multiSelectMode,
106
+ onSetMultiSelectMode,
107
+ setMarquee,
108
+ applyScene,
109
+ recordSceneSnapshot: history.recordSnapshot,
110
+ });
111
+ const playPointer = useMemo(() => makePlayPointerHandlers(getPlayRuntime), [getPlayRuntime]);
112
+ useSelectionKeyboard({
113
+ sceneData,
114
+ selectedActorIds,
115
+ onSelectActorIds,
116
+ multiSelectMode,
117
+ onSetMultiSelectMode,
118
+ isPlaying,
119
+ commitScene: (next) => history.commit(formatJson(next)),
120
+ });
121
+ if (!sceneData && !viewport.hasRuntime()) {
122
+ return (
123
+ <>
124
+ <EditorHeader title={basename(path)} onToggleFiles={onToggleFiles} filesOpen={filesOpen} />
125
+ <EditorBody>
126
+ <div className={styles.inspector}>{error}</div>
127
+ </EditorBody>
128
+ </>
129
+ );
130
+ }
131
+ const selectedActor =
132
+ selectedActorIds.length === 1
133
+ ? sceneData?.actors.find((actor) => actor.id === selectedActorIds[0])
134
+ : undefined;
135
+ const actions = makeSceneActions({
136
+ sceneData,
137
+ commit: history.commit,
138
+ selectedActorIds,
139
+ onSelectActorIds,
140
+ });
141
+ const inspectorLabel = showMulti ? 'Multi-select' : selectedActor ? 'Inspector' : 'Scene';
142
+ const inspectorHint = showMulti
143
+ ? `${selectedActorIds.length} actor${selectedActorIds.length === 1 ? '' : 's'} selected`
144
+ : selectedActor
145
+ ? `actor ${selectedActor.id}`
146
+ : 'scene settings';
147
+ const playbackButtons = (
148
+ <PlaybackButtons
149
+ isPlaying={isPlaying}
150
+ setIsPlaying={setIsPlaying}
151
+ history={history}
152
+ navMode={navMode}
153
+ setNavMode={setNavMode}
154
+ />
155
+ );
156
+ const gizmoButtons = (
157
+ <GizmoModeButtons gizmoMode={gizmoMode} setGizmoMode={setGizmoMode} isPlaying={isPlaying} />
158
+ );
159
+ const actorButtons = (
160
+ <ActorToolButtons
161
+ sceneData={sceneData}
162
+ files={files}
163
+ models={models}
164
+ selectedActorIds={selectedActorIds}
165
+ onSelectActorIds={onSelectActorIds}
166
+ isPlaying={isPlaying}
167
+ onChange={onChange}
168
+ history={history}
169
+ />
170
+ );
171
+ return (
172
+ <>
173
+ <EditorHeader
174
+ title={sceneData?.name ?? basename(path)}
175
+ subtitle={
176
+ isPlaying ? 'WASD / arrows' : (error ?? `${sceneData?.actors.length ?? 0} actors`)
177
+ }
178
+ right={
179
+ <span className={styles.mobileOnly}>
180
+ {playbackButtons}
181
+ {actorButtons}
182
+ </span>
183
+ }
184
+ onToggleFiles={onToggleFiles}
185
+ filesOpen={filesOpen}
186
+ />
187
+ <EditorBody>
188
+ <div className={styles.sceneWorkspace}>
189
+ <div className={styles.sceneTools}>
190
+ <div className={styles.sceneToolsGroup}>{gizmoButtons}</div>
191
+ <div className={styles.sceneToolsGroup}>{playbackButtons}</div>
192
+ <div className={styles.sceneToolsGroup}>{actorButtons}</div>
193
+ </div>
194
+ <div className={cx(styles.stageWrap, !isPlaying && styles.stageWrapFull)}>
195
+ <div className={styles.stageCard}>
196
+ <ThreeCanvas
197
+ onSetup={viewport.onSetup}
198
+ onFrame={viewport.onFrame}
199
+ className={styles.stageCanvas}
200
+ onContextMenu={(event) => event.preventDefault()}
201
+ onPointerDown={isPlaying ? playPointer.onPointerDown : gesture.onPointerDown}
202
+ onPointerMove={isPlaying ? playPointer.onPointerMove : gesture.onPointerMove}
203
+ onPointerUp={isPlaying ? playPointer.onPointerUp : gesture.onPointerUp}
204
+ onPointerCancel={isPlaying ? playPointer.onPointerUp : gesture.onPointerUp}
205
+ />
206
+ {marquee && !isPlaying ? <MarqueeOverlay rect={marquee} /> : null}
207
+ {isPlaying && <SceneUI getRuntime={getPlayRuntime} />}
208
+ </div>
209
+ </div>
210
+ <aside {...inspectorSheet.rootProps}>
211
+ <div {...inspectorSheet.grabProps}>
212
+ <SheetGrabHandle label={inspectorLabel} hint={inspectorHint} />
213
+ </div>
214
+ <div className={cx(styles.sheetBody, styles.inspectorBody)}>
215
+ {showMulti ? (
216
+ <MultiSelectInspector
217
+ count={selectedActorIds.length}
218
+ onDelete={actions.deleteSelection}
219
+ onDuplicate={actions.duplicateSelection}
220
+ onDeselectAll={() => {
221
+ onSelectActorIds([]);
222
+ onSetMultiSelectMode(false);
223
+ }}
224
+ />
225
+ ) : selectedActor ? (
226
+ <ActorInspector
227
+ selectedActor={selectedActor}
228
+ files={files}
229
+ onSetComponent={actions.updateComponent}
230
+ onAddBehavior={actions.addBehavior}
231
+ onRemoveBehavior={actions.removeBehavior}
232
+ />
233
+ ) : (
234
+ <SceneInspector
235
+ sceneData={sceneData}
236
+ commit={(next) => history.commit(formatJson(next))}
237
+ />
238
+ )}
239
+ </div>
240
+ </aside>
241
+ </div>
242
+ </EditorBody>
243
+ <TouchControls getKeys={getRuntimeKeys} visible={isPlaying} />
244
+ </>
245
+ );
246
+ }
247
+
248
+ function MarqueeOverlay({ rect }) {
249
+ return (
250
+ <div
251
+ style={{
252
+ position: 'absolute',
253
+ left: rect.x,
254
+ top: rect.y,
255
+ width: rect.width,
256
+ height: rect.height,
257
+ border: '1px dashed rgba(255, 255, 255, 0.85)',
258
+ background: 'rgba(255, 255, 255, 0.12)',
259
+ pointerEvents: 'none',
260
+ }}
261
+ />
262
+ );
263
+ }
264
+
265
+ function PlaybackButtons({ isPlaying, setIsPlaying, history, navMode, setNavMode }) {
266
+ return (
267
+ <>
268
+ <IconButton
269
+ icon={isPlaying ? 'stop' : 'play'}
270
+ label={isPlaying ? 'Stop' : 'Play'}
271
+ active={isPlaying}
272
+ variant={isPlaying ? 'primary' : ''}
273
+ onClick={() => setIsPlaying(!isPlaying)}
274
+ />
275
+ <IconButton
276
+ icon="undo"
277
+ label="Undo"
278
+ onClick={history.undo}
279
+ disabled={!history.canUndo || isPlaying}
280
+ />
281
+ <IconButton
282
+ icon="redo"
283
+ label="Redo"
284
+ onClick={history.redo}
285
+ disabled={!history.canRedo || isPlaying}
286
+ />
287
+ <span style={{ width: 16 }} aria-hidden="true" />
288
+ <IconButton
289
+ icon="camera"
290
+ label="Navigate camera (drag orbits)"
291
+ active={navMode}
292
+ onClick={() => setNavMode((value) => !value)}
293
+ disabled={isPlaying}
294
+ />
295
+ </>
296
+ );
297
+ }
298
+
299
+ function GizmoModeButtons({ gizmoMode, setGizmoMode, isPlaying }) {
300
+ return (
301
+ <>
302
+ {GIZMO_MODES.map((mode) => (
303
+ <IconButton
304
+ key={mode.key}
305
+ icon={mode.icon}
306
+ label={mode.label}
307
+ active={gizmoMode === mode.key}
308
+ onClick={() => setGizmoMode(mode.key)}
309
+ disabled={isPlaying}
310
+ />
311
+ ))}
312
+ </>
313
+ );
314
+ }
315
+
316
+ function ActorToolButtons({
317
+ sceneData,
318
+ files,
319
+ models,
320
+ selectedActorIds,
321
+ onSelectActorIds,
322
+ isPlaying,
323
+ onChange,
324
+ history,
325
+ }) {
326
+ const applyScene = (next) => onChange(formatJson(next));
327
+ const onAdd = () => {
328
+ const { sceneData: next, newId } = addActor(sceneData, files, models);
329
+ history.recordSnapshot();
330
+ applyScene(next);
331
+ onSelectActorIds([newId]);
332
+ };
333
+ const onDuplicate = () => {
334
+ if (selectedActorIds.length === 0) return;
335
+ const { sceneData: next, newIds } = duplicateActors(sceneData, selectedActorIds);
336
+ history.recordSnapshot();
337
+ applyScene(next);
338
+ if (newIds.length) onSelectActorIds(newIds);
339
+ };
340
+ const onRemove = () => {
341
+ if (selectedActorIds.length === 0) return;
342
+ history.recordSnapshot();
343
+ applyScene(removeActors(sceneData, selectedActorIds));
344
+ onSelectActorIds([]);
345
+ };
346
+ const hasSelection = selectedActorIds.length > 0;
347
+ return (
348
+ <>
349
+ <IconButton icon="plus" label="Add actor" onClick={onAdd} disabled={isPlaying} />
350
+ <IconButton
351
+ icon="clone"
352
+ label="Duplicate"
353
+ onClick={onDuplicate}
354
+ disabled={!hasSelection || isPlaying}
355
+ />
356
+ <IconButton
357
+ icon="trash"
358
+ label="Remove"
359
+ onClick={onRemove}
360
+ disabled={!hasSelection || isPlaying}
361
+ />
362
+ </>
363
+ );
364
+ }
365
+
366
+ function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorIds }) {
367
+ function commitScene(next) {
368
+ commit(formatJson(next));
369
+ }
370
+ return {
371
+ updateComponent: (actorId, behaviorName, nextProps) =>
372
+ commitScene(setActorComponent(sceneData, actorId, behaviorName, nextProps)),
373
+ addBehavior: (actorId, behaviorName) => {
374
+ const Behavior = findBehaviorClass(behaviorName);
375
+ if (!Behavior) return;
376
+ commitScene(
377
+ setActorComponent(sceneData, actorId, behaviorName, { ...Behavior.defaultProps })
378
+ );
379
+ },
380
+ removeBehavior: (actorId, behaviorName) =>
381
+ commitScene(removeActorComponent(sceneData, actorId, behaviorName)),
382
+ deleteSelection: () => {
383
+ if (selectedActorIds.length === 0) return;
384
+ commitScene(removeActors(sceneData, selectedActorIds));
385
+ onSelectActorIds([]);
386
+ },
387
+ duplicateSelection: () => {
388
+ if (selectedActorIds.length === 0) return;
389
+ const { sceneData: next, newIds } = duplicateActors(sceneData, selectedActorIds);
390
+ commitScene(next);
391
+ if (newIds.length) onSelectActorIds(newIds);
392
+ },
393
+ };
394
+ }
395
+
396
+ // The viewport: edit runtime lifecycle, play runtime lifecycle, the camera
397
+ // rig, the gizmo write-back, and the per-frame render.
398
+ function useSceneViewport(args) {
399
+ const argsRef = useRef(args);
400
+ argsRef.current = args;
401
+ const editRuntimeRef = useRef(null);
402
+ const playRuntimeRef = useRef(null);
403
+ const playCameraRef = useRef(null);
404
+ const rigRef = useRef(null);
405
+ const canvasRef = useRef(null);
406
+ const gizmoDragRef = useRef({ recorded: false });
407
+ if (!editRuntimeRef.current) {
408
+ editRuntimeRef.current = makeScene(
409
+ args.sceneData ?? { actors: [] },
410
+ behaviorClasses,
411
+ args.models
412
+ );
413
+ }
414
+ useEffect(() => {
415
+ return () => {
416
+ editRuntimeRef.current?.dispose();
417
+ editRuntimeRef.current = null;
418
+ };
419
+ }, []);
420
+ // Reload the edit runtime when the scene text changes (reconciling load --
421
+ // groups and caches survive). Invalid JSON keeps the last good state.
422
+ useEffect(() => {
423
+ const current = argsRef.current;
424
+ if (current.sceneData) editRuntimeRef.current?.load(current.sceneData);
425
+ // eslint-disable-next-line react-hooks/exhaustive-deps
426
+ }, [args.text]);
427
+ useEffect(() => {
428
+ if (editRuntimeRef.current) editRuntimeRef.current.models = args.models;
429
+ }, [args.models]);
430
+ // Play runtime spins up on play and resets when the scene text changes
431
+ // mid-play, mirroring the 2d kit.
432
+ useEffect(() => {
433
+ if (!args.isPlaying) return undefined;
434
+ const current = argsRef.current;
435
+ if (!current.sceneData) return undefined;
436
+ const runtime = makeScene(current.sceneData, behaviorClasses, current.models);
437
+ playRuntimeRef.current = runtime;
438
+ return () => {
439
+ runtime.dispose();
440
+ playRuntimeRef.current = null;
441
+ };
442
+ // eslint-disable-next-line react-hooks/exhaustive-deps
443
+ }, [args.isPlaying, args.text]);
444
+ useEffect(() => {
445
+ rigRef.current?.setNavMode(args.navMode);
446
+ }, [args.navMode]);
447
+ useEffect(() => {
448
+ const rig = rigRef.current;
449
+ const runtime = editRuntimeRef.current;
450
+ if (!rig || !runtime) return;
451
+ const primaryId = args.selectedActorIds[0];
452
+ const actor = primaryId ? runtime.getActor(primaryId) : null;
453
+ if (!args.isPlaying && actor?.components.Transform) {
454
+ rig.gizmo.attach(runtime.actorGroup(primaryId));
455
+ rig.gizmo.setMode(args.gizmoMode);
456
+ } else {
457
+ rig.gizmo.detach();
458
+ }
459
+ }, [args.selectedActorIds, args.isPlaying, args.gizmoMode, args.text]);
460
+ useEffect(() => {
461
+ if (rigRef.current) applyGizmoSnap(rigRef.current.gizmo, getSnapSettings(args.sceneData));
462
+ // eslint-disable-next-line react-hooks/exhaustive-deps
463
+ }, [args.text]);
464
+ const onSetup = ({ canvas }) => {
465
+ canvasRef.current = canvas;
466
+ playCameraRef.current = new THREE.PerspectiveCamera(50, 1, 0.1, 2000);
467
+ const rig = createViewportRig({
468
+ canvas,
469
+ scene: editRuntimeRef.current.three,
470
+ onGizmoChange: (gizmo) => writeGizmoTransform(gizmo, argsRef, gizmoDragRef),
471
+ onGizmoDraggingChanged: (dragging) => {
472
+ if (dragging) gizmoDragRef.current = { recorded: false };
473
+ },
474
+ });
475
+ rig.setNavMode(argsRef.current.navMode);
476
+ applyGizmoSnap(rig.gizmo, getSnapSettings(argsRef.current.sceneData));
477
+ // Open the editor through the scene's own play camera (when it has one)
478
+ // so editing and playing share the same framing.
479
+ const playSpec = sceneCameraSpec(argsRef.current.sceneData);
480
+ if (playSpec) {
481
+ applyCameraSpec(rig.camera, playSpec);
482
+ rig.orbit.target.set(playSpec.targetX, playSpec.targetY, playSpec.targetZ);
483
+ rig.orbit.update();
484
+ }
485
+ rigRef.current = rig;
486
+ return () => {
487
+ rig.dispose();
488
+ rigRef.current = null;
489
+ };
490
+ };
491
+ const onFrame = ({ canvas, renderer, dt }) => {
492
+ const current = argsRef.current;
493
+ if (current.isPlaying && playRuntimeRef.current) {
494
+ const runtime = playRuntimeRef.current;
495
+ runtime.update(dt);
496
+ runtime.syncFrame(dt, {});
497
+ const camera = playCameraRef.current;
498
+ if (!fitRendererToCanvas(renderer, camera, canvas)) return;
499
+ applyCameraSpec(camera, runtime.camera ?? defaultCameraSpec);
500
+ runtime.activeCamera = camera;
501
+ renderer.render(runtime.three, camera);
502
+ } else if (editRuntimeRef.current && rigRef.current) {
503
+ const runtime = editRuntimeRef.current;
504
+ const rig = rigRef.current;
505
+ runtime.syncFrame(dt, {
506
+ selectedActorIds: current.selectedActorIds,
507
+ editPlaceholders: true,
508
+ showGrid: true,
509
+ });
510
+ if (!fitRendererToCanvas(renderer, rig.camera, canvas)) return;
511
+ rig.orbit.update();
512
+ runtime.activeCamera = rig.camera;
513
+ renderer.render(runtime.three, rig.camera);
514
+ }
515
+ };
516
+ return {
517
+ onSetup,
518
+ onFrame,
519
+ editRuntimeRef,
520
+ playRuntimeRef,
521
+ rigRef,
522
+ canvasRef,
523
+ hasRuntime: () => !!editRuntimeRef.current,
524
+ };
525
+ }
526
+
527
+ // Gizmo drag write-back: read the dragged group's transform into the actor's
528
+ // Transform component (degrees, 2-decimal world units).
529
+ function writeGizmoTransform(gizmo, argsRef, gizmoDragRef) {
530
+ const current = argsRef.current;
531
+ const group = gizmo.object;
532
+ const actorId = group?.userData.actorId;
533
+ if (!group || actorId == null || !current.sceneData) return;
534
+ if (!gizmoDragRef.current.recorded) {
535
+ current.recordSnapshot();
536
+ gizmoDragRef.current.recorded = true;
537
+ }
538
+ current.applyScene(
539
+ setActorComponent(current.sceneData, actorId, 'Transform', {
540
+ x: roundUnits(group.position.x),
541
+ y: roundUnits(group.position.y),
542
+ z: roundUnits(group.position.z),
543
+ rotationX: roundUnits(radiansToDegrees(group.rotation.x)),
544
+ rotationY: roundUnits(radiansToDegrees(group.rotation.y)),
545
+ rotationZ: roundUnits(radiansToDegrees(group.rotation.z)),
546
+ scaleX: roundUnits(group.scale.x),
547
+ scaleY: roundUnits(group.scale.y),
548
+ scaleZ: roundUnits(group.scale.z),
549
+ })
550
+ );
551
+ }
552
+
553
+ function getSnapSettings(sceneData) {
554
+ const rawSize = sceneData?.editor?.gridSize;
555
+ return {
556
+ enabled: sceneData?.editor?.snapToGrid ?? true,
557
+ gridSize: Number.isFinite(rawSize) && rawSize > 0 ? rawSize : DEFAULT_GRID_SIZE,
558
+ };
559
+ }
560
+
561
+ function snapDelta(delta, gridSize, enabled) {
562
+ if (!enabled) return delta;
563
+ return Math.round(delta / gridSize) * gridSize;
564
+ }
565
+
566
+ function collectMoveStarts(sceneData, actorIds) {
567
+ const starts = {};
568
+ const movingIds = new Set(actorIds);
569
+ for (const actor of sceneData.actors) {
570
+ if (!movingIds.has(actor.id)) continue;
571
+ const transform = actor.components.Transform;
572
+ if (!transform) continue;
573
+ starts[actor.id] = { x: transform.x ?? 0, z: transform.z ?? 0 };
574
+ }
575
+ return starts;
576
+ }
577
+
578
+ function moveActorsFromStarts(sceneData, actorIds, starts, dx, dz) {
579
+ if (actorIds.length === 0) return sceneData;
580
+ const movingIds = new Set(actorIds);
581
+ const next = structuredClone(sceneData);
582
+ let changed = false;
583
+ for (const actor of next.actors) {
584
+ if (!movingIds.has(actor.id)) continue;
585
+ const start = starts[actor.id];
586
+ const transform = actor.components.Transform;
587
+ if (!start || !transform) continue;
588
+ const nextX = roundUnits(start.x + dx);
589
+ const nextZ = roundUnits(start.z + dz);
590
+ if (transform.x === nextX && transform.z === nextZ) continue;
591
+ actor.components.Transform = { ...transform, x: nextX, z: nextZ };
592
+ changed = true;
593
+ }
594
+ return changed ? next : sceneData;
595
+ }
596
+
597
+ // Raycast a screen point onto a horizontal plane at `planeY`, in world units.
598
+ function worldOnPlane(runtime, canvas, clientX, clientY, planeY) {
599
+ const camera = runtime.activeCamera;
600
+ if (!camera) return null;
601
+ const rect = canvas.getBoundingClientRect();
602
+ const ndc = {
603
+ x: ((clientX - rect.left) / rect.width) * 2 - 1,
604
+ y: -(((clientY - rect.top) / rect.height) * 2 - 1),
605
+ };
606
+ const raycaster = new THREE.Raycaster();
607
+ raycaster.setFromCamera(ndc, camera);
608
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -planeY);
609
+ const hit = new THREE.Vector3();
610
+ if (!raycaster.ray.intersectPlane(plane, hit)) return null;
611
+ return { x: hit.x, z: hit.z };
612
+ }
613
+
614
+ function screenPoint(canvas, clientX, clientY) {
615
+ const rect = canvas.getBoundingClientRect();
616
+ return { x: clientX - rect.left, y: clientY - rect.top };
617
+ }
618
+
619
+ // Edit-mode pointer gestures, the same model as the 2d kit: tap select,
620
+ // drag moves on the ground plane, shift toggles, long-press enters
621
+ // multi-select, drag on empty space (shift or multi mode) marquees.
622
+ function useSelectionGesture(args) {
623
+ const dragRef = useRef(null);
624
+ const argsRef = useRef(args);
625
+ argsRef.current = args;
626
+ const onPointerDown = useCallback((event) => {
627
+ const current = argsRef.current;
628
+ const runtime = current.viewport.editRuntimeRef.current;
629
+ const rig = current.viewport.rigRef.current;
630
+ if (current.isPlaying || current.navMode || !runtime || !current.sceneData) return;
631
+ if (event.button !== 0 || rig?.gizmoBusy()) return;
632
+ const canvas = event.currentTarget;
633
+ const screen = screenPoint(canvas, event.clientX, event.clientY);
634
+ const actor = runtime.actorAtScreen(canvas, event.clientX, event.clientY);
635
+ const planeY = actor?.components.Transform?.y ?? 0;
636
+ try {
637
+ canvas.setPointerCapture(event.pointerId);
638
+ } catch {
639
+ // Synthetic / already-released pointers can't be captured.
640
+ }
641
+ const drag = {
642
+ pointerId: event.pointerId,
643
+ canvas,
644
+ startScreen: screen,
645
+ planeY,
646
+ startWorld: worldOnPlane(runtime, canvas, event.clientX, event.clientY, planeY),
647
+ startedOnActorId: actor?.id ?? null,
648
+ modeAtStart: current.multiSelectMode,
649
+ movedFar: false,
650
+ longPressTimer: null,
651
+ longPressFired: false,
652
+ kind: 'idle',
653
+ pendingMarquee: false,
654
+ movingActorIds: [],
655
+ moveStarts: {},
656
+ recordedMoveSnapshot: false,
657
+ selectionBeforeMarquee: [...current.selectedActorIds],
658
+ };
659
+ if (event.shiftKey) {
660
+ handleShiftPointerDown(drag, actor, current);
661
+ } else if (current.multiSelectMode) {
662
+ handleModePointerDown(drag, actor, current);
663
+ } else {
664
+ handleDefaultPointerDown(drag, actor, screen, current);
665
+ }
666
+ drag.moveStarts = collectMoveStarts(current.sceneData, drag.movingActorIds);
667
+ dragRef.current = drag;
668
+ }, []);
669
+ const onPointerMove = useCallback((event) => {
670
+ const drag = dragRef.current;
671
+ const current = argsRef.current;
672
+ if (!drag || drag.pointerId !== event.pointerId) return;
673
+ const runtime = current.viewport.editRuntimeRef.current;
674
+ if (!runtime || !current.sceneData) return;
675
+ const screen = screenPoint(drag.canvas, event.clientX, event.clientY);
676
+ const screenDistance = Math.hypot(
677
+ screen.x - drag.startScreen.x,
678
+ screen.y - drag.startScreen.y
679
+ );
680
+ if (!drag.movedFar && screenDistance > DRAG_THRESHOLD_PX) {
681
+ drag.movedFar = true;
682
+ if (drag.longPressTimer !== null) {
683
+ window.clearTimeout(drag.longPressTimer);
684
+ drag.longPressTimer = null;
685
+ }
686
+ if (drag.pendingMarquee && drag.kind === 'idle') drag.kind = 'marquee';
687
+ }
688
+ if (drag.kind === 'move' && drag.movedFar && drag.startWorld) {
689
+ const world = worldOnPlane(runtime, drag.canvas, event.clientX, event.clientY, drag.planeY);
690
+ if (!world) return;
691
+ const snap = getSnapSettings(current.sceneData);
692
+ const dx = snapDelta(world.x - drag.startWorld.x, snap.gridSize, snap.enabled);
693
+ const dz = snapDelta(world.z - drag.startWorld.z, snap.gridSize, snap.enabled);
694
+ const nextScene = moveActorsFromStarts(
695
+ current.sceneData,
696
+ drag.movingActorIds,
697
+ drag.moveStarts,
698
+ dx,
699
+ dz
700
+ );
701
+ if (nextScene === current.sceneData) return;
702
+ if (!drag.recordedMoveSnapshot) {
703
+ current.recordSceneSnapshot();
704
+ drag.recordedMoveSnapshot = true;
705
+ }
706
+ current.applyScene(nextScene);
707
+ } else if (drag.kind === 'marquee') {
708
+ current.setMarquee({
709
+ x: Math.min(drag.startScreen.x, screen.x),
710
+ y: Math.min(drag.startScreen.y, screen.y),
711
+ width: Math.abs(screen.x - drag.startScreen.x),
712
+ height: Math.abs(screen.y - drag.startScreen.y),
713
+ });
714
+ }
715
+ }, []);
716
+ const onPointerUp = useCallback((event) => {
717
+ const drag = dragRef.current;
718
+ const current = argsRef.current;
719
+ if (!drag || drag.pointerId !== event.pointerId) return;
720
+ if (drag.longPressTimer !== null) {
721
+ window.clearTimeout(drag.longPressTimer);
722
+ drag.longPressTimer = null;
723
+ }
724
+ if (drag.kind === 'marquee') {
725
+ finalizeMarquee(drag, current, event);
726
+ } else if (drag.kind === 'idle' && !drag.movedFar && !drag.longPressFired) {
727
+ handleTap(drag, current);
728
+ }
729
+ current.setMarquee(null);
730
+ dragRef.current = null;
731
+ }, []);
732
+ return { onPointerDown, onPointerMove, onPointerUp };
733
+ }
734
+
735
+ function handleShiftPointerDown(drag, actor, current) {
736
+ if (actor) {
737
+ const wasSelected = current.selectedActorIds.includes(actor.id);
738
+ const nextIds = wasSelected
739
+ ? current.selectedActorIds.filter((id) => id !== actor.id)
740
+ : [...current.selectedActorIds, actor.id];
741
+ current.onSelectActorIds(nextIds);
742
+ if (!wasSelected) {
743
+ drag.kind = 'move';
744
+ drag.movingActorIds = nextIds;
745
+ }
746
+ } else {
747
+ drag.pendingMarquee = true;
748
+ }
749
+ }
750
+
751
+ function handleModePointerDown(drag, actor, current) {
752
+ if (actor) {
753
+ if (current.selectedActorIds.includes(actor.id)) {
754
+ drag.kind = 'move';
755
+ drag.movingActorIds = [...current.selectedActorIds];
756
+ }
757
+ // else: leave kind='idle' so pointerup triggers the tap-toggle path.
758
+ } else {
759
+ // Defer marquee until movement, so a stationary press/release reaches
760
+ // the tap handler (which exits multi mode on empty tap).
761
+ drag.pendingMarquee = true;
762
+ }
763
+ }
764
+
765
+ function handleDefaultPointerDown(drag, actor, screen, current) {
766
+ if (actor) {
767
+ if (!current.selectedActorIds.includes(actor.id)) {
768
+ current.onSelectActorIds([actor.id]);
769
+ drag.movingActorIds = [actor.id];
770
+ } else {
771
+ drag.movingActorIds = [...current.selectedActorIds];
772
+ }
773
+ drag.kind = 'move';
774
+ drag.longPressTimer = window.setTimeout(() => {
775
+ if (drag.movedFar) return;
776
+ drag.longPressFired = true;
777
+ current.onSetMultiSelectMode(true);
778
+ }, LONG_PRESS_MS);
779
+ } else {
780
+ current.onSelectActorIds([]);
781
+ drag.longPressTimer = window.setTimeout(() => {
782
+ if (drag.movedFar) return;
783
+ drag.longPressFired = true;
784
+ current.onSetMultiSelectMode(true);
785
+ drag.kind = 'marquee';
786
+ current.setMarquee({ x: screen.x, y: screen.y, width: 0, height: 0 });
787
+ }, LONG_PRESS_MS);
788
+ }
789
+ }
790
+
791
+ function finalizeMarquee(drag, current, event) {
792
+ const runtime = current.viewport.editRuntimeRef.current;
793
+ if (!runtime || !current.sceneData) return;
794
+ const screen = screenPoint(drag.canvas, event.clientX, event.clientY);
795
+ const rect = {
796
+ x: Math.min(drag.startScreen.x, screen.x),
797
+ y: Math.min(drag.startScreen.y, screen.y),
798
+ width: Math.abs(screen.x - drag.startScreen.x),
799
+ height: Math.abs(screen.y - drag.startScreen.y),
800
+ };
801
+ if (rect.width === 0 && rect.height === 0) return;
802
+ const hits = runtime.actorIdsInScreenRect(drag.canvas, rect);
803
+ const merged = new Set(drag.selectionBeforeMarquee);
804
+ for (const id of hits) merged.add(id);
805
+ current.onSelectActorIds([...merged]);
806
+ }
807
+
808
+ function handleTap(drag, current) {
809
+ if (!drag.modeAtStart) return;
810
+ if (drag.startedOnActorId !== null) {
811
+ const id = drag.startedOnActorId;
812
+ const wasSelected = current.selectedActorIds.includes(id);
813
+ current.onSelectActorIds(
814
+ wasSelected
815
+ ? current.selectedActorIds.filter((x) => x !== id)
816
+ : [...current.selectedActorIds, id]
817
+ );
818
+ } else {
819
+ current.onSetMultiSelectMode(false);
820
+ current.onSelectActorIds([]);
821
+ }
822
+ }
823
+
824
+ function useSelectionKeyboard(args) {
825
+ const ref = useRef(args);
826
+ ref.current = args;
827
+ useEffect(() => {
828
+ function onKeyDown(event) {
829
+ const current = ref.current;
830
+ if (current.isPlaying || isEditableTarget(event.target)) return;
831
+ if (event.key === 'Escape') {
832
+ if (current.selectedActorIds.length || current.multiSelectMode) {
833
+ event.preventDefault();
834
+ current.onSelectActorIds([]);
835
+ current.onSetMultiSelectMode(false);
836
+ }
837
+ return;
838
+ }
839
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
840
+ if (!current.sceneData) return;
841
+ event.preventDefault();
842
+ current.onSelectActorIds(current.sceneData.actors.map((actor) => actor.id));
843
+ return;
844
+ }
845
+ if (event.key === 'Delete' || event.key === 'Backspace') {
846
+ if (!current.sceneData || current.selectedActorIds.length === 0) return;
847
+ event.preventDefault();
848
+ current.commitScene(removeActors(current.sceneData, current.selectedActorIds));
849
+ current.onSelectActorIds([]);
850
+ }
851
+ }
852
+ window.addEventListener('keydown', onKeyDown);
853
+ return () => window.removeEventListener('keydown', onKeyDown);
854
+ }, []);
855
+ }
856
+
857
+ function useSelectionInspectorSheet(open) {
858
+ const [snap, setSnap] = useState('high');
859
+ // Inspector visibility is driven by selection / multi-mode. Snap state
860
+ // persists across selections so switching keeps the user's chosen snap.
861
+ const effectiveSnap = open ? snap : 'hidden';
862
+ return useMobileSheet({
863
+ snap: effectiveSnap,
864
+ baseClassName: styles.inspector,
865
+ onTransition: (direction) => {
866
+ if (!open) return;
867
+ if (direction === 'tap') setSnap((prev) => (prev === 'high' ? 'low' : 'high'));
868
+ else if (direction === 'down') setSnap('low');
869
+ else if (direction === 'up') setSnap('high');
870
+ },
871
+ });
872
+ }
873
+
874
+ function MultiSelectInspector({ count, onDelete, onDuplicate, onDeselectAll }) {
875
+ return (
876
+ <div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
877
+ <div style={{ fontSize: 13, opacity: 0.8 }}>
878
+ {count === 0
879
+ ? 'Multi-select mode -- tap actors to add'
880
+ : `${count} actor${count === 1 ? '' : 's'} selected`}
881
+ </div>
882
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
883
+ <button
884
+ type="button"
885
+ onClick={onDuplicate}
886
+ disabled={count === 0}
887
+ style={inspectorActionStyle(count === 0)}>
888
+ <Icon name="clone" /> Duplicate
889
+ </button>
890
+ <button
891
+ type="button"
892
+ onClick={onDelete}
893
+ disabled={count === 0}
894
+ style={inspectorActionStyle(count === 0)}>
895
+ <Icon name="trash" /> Delete
896
+ </button>
897
+ <button type="button" onClick={onDeselectAll} style={inspectorActionStyle(false)}>
898
+ <Icon name="times" /> Deselect all
899
+ </button>
900
+ </div>
901
+ </div>
902
+ );
903
+ }
904
+
905
+ function inspectorActionStyle(disabled) {
906
+ return {
907
+ display: 'inline-flex',
908
+ alignItems: 'center',
909
+ gap: 6,
910
+ padding: '6px 10px',
911
+ background: 'transparent',
912
+ color: 'inherit',
913
+ border: '1px solid var(--castle-inspector-divider)',
914
+ borderRadius: 6,
915
+ cursor: disabled ? 'default' : 'pointer',
916
+ opacity: disabled ? 0.5 : 1,
917
+ fontSize: 13,
918
+ };
919
+ }
920
+
921
+ function SceneInspector({ sceneData, commit }) {
922
+ const snapSettings = getSnapSettings(sceneData);
923
+ const onChangeScene = (nextScene) => commit({ ...sceneData, ...nextScene });
924
+ const onChangeEditor = (nextEditor) =>
925
+ commit({ ...sceneData, editor: { ...(sceneData.editor ?? {}), ...nextEditor } });
926
+ const onChangeLighting = (nextLighting) =>
927
+ commit({ ...sceneData, lighting: { ...(sceneData.lighting ?? {}), ...nextLighting } });
928
+ return (
929
+ <>
930
+ <Panel title="Scene">
931
+ <TextField
932
+ label="Name"
933
+ value={sceneData.name ?? ''}
934
+ onChange={(name) => onChangeScene({ name })}
935
+ />
936
+ <ColorField
937
+ label="Background"
938
+ value={sceneData.background ?? '#10131c'}
939
+ onChange={(background) => onChangeScene({ background })}
940
+ />
941
+ <CheckboxField
942
+ label="Snap to grid"
943
+ checked={snapSettings.enabled}
944
+ onChange={(snapToGrid) => onChangeEditor({ snapToGrid })}
945
+ />
946
+ <NumberField
947
+ label="Grid size"
948
+ value={snapSettings.gridSize}
949
+ min={0.25}
950
+ step={0.25}
951
+ onChange={(gridSize) => onChangeEditor({ gridSize })}
952
+ />
953
+ </Panel>
954
+ <Panel title="Lighting">
955
+ <AutoFields
956
+ defaultProps={defaultLighting}
957
+ component={sceneData.lighting ?? {}}
958
+ setComponent={onChangeLighting}
959
+ />
960
+ </Panel>
961
+ </>
962
+ );
963
+ }
964
+
965
+ function ActorInspector({ selectedActor, files, onSetComponent, onAddBehavior, onRemoveBehavior }) {
966
+ const presentNames = new Set(
967
+ Object.entries(selectedActor.components)
968
+ .filter(([, component]) => !!component)
969
+ .map(([behaviorName]) => behaviorName)
970
+ );
971
+ const availableBehaviors = behaviorClasses.filter(
972
+ (candidate) => !presentNames.has(candidate.behaviorName)
973
+ );
974
+ const behaviorEntries = Object.entries(selectedActor.components).filter((entry) => !!entry[1]);
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;
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
+ // Transform is core to every actor -- it cannot be removed.
1002
+ const removable = behaviorName !== 'Transform';
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({ available, onAdd }) {
1045
+ if (!available.length) return null;
1046
+ return (
1047
+ <div
1048
+ style={{
1049
+ padding: '12px 16px',
1050
+ borderBottom: '1px solid var(--castle-inspector-divider)',
1051
+ }}>
1052
+ <div style={{ position: 'relative' }}>
1053
+ <select
1054
+ className={styles.select}
1055
+ value=""
1056
+ onChange={(event) => {
1057
+ if (event.target.value) onAdd(event.target.value);
1058
+ }}
1059
+ style={{
1060
+ appearance: 'none',
1061
+ WebkitAppearance: 'none',
1062
+ MozAppearance: 'none',
1063
+ paddingRight: 30,
1064
+ }}>
1065
+ <option value="">+ Add behavior</option>
1066
+ {available.map((behavior) => (
1067
+ <option key={behavior.behaviorName} value={behavior.behaviorName}>
1068
+ {behavior.behaviorName}
1069
+ </option>
1070
+ ))}
1071
+ </select>
1072
+ <span
1073
+ style={{
1074
+ position: 'absolute',
1075
+ right: 11,
1076
+ top: '50%',
1077
+ transform: 'translateY(-50%)',
1078
+ pointerEvents: 'none',
1079
+ fontSize: 12,
1080
+ opacity: 0.6,
1081
+ }}>
1082
+ <Icon name="chevron-down" />
1083
+ </span>
1084
+ </div>
1085
+ </div>
1086
+ );
1087
+ }