castle-web-cli 0.4.11 → 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.
- package/dist/agent-prompts.d.ts +31 -0
- package/dist/agent-prompts.js +100 -0
- package/dist/agent.d.ts +17 -0
- package/dist/agent.js +894 -0
- package/dist/chat-client.d.ts +1 -0
- package/dist/chat-client.js +398 -0
- package/dist/commonInstructions.d.ts +1 -0
- package/dist/commonInstructions.js +8 -0
- package/dist/ide-client.js +46 -14
- package/dist/ide.d.ts +2 -0
- package/dist/ide.js +321 -36
- package/dist/init.js +12 -1
- package/dist/serve.js +18 -3
- package/kits/basic-2d/CLAUDE.md +3 -1
- package/kits/basic-3d/.prettierrc +8 -0
- package/kits/basic-3d/CLAUDE.md +162 -0
- package/kits/basic-3d/behaviors/Camera.jsx +56 -0
- package/kits/basic-3d/behaviors/Collider.jsx +78 -0
- package/kits/basic-3d/behaviors/Mesh.jsx +82 -0
- package/kits/basic-3d/behaviors/Model.jsx +61 -0
- package/kits/basic-3d/behaviors/Transform.jsx +35 -0
- package/kits/basic-3d/editors/App.jsx +147 -0
- package/kits/basic-3d/editors/CodeEditor.jsx +112 -0
- package/kits/basic-3d/editors/FileBrowser.jsx +143 -0
- package/kits/basic-3d/editors/ModelEditor.jsx +400 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +14 -0
- package/kits/basic-3d/editors/SceneEditor.jsx +1087 -0
- package/kits/basic-3d/editors/behaviorRegistry.js +24 -0
- package/kits/basic-3d/editors/editorHistory.js +52 -0
- package/kits/basic-3d/editors/viewportRig.js +90 -0
- package/kits/basic-3d/engine/ScenePlayer.jsx +55 -0
- package/kits/basic-3d/engine/SceneUI.jsx +67 -0
- package/kits/basic-3d/engine/SceneViewport.jsx +102 -0
- package/kits/basic-3d/engine/TouchControls.jsx +136 -0
- package/kits/basic-3d/engine/autoInspector.jsx +51 -0
- package/kits/basic-3d/engine/files.js +73 -0
- package/kits/basic-3d/engine/scene.js +502 -0
- package/kits/basic-3d/engine/threeUtil.js +260 -0
- package/kits/basic-3d/engine/ui.jsx +352 -0
- package/kits/basic-3d/engine/ui.module.css +944 -0
- package/kits/basic-3d/eslint.config.js +51 -0
- package/kits/basic-3d/index.html +11 -0
- package/kits/basic-3d/main.jsx +10 -0
- package/kits/basic-3d/models/block.model +14 -0
- package/kits/basic-3d/package-lock.json +2713 -0
- package/kits/basic-3d/package.json +41 -0
- package/kits/basic-3d/scenes/main.scene +76 -0
- package/kits/basic-3d/vite.config.js +1 -0
- 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
|
+
}
|