react-three-game 0.0.36 → 0.0.38
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/index.d.ts +5 -3
- package/dist/index.js +5 -5
- package/dist/tools/prefabeditor/EditorContext.d.ts +11 -0
- package/dist/tools/prefabeditor/EditorContext.js +9 -0
- package/dist/tools/prefabeditor/EditorTree.d.ts +1 -3
- package/dist/tools/prefabeditor/EditorTree.js +38 -3
- package/dist/tools/prefabeditor/EditorUI.d.ts +1 -5
- package/dist/tools/prefabeditor/EditorUI.js +4 -2
- package/dist/tools/prefabeditor/ExportHelper.d.ts +7 -0
- package/dist/tools/prefabeditor/ExportHelper.js +55 -0
- package/dist/tools/prefabeditor/InstanceProvider.d.ts +1 -0
- package/dist/tools/prefabeditor/InstanceProvider.js +51 -7
- package/dist/tools/prefabeditor/PrefabEditor.d.ts +10 -2
- package/dist/tools/prefabeditor/PrefabEditor.js +60 -53
- package/dist/tools/prefabeditor/PrefabRoot.d.ts +7 -2
- package/dist/tools/prefabeditor/PrefabRoot.js +78 -49
- package/dist/tools/prefabeditor/components/Input.d.ts +2 -1
- package/dist/tools/prefabeditor/components/Input.js +9 -3
- package/dist/tools/prefabeditor/components/PhysicsComponent.js +8 -7
- package/dist/tools/prefabeditor/components/TransformComponent.js +11 -3
- package/dist/tools/prefabeditor/utils.d.ts +7 -1
- package/dist/tools/prefabeditor/utils.js +41 -0
- package/package.json +1 -1
- package/src/index.ts +12 -12
- package/src/tools/prefabeditor/EditorContext.tsx +20 -0
- package/src/tools/prefabeditor/EditorTree.tsx +83 -22
- package/src/tools/prefabeditor/EditorUI.tsx +2 -10
- package/src/tools/prefabeditor/InstanceProvider.tsx +60 -4
- package/src/tools/prefabeditor/PrefabEditor.tsx +80 -51
- package/src/tools/prefabeditor/PrefabRoot.tsx +181 -69
- package/src/tools/prefabeditor/components/Input.tsx +11 -3
- package/src/tools/prefabeditor/components/PhysicsComponent.tsx +20 -7
- package/src/tools/prefabeditor/components/TransformComponent.tsx +25 -4
- package/src/tools/prefabeditor/utils.ts +43 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { MapControls, TransformControls, useHelper } from "@react-three/drei";
|
|
4
|
-
import { forwardRef, useCallback, useEffect, useRef, useState
|
|
4
|
+
import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
5
5
|
import { BoxHelper, Euler, Group, Matrix4, Object3D, Quaternion, SRGBColorSpace, Texture, TextureLoader, Vector3, } from "three";
|
|
6
6
|
import { ThreeEvent } from "@react-three/fiber";
|
|
7
7
|
|
|
@@ -9,63 +9,64 @@ import { Prefab, GameObject as GameObjectType } from "./types";
|
|
|
9
9
|
import { getComponent, registerComponent } from "./components/ComponentRegistry";
|
|
10
10
|
import components from "./components";
|
|
11
11
|
import { loadModel } from "../dragdrop/modelLoader";
|
|
12
|
-
import { GameInstance, GameInstanceProvider } from "./InstanceProvider";
|
|
12
|
+
import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./InstanceProvider";
|
|
13
13
|
import { updateNode } from "./utils";
|
|
14
14
|
import { PhysicsProps } from "./components/PhysicsComponent";
|
|
15
|
-
|
|
16
|
-
/* -------------------------------------------------- */
|
|
17
|
-
/* Setup */
|
|
18
|
-
/* -------------------------------------------------- */
|
|
15
|
+
import { EditorContext } from "./EditorContext";
|
|
19
16
|
|
|
20
17
|
components.forEach(registerComponent);
|
|
21
18
|
|
|
22
19
|
const IDENTITY = new Matrix4();
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
export interface PrefabRootRef {
|
|
22
|
+
root: Group | null;
|
|
23
|
+
}
|
|
27
24
|
|
|
28
|
-
export const PrefabRoot = forwardRef<
|
|
25
|
+
export const PrefabRoot = forwardRef<PrefabRootRef, {
|
|
29
26
|
editMode?: boolean;
|
|
30
27
|
data: Prefab;
|
|
31
28
|
onPrefabChange?: (data: Prefab) => void;
|
|
32
29
|
selectedId?: string | null;
|
|
33
30
|
onSelect?: (id: string | null) => void;
|
|
34
|
-
|
|
31
|
+
onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
|
|
35
32
|
basePath?: string;
|
|
36
|
-
}>(({ editMode, data, onPrefabChange, selectedId, onSelect,
|
|
33
|
+
}>(({ editMode, data, onPrefabChange, selectedId, onSelect, onClick, basePath = "" }, ref) => {
|
|
37
34
|
|
|
35
|
+
// optional editor context
|
|
36
|
+
const editorContext = useContext(EditorContext);
|
|
37
|
+
const transformMode = editorContext?.transformMode ?? "translate";
|
|
38
|
+
const snapResolution = editorContext?.snapResolution ?? 0;
|
|
39
|
+
|
|
40
|
+
// prefab root state
|
|
38
41
|
const [models, setModels] = useState<Record<string, Object3D>>({});
|
|
39
42
|
const [textures, setTextures] = useState<Record<string, Texture>>({});
|
|
40
43
|
const loading = useRef(new Set<string>());
|
|
41
44
|
const objectRefs = useRef<Record<string, Object3D | null>>({});
|
|
42
45
|
const [selectedObject, setSelectedObject] = useState<Object3D | null>(null);
|
|
46
|
+
const rootRef = useRef<Group>(null);
|
|
47
|
+
|
|
48
|
+
useImperativeHandle(ref, () => ({
|
|
49
|
+
root: rootRef.current
|
|
50
|
+
}), []);
|
|
43
51
|
|
|
44
52
|
const registerRef = useCallback((id: string, obj: Object3D | null) => {
|
|
45
53
|
objectRefs.current[id] = obj;
|
|
46
54
|
if (id === selectedId) setSelectedObject(obj);
|
|
47
55
|
}, [selectedId]);
|
|
48
56
|
|
|
49
|
-
// Suppress TransformControls scene graph warnings during transitions
|
|
50
57
|
useEffect(() => {
|
|
51
58
|
const originalError = console.error;
|
|
52
59
|
console.error = (...args: any[]) => {
|
|
53
|
-
if (typeof args[0] === 'string' && args[0].includes('TransformControls') && args[0].includes('scene graph'))
|
|
54
|
-
return; // Suppress this specific error
|
|
55
|
-
}
|
|
60
|
+
if (typeof args[0] === 'string' && args[0].includes('TransformControls') && args[0].includes('scene graph')) return;
|
|
56
61
|
originalError.apply(console, args);
|
|
57
62
|
};
|
|
58
|
-
return () => {
|
|
59
|
-
console.error = originalError;
|
|
60
|
-
};
|
|
63
|
+
return () => { console.error = originalError; };
|
|
61
64
|
}, []);
|
|
62
65
|
|
|
63
66
|
useEffect(() => {
|
|
64
67
|
setSelectedObject(selectedId ? objectRefs.current[selectedId] ?? null : null);
|
|
65
68
|
}, [selectedId]);
|
|
66
69
|
|
|
67
|
-
/* ---------------- Transform writeback ---------------- */
|
|
68
|
-
|
|
69
70
|
const onTransformChange = () => {
|
|
70
71
|
if (!selectedId || !onPrefabChange) return;
|
|
71
72
|
|
|
@@ -91,8 +92,6 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
91
92
|
onPrefabChange({ ...data, root });
|
|
92
93
|
};
|
|
93
94
|
|
|
94
|
-
/* ---------------- Asset loading ---------------- */
|
|
95
|
-
|
|
96
95
|
useEffect(() => {
|
|
97
96
|
const modelsToLoad = new Set<string>();
|
|
98
97
|
const texturesToLoad = new Set<string>();
|
|
@@ -133,10 +132,8 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
133
132
|
});
|
|
134
133
|
}, [data, models, textures]);
|
|
135
134
|
|
|
136
|
-
/* ---------------- Render ---------------- */
|
|
137
|
-
|
|
138
135
|
return (
|
|
139
|
-
<group ref={
|
|
136
|
+
<group ref={rootRef}>
|
|
140
137
|
<GameInstanceProvider
|
|
141
138
|
models={models}
|
|
142
139
|
selectedId={selectedId}
|
|
@@ -148,6 +145,7 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
148
145
|
gameObject={data.root}
|
|
149
146
|
selectedId={selectedId}
|
|
150
147
|
onSelect={editMode ? onSelect : undefined}
|
|
148
|
+
onClick={onClick}
|
|
151
149
|
registerRef={registerRef}
|
|
152
150
|
loadedModels={models}
|
|
153
151
|
loadedTextures={textures}
|
|
@@ -161,10 +159,14 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
161
159
|
<MapControls makeDefault />
|
|
162
160
|
{selectedObject && (
|
|
163
161
|
<TransformControls
|
|
162
|
+
key={`transform-${snapResolution}`}
|
|
164
163
|
object={selectedObject}
|
|
165
164
|
mode={transformMode}
|
|
166
165
|
space="local"
|
|
167
166
|
onObjectChange={onTransformChange}
|
|
167
|
+
translationSnap={snapResolution > 0 ? snapResolution : undefined}
|
|
168
|
+
rotationSnap={snapResolution > 0 ? snapResolution : undefined}
|
|
169
|
+
scaleSnap={snapResolution > 0 ? snapResolution : undefined}
|
|
168
170
|
/>
|
|
169
171
|
)}
|
|
170
172
|
</>
|
|
@@ -173,54 +175,110 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
173
175
|
);
|
|
174
176
|
});
|
|
175
177
|
|
|
176
|
-
/* -------------------------------------------------- */
|
|
177
|
-
/* Renderer Switch */
|
|
178
|
-
/* -------------------------------------------------- */
|
|
179
|
-
|
|
180
178
|
export function GameObjectRenderer(props: RendererProps) {
|
|
181
179
|
const node = props.gameObject;
|
|
182
180
|
if (!node || node.hidden || node.disabled) return null;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
181
|
+
|
|
182
|
+
const isInstanced = node.components?.model?.properties?.instanced;
|
|
183
|
+
const prevInstancedRef = useRef<boolean | undefined>(undefined);
|
|
184
|
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (prevInstancedRef.current !== undefined && prevInstancedRef.current !== isInstanced) {
|
|
188
|
+
setIsTransitioning(true);
|
|
189
|
+
const timer = setTimeout(() => setIsTransitioning(false), 100);
|
|
190
|
+
return () => clearTimeout(timer);
|
|
191
|
+
}
|
|
192
|
+
prevInstancedRef.current = isInstanced;
|
|
193
|
+
}, [isInstanced]);
|
|
194
|
+
|
|
195
|
+
if (isTransitioning) return null;
|
|
196
|
+
|
|
197
|
+
const key = `${node.id}_${isInstanced ? 'instanced' : 'standard'}`;
|
|
198
|
+
return isInstanced
|
|
199
|
+
? <InstancedNode key={key} {...props} />
|
|
200
|
+
: <StandardNode key={key} {...props} />;
|
|
186
201
|
}
|
|
187
202
|
|
|
188
|
-
/* -------------------------------------------------- */
|
|
189
|
-
/* InstancedNode (terminal) */
|
|
190
|
-
/* -------------------------------------------------- */
|
|
191
203
|
function isPhysicsProps(v: any): v is PhysicsProps {
|
|
192
204
|
return v?.type === "fixed" || v?.type === "dynamic";
|
|
193
205
|
}
|
|
194
206
|
|
|
195
|
-
function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode }: RendererProps) {
|
|
207
|
+
function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, registerRef, selectedId: _selectedId, onSelect, onClick }: RendererProps) {
|
|
196
208
|
const world = parentMatrix.clone().multiply(compose(gameObject));
|
|
197
|
-
const { position, rotation, scale } = decompose(world);
|
|
209
|
+
const { position: worldPosition, rotation: worldRotation, scale: worldScale } = decompose(world);
|
|
210
|
+
const localTransform = getNodeTransformProps(gameObject);
|
|
211
|
+
|
|
198
212
|
const physicsProps = isPhysicsProps(
|
|
199
213
|
gameObject.components?.physics?.properties
|
|
200
214
|
)
|
|
201
215
|
? gameObject.components?.physics?.properties
|
|
202
216
|
: undefined;
|
|
203
217
|
|
|
218
|
+
const groupRef = useRef<Group>(null);
|
|
219
|
+
const clickValid = useRef(false);
|
|
220
|
+
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (editMode) {
|
|
223
|
+
registerRef(gameObject.id, groupRef.current);
|
|
224
|
+
return () => registerRef(gameObject.id, null);
|
|
225
|
+
}
|
|
226
|
+
}, [gameObject.id, registerRef, editMode]);
|
|
227
|
+
|
|
228
|
+
const modelUrl = gameObject.components?.model?.properties?.filename;
|
|
229
|
+
|
|
230
|
+
if (editMode) {
|
|
231
|
+
return (
|
|
232
|
+
<>
|
|
233
|
+
<group
|
|
234
|
+
ref={groupRef}
|
|
235
|
+
position={localTransform.position}
|
|
236
|
+
rotation={localTransform.rotation}
|
|
237
|
+
scale={localTransform.scale}
|
|
238
|
+
onPointerDown={(e) => { e.stopPropagation(); clickValid.current = true; }}
|
|
239
|
+
onPointerMove={() => { clickValid.current = false; }}
|
|
240
|
+
onPointerUp={(e) => {
|
|
241
|
+
if (clickValid.current) {
|
|
242
|
+
e.stopPropagation();
|
|
243
|
+
onSelect?.(gameObject.id);
|
|
244
|
+
onClick?.(e, gameObject);
|
|
245
|
+
}
|
|
246
|
+
clickValid.current = false;
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
<mesh visible={false}>
|
|
250
|
+
<boxGeometry args={[0.01, 0.01, 0.01]} />
|
|
251
|
+
</mesh>
|
|
252
|
+
</group>
|
|
253
|
+
<GameInstance
|
|
254
|
+
id={gameObject.id}
|
|
255
|
+
modelUrl={modelUrl}
|
|
256
|
+
position={worldPosition}
|
|
257
|
+
rotation={worldRotation}
|
|
258
|
+
scale={worldScale}
|
|
259
|
+
physics={physicsProps}
|
|
260
|
+
/>
|
|
261
|
+
</>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
204
265
|
return (
|
|
205
266
|
<GameInstance
|
|
206
267
|
id={gameObject.id}
|
|
207
268
|
modelUrl={gameObject.components?.model?.properties?.filename}
|
|
208
|
-
position={
|
|
209
|
-
rotation={
|
|
210
|
-
scale={
|
|
211
|
-
physics={
|
|
269
|
+
position={worldPosition}
|
|
270
|
+
rotation={worldRotation}
|
|
271
|
+
scale={worldScale}
|
|
272
|
+
physics={physicsProps}
|
|
212
273
|
/>
|
|
213
274
|
);
|
|
214
275
|
}
|
|
215
276
|
|
|
216
|
-
/* -------------------------------------------------- */
|
|
217
|
-
/* StandardNode */
|
|
218
|
-
/* -------------------------------------------------- */
|
|
219
|
-
|
|
220
277
|
function StandardNode({
|
|
221
278
|
gameObject,
|
|
222
279
|
selectedId,
|
|
223
280
|
onSelect,
|
|
281
|
+
onClick,
|
|
224
282
|
registerRef,
|
|
225
283
|
loadedModels,
|
|
226
284
|
loadedTextures,
|
|
@@ -229,12 +287,13 @@ function StandardNode({
|
|
|
229
287
|
}: RendererProps) {
|
|
230
288
|
|
|
231
289
|
const groupRef = useRef<Object3D | null>(null);
|
|
290
|
+
const helperRef = useRef<Object3D | null>(null);
|
|
232
291
|
const clickValid = useRef(false);
|
|
233
292
|
const isSelected = selectedId === gameObject.id;
|
|
234
|
-
const
|
|
293
|
+
const stillInstanced = useInstanceCheck(gameObject.id);
|
|
235
294
|
|
|
236
295
|
useHelper(
|
|
237
|
-
editMode && isSelected ? helperRef : null,
|
|
296
|
+
editMode && isSelected ? helperRef as React.RefObject<Object3D> : null,
|
|
238
297
|
BoxHelper,
|
|
239
298
|
"cyan"
|
|
240
299
|
);
|
|
@@ -255,26 +314,34 @@ function StandardNode({
|
|
|
255
314
|
if (clickValid.current) {
|
|
256
315
|
e.stopPropagation();
|
|
257
316
|
onSelect?.(gameObject.id);
|
|
317
|
+
onClick?.(e, gameObject);
|
|
258
318
|
}
|
|
259
319
|
clickValid.current = false;
|
|
260
320
|
};
|
|
261
321
|
|
|
322
|
+
const physics = gameObject.components?.physics;
|
|
323
|
+
const ready = !gameObject.components?.model ||
|
|
324
|
+
loadedModels[gameObject.components.model.properties.filename];
|
|
325
|
+
const hasPhysics = physics && ready && !stillInstanced;
|
|
326
|
+
const transform = getNodeTransformProps(gameObject);
|
|
327
|
+
const physicsDef = hasPhysics ? getComponent("Physics") : null;
|
|
328
|
+
const isInstanced = gameObject.components?.model?.properties?.instanced;
|
|
329
|
+
const physicsKey = `physics_${gameObject.id}_${isInstanced ? 'instanced' : 'standard'}`;
|
|
330
|
+
|
|
262
331
|
const inner = (
|
|
263
332
|
<group
|
|
264
|
-
|
|
265
|
-
{
|
|
266
|
-
|
|
267
|
-
onPointerMove={() => (clickValid.current = false)}
|
|
268
|
-
onPointerUp={onUp}
|
|
333
|
+
onPointerDown={editMode ? onDown : undefined}
|
|
334
|
+
onPointerMove={editMode ? () => (clickValid.current = false) : undefined}
|
|
335
|
+
onPointerUp={editMode ? onUp : undefined}
|
|
269
336
|
>
|
|
270
337
|
{renderCoreNode(gameObject, { loadedModels, loadedTextures, editMode, registerRef }, parentMatrix)}
|
|
271
338
|
{gameObject.children?.map(child => (
|
|
272
339
|
<GameObjectRenderer
|
|
273
340
|
key={child.id}
|
|
274
|
-
{...{ child }}
|
|
275
341
|
gameObject={child}
|
|
276
342
|
selectedId={selectedId}
|
|
277
343
|
onSelect={onSelect}
|
|
344
|
+
onClick={onClick}
|
|
278
345
|
registerRef={registerRef}
|
|
279
346
|
loadedModels={loadedModels}
|
|
280
347
|
loadedTextures={loadedTextures}
|
|
@@ -285,28 +352,74 @@ function StandardNode({
|
|
|
285
352
|
</group>
|
|
286
353
|
);
|
|
287
354
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
355
|
+
if (editMode) {
|
|
356
|
+
return (
|
|
357
|
+
<>
|
|
358
|
+
<group
|
|
359
|
+
ref={groupRef}
|
|
360
|
+
position={transform.position}
|
|
361
|
+
rotation={transform.rotation}
|
|
362
|
+
scale={transform.scale}
|
|
363
|
+
>
|
|
364
|
+
<mesh visible={false}>
|
|
365
|
+
<boxGeometry args={[0.01, 0.01, 0.01]} />
|
|
366
|
+
</mesh>
|
|
367
|
+
</group>
|
|
368
|
+
<group
|
|
369
|
+
ref={helperRef}
|
|
370
|
+
position={transform.position}
|
|
371
|
+
rotation={transform.rotation}
|
|
372
|
+
scale={transform.scale}
|
|
373
|
+
>
|
|
374
|
+
{inner}
|
|
375
|
+
</group>
|
|
376
|
+
{hasPhysics && physicsDef?.View ? (
|
|
377
|
+
<physicsDef.View
|
|
378
|
+
key={physicsKey}
|
|
379
|
+
properties={physics.properties}
|
|
380
|
+
position={transform.position}
|
|
381
|
+
rotation={transform.rotation}
|
|
382
|
+
scale={transform.scale}
|
|
383
|
+
editMode={editMode}
|
|
384
|
+
>{inner}</physicsDef.View>
|
|
385
|
+
) : null}
|
|
386
|
+
</>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
291
389
|
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
390
|
+
if (hasPhysics && physicsDef?.View) {
|
|
391
|
+
return (
|
|
392
|
+
<physicsDef.View
|
|
393
|
+
key={physicsKey}
|
|
394
|
+
properties={physics.properties}
|
|
395
|
+
position={transform.position}
|
|
396
|
+
rotation={transform.rotation}
|
|
397
|
+
scale={transform.scale}
|
|
398
|
+
editMode={editMode}
|
|
399
|
+
>{inner}</physicsDef.View>
|
|
400
|
+
);
|
|
297
401
|
}
|
|
298
402
|
|
|
299
|
-
return
|
|
403
|
+
return (
|
|
404
|
+
<group
|
|
405
|
+
ref={groupRef}
|
|
406
|
+
position={transform.position}
|
|
407
|
+
rotation={transform.rotation}
|
|
408
|
+
scale={transform.scale}
|
|
409
|
+
onPointerDown={onDown}
|
|
410
|
+
onPointerMove={() => (clickValid.current = false)}
|
|
411
|
+
onPointerUp={onUp}
|
|
412
|
+
>
|
|
413
|
+
{inner}
|
|
414
|
+
</group>
|
|
415
|
+
);
|
|
300
416
|
}
|
|
301
417
|
|
|
302
|
-
/* -------------------------------------------------- */
|
|
303
|
-
/* Types & Helpers */
|
|
304
|
-
/* -------------------------------------------------- */
|
|
305
|
-
|
|
306
418
|
interface RendererProps {
|
|
307
|
-
gameObject: GameObjectType;
|
|
419
|
+
gameObject: GameObjectType;
|
|
308
420
|
selectedId?: string | null;
|
|
309
421
|
onSelect?: (id: string) => void;
|
|
422
|
+
onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
|
|
310
423
|
registerRef: (id: string, obj: Object3D | null) => void;
|
|
311
424
|
loadedModels: Record<string, Object3D>;
|
|
312
425
|
loadedTextures: Record<string, Texture>;
|
|
@@ -401,7 +514,6 @@ function renderCoreNode(
|
|
|
401
514
|
const def = getComponent(comp.type);
|
|
402
515
|
if (!def?.View) return;
|
|
403
516
|
|
|
404
|
-
// crude but works with your existing component API
|
|
405
517
|
if (def.View.toString().includes("children")) {
|
|
406
518
|
wrappers.push({ key, View: def.View, properties: comp.properties });
|
|
407
519
|
} else {
|
|
@@ -52,12 +52,19 @@ export function Label({ children }: { children: React.ReactNode }) {
|
|
|
52
52
|
export function Vector3Input({
|
|
53
53
|
label,
|
|
54
54
|
value,
|
|
55
|
-
onChange
|
|
55
|
+
onChange,
|
|
56
|
+
snap
|
|
56
57
|
}: {
|
|
57
58
|
label: string;
|
|
58
59
|
value: [number, number, number];
|
|
59
60
|
onChange: (v: [number, number, number]) => void;
|
|
61
|
+
snap?: number;
|
|
60
62
|
}) {
|
|
63
|
+
const snapValue = (num: number) => {
|
|
64
|
+
if (!snap) return num;
|
|
65
|
+
return Math.round(num / snap) * snap;
|
|
66
|
+
};
|
|
67
|
+
|
|
61
68
|
const [draft, setDraft] = useState<[string, string, string]>(
|
|
62
69
|
() => value.map(v => v.toString()) as any
|
|
63
70
|
);
|
|
@@ -77,7 +84,7 @@ export function Vector3Input({
|
|
|
77
84
|
const num = parseFloat(draft[index]);
|
|
78
85
|
if (Number.isFinite(num)) {
|
|
79
86
|
const next = [...value] as [number, number, number];
|
|
80
|
-
next[index] = num;
|
|
87
|
+
next[index] = snapValue(num);
|
|
81
88
|
onChange(next);
|
|
82
89
|
}
|
|
83
90
|
};
|
|
@@ -105,7 +112,8 @@ export function Vector3Input({
|
|
|
105
112
|
if (e.shiftKey) speed *= 0.1; // fine
|
|
106
113
|
if (e.altKey) speed *= 5; // coarse
|
|
107
114
|
|
|
108
|
-
const
|
|
115
|
+
const rawValue = startValue + dx * speed;
|
|
116
|
+
const nextValue = snapValue(rawValue);
|
|
109
117
|
const next = [...value] as [number, number, number];
|
|
110
118
|
next[index] = nextValue;
|
|
111
119
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { RigidBody } from "@react-three/rapier";
|
|
1
|
+
import { RigidBody, RapierRigidBody } from "@react-three/rapier";
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
3
4
|
import { Component } from "./ComponentRegistry";
|
|
4
5
|
import { Label } from "./Input";
|
|
6
|
+
import { Quaternion, Euler } from 'three';
|
|
5
7
|
|
|
6
8
|
export interface PhysicsProps {
|
|
7
9
|
type: "fixed" | "dynamic";
|
|
@@ -52,18 +54,29 @@ interface PhysicsViewProps {
|
|
|
52
54
|
properties: { type?: 'dynamic' | 'fixed'; collider?: string };
|
|
53
55
|
editMode?: boolean;
|
|
54
56
|
children?: ReactNode;
|
|
57
|
+
position?: [number, number, number];
|
|
58
|
+
rotation?: [number, number, number];
|
|
59
|
+
scale?: [number, number, number];
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
function PhysicsComponentView({ properties,
|
|
58
|
-
if (editMode) return <>{children}</>;
|
|
59
|
-
|
|
62
|
+
function PhysicsComponentView({ properties, children, position, rotation, scale, editMode }: PhysicsViewProps) {
|
|
60
63
|
const colliders = properties.collider || (properties.type === 'fixed' ? 'trimesh' : 'hull');
|
|
61
64
|
|
|
62
|
-
//
|
|
63
|
-
|
|
65
|
+
// In edit mode, include position/rotation in key to force remount when transform changes
|
|
66
|
+
// This ensures the RigidBody debug visualization updates even when physics is paused
|
|
67
|
+
const rbKey = editMode
|
|
68
|
+
? `${properties.type || 'dynamic'}_${colliders}_${position?.join(',')}_${rotation?.join(',')}`
|
|
69
|
+
: `${properties.type || 'dynamic'}_${colliders}`;
|
|
64
70
|
|
|
65
71
|
return (
|
|
66
|
-
<RigidBody
|
|
72
|
+
<RigidBody
|
|
73
|
+
key={rbKey}
|
|
74
|
+
type={properties.type}
|
|
75
|
+
colliders={colliders as any}
|
|
76
|
+
position={position}
|
|
77
|
+
rotation={rotation}
|
|
78
|
+
scale={scale}
|
|
79
|
+
>
|
|
67
80
|
{children}
|
|
68
81
|
</RigidBody>
|
|
69
82
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Component } from "./ComponentRegistry";
|
|
2
2
|
import { Vector3Input, Label } from "./Input";
|
|
3
|
+
import { useEditorContext } from "../EditorContext";
|
|
3
4
|
|
|
4
5
|
const buttonStyle = {
|
|
5
6
|
padding: '2px 6px',
|
|
@@ -18,10 +19,12 @@ function TransformComponentEditor({ component, onUpdate, transformMode, setTrans
|
|
|
18
19
|
transformMode?: "translate" | "rotate" | "scale";
|
|
19
20
|
setTransformMode?: (m: "translate" | "rotate" | "scale") => void;
|
|
20
21
|
}) {
|
|
22
|
+
const { snapResolution, setSnapResolution } = useEditorContext();
|
|
23
|
+
|
|
21
24
|
return <div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
22
25
|
{transformMode && setTransformMode && (
|
|
23
26
|
<div style={{ marginBottom: 8 }}>
|
|
24
|
-
<Label>Transform Mode</Label>
|
|
27
|
+
<Label>Transform Mode {snapResolution > 0 && `(Snap: ${snapResolution})`}</Label>
|
|
25
28
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
26
29
|
{["translate", "rotate", "scale"].map(mode => {
|
|
27
30
|
const isActive = transformMode === mode;
|
|
@@ -45,11 +48,29 @@ function TransformComponentEditor({ component, onUpdate, transformMode, setTrans
|
|
|
45
48
|
);
|
|
46
49
|
})}
|
|
47
50
|
</div>
|
|
51
|
+
<div style={{ marginTop: 6 }}>
|
|
52
|
+
<button
|
|
53
|
+
onClick={() => setSnapResolution(snapResolution > 0 ? 0 : 0.1)}
|
|
54
|
+
style={{
|
|
55
|
+
...buttonStyle,
|
|
56
|
+
background: snapResolution > 0 ? 'rgba(255,255,255,0.10)' : 'transparent',
|
|
57
|
+
width: '100%',
|
|
58
|
+
}}
|
|
59
|
+
onPointerEnter={(e) => {
|
|
60
|
+
if (snapResolution === 0) e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
|
|
61
|
+
}}
|
|
62
|
+
onPointerLeave={(e) => {
|
|
63
|
+
if (snapResolution === 0) e.currentTarget.style.background = 'transparent';
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
Snap: {snapResolution > 0 ? `ON (${snapResolution})` : 'OFF'}
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
48
69
|
</div>
|
|
49
70
|
)}
|
|
50
|
-
<Vector3Input label="Position" value={component.properties.position} onChange={v => onUpdate({ position: v })} />
|
|
51
|
-
<Vector3Input label="Rotation" value={component.properties.rotation} onChange={v => onUpdate({ rotation: v })} />
|
|
52
|
-
<Vector3Input label="Scale" value={component.properties.scale} onChange={v => onUpdate({ scale: v })} />
|
|
71
|
+
<Vector3Input label="Position" value={component.properties.position} onChange={v => onUpdate({ position: v })} snap={snapResolution} />
|
|
72
|
+
<Vector3Input label="Rotation" value={component.properties.rotation} onChange={v => onUpdate({ rotation: v })} snap={snapResolution} />
|
|
73
|
+
<Vector3Input label="Scale" value={component.properties.scale} onChange={v => onUpdate({ scale: v })} snap={snapResolution} />
|
|
53
74
|
</div>;
|
|
54
75
|
}
|
|
55
76
|
|
|
@@ -1,4 +1,37 @@
|
|
|
1
|
-
import { GameObject } from "./types";
|
|
1
|
+
import { GameObject, Prefab } from "./types";
|
|
2
|
+
|
|
3
|
+
/** Save a prefab as JSON file */
|
|
4
|
+
export function saveJson(data: Prefab, filename: string) {
|
|
5
|
+
const a = document.createElement('a');
|
|
6
|
+
a.href = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
|
|
7
|
+
a.download = `${filename || 'prefab'}.json`;
|
|
8
|
+
a.click();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Load a prefab from JSON file */
|
|
12
|
+
export function loadJson(): Promise<Prefab | undefined> {
|
|
13
|
+
return new Promise(resolve => {
|
|
14
|
+
const input = document.createElement('input');
|
|
15
|
+
input.type = 'file';
|
|
16
|
+
input.accept = '.json,application/json';
|
|
17
|
+
input.onchange = e => {
|
|
18
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
19
|
+
if (!file) return resolve(undefined);
|
|
20
|
+
const reader = new FileReader();
|
|
21
|
+
reader.onload = e => {
|
|
22
|
+
try {
|
|
23
|
+
const text = e.target?.result;
|
|
24
|
+
if (typeof text === 'string') resolve(JSON.parse(text) as Prefab);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error('Error parsing prefab JSON:', err);
|
|
27
|
+
resolve(undefined);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
reader.readAsText(file);
|
|
31
|
+
};
|
|
32
|
+
input.click();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
2
35
|
|
|
3
36
|
/** Find a node by ID in the tree */
|
|
4
37
|
export function findNode(root: GameObject, id: string): GameObject | null {
|
|
@@ -74,6 +107,15 @@ export function cloneNode(node: GameObject): GameObject {
|
|
|
74
107
|
};
|
|
75
108
|
}
|
|
76
109
|
|
|
110
|
+
/** Recursively update all IDs in a node tree */
|
|
111
|
+
export function regenerateIds(node: GameObject): GameObject {
|
|
112
|
+
return {
|
|
113
|
+
...node,
|
|
114
|
+
id: crypto.randomUUID(),
|
|
115
|
+
children: node.children?.map(regenerateIds)
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
77
119
|
/** Get component data from a node */
|
|
78
120
|
export function getComponent<T = any>(node: GameObject, type: string): T | undefined {
|
|
79
121
|
const comp = Object.values(node.components ?? {}).find(c => c?.type === type);
|