castle-web-cli 0.4.75 → 0.4.76

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