react-three-game 0.0.37 → 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/PrefabEditor.d.ts +10 -2
- package/dist/tools/prefabeditor/PrefabEditor.js +60 -53
- package/dist/tools/prefabeditor/PrefabRoot.d.ts +4 -2
- package/dist/tools/prefabeditor/PrefabRoot.js +18 -41
- package/dist/tools/prefabeditor/components/Input.d.ts +2 -1
- package/dist/tools/prefabeditor/components/Input.js +9 -3
- 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/PrefabEditor.tsx +79 -50
- package/src/tools/prefabeditor/PrefabRoot.tsx +26 -64
- package/src/tools/prefabeditor/components/Input.tsx +11 -3
- 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
|
|
|
@@ -12,61 +12,61 @@ import { loadModel } from "../dragdrop/modelLoader";
|
|
|
12
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
|
-
transformMode?: "translate" | "rotate" | "scale";
|
|
36
32
|
basePath?: string;
|
|
37
|
-
}>(({ editMode, data, onPrefabChange, selectedId, onSelect, onClick,
|
|
33
|
+
}>(({ editMode, data, onPrefabChange, selectedId, onSelect, onClick, basePath = "" }, ref) => {
|
|
38
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
|
|
39
41
|
const [models, setModels] = useState<Record<string, Object3D>>({});
|
|
40
42
|
const [textures, setTextures] = useState<Record<string, Texture>>({});
|
|
41
43
|
const loading = useRef(new Set<string>());
|
|
42
44
|
const objectRefs = useRef<Record<string, Object3D | null>>({});
|
|
43
45
|
const [selectedObject, setSelectedObject] = useState<Object3D | null>(null);
|
|
46
|
+
const rootRef = useRef<Group>(null);
|
|
47
|
+
|
|
48
|
+
useImperativeHandle(ref, () => ({
|
|
49
|
+
root: rootRef.current
|
|
50
|
+
}), []);
|
|
44
51
|
|
|
45
52
|
const registerRef = useCallback((id: string, obj: Object3D | null) => {
|
|
46
53
|
objectRefs.current[id] = obj;
|
|
47
54
|
if (id === selectedId) setSelectedObject(obj);
|
|
48
55
|
}, [selectedId]);
|
|
49
56
|
|
|
50
|
-
// Suppress TransformControls scene graph warnings during transitions
|
|
51
57
|
useEffect(() => {
|
|
52
58
|
const originalError = console.error;
|
|
53
59
|
console.error = (...args: any[]) => {
|
|
54
|
-
if (typeof args[0] === 'string' && args[0].includes('TransformControls') && args[0].includes('scene graph'))
|
|
55
|
-
return; // Suppress this specific error
|
|
56
|
-
}
|
|
60
|
+
if (typeof args[0] === 'string' && args[0].includes('TransformControls') && args[0].includes('scene graph')) return;
|
|
57
61
|
originalError.apply(console, args);
|
|
58
62
|
};
|
|
59
|
-
return () => {
|
|
60
|
-
console.error = originalError;
|
|
61
|
-
};
|
|
63
|
+
return () => { console.error = originalError; };
|
|
62
64
|
}, []);
|
|
63
65
|
|
|
64
66
|
useEffect(() => {
|
|
65
67
|
setSelectedObject(selectedId ? objectRefs.current[selectedId] ?? null : null);
|
|
66
68
|
}, [selectedId]);
|
|
67
69
|
|
|
68
|
-
/* ---------------- Transform writeback ---------------- */
|
|
69
|
-
|
|
70
70
|
const onTransformChange = () => {
|
|
71
71
|
if (!selectedId || !onPrefabChange) return;
|
|
72
72
|
|
|
@@ -92,8 +92,6 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
92
92
|
onPrefabChange({ ...data, root });
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
-
/* ---------------- Asset loading ---------------- */
|
|
96
|
-
|
|
97
95
|
useEffect(() => {
|
|
98
96
|
const modelsToLoad = new Set<string>();
|
|
99
97
|
const texturesToLoad = new Set<string>();
|
|
@@ -134,10 +132,8 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
134
132
|
});
|
|
135
133
|
}, [data, models, textures]);
|
|
136
134
|
|
|
137
|
-
/* ---------------- Render ---------------- */
|
|
138
|
-
|
|
139
135
|
return (
|
|
140
|
-
<group ref={
|
|
136
|
+
<group ref={rootRef}>
|
|
141
137
|
<GameInstanceProvider
|
|
142
138
|
models={models}
|
|
143
139
|
selectedId={selectedId}
|
|
@@ -163,10 +159,14 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
163
159
|
<MapControls makeDefault />
|
|
164
160
|
{selectedObject && (
|
|
165
161
|
<TransformControls
|
|
162
|
+
key={`transform-${snapResolution}`}
|
|
166
163
|
object={selectedObject}
|
|
167
164
|
mode={transformMode}
|
|
168
165
|
space="local"
|
|
169
166
|
onObjectChange={onTransformChange}
|
|
167
|
+
translationSnap={snapResolution > 0 ? snapResolution : undefined}
|
|
168
|
+
rotationSnap={snapResolution > 0 ? snapResolution : undefined}
|
|
169
|
+
scaleSnap={snapResolution > 0 ? snapResolution : undefined}
|
|
170
170
|
/>
|
|
171
171
|
)}
|
|
172
172
|
</>
|
|
@@ -175,10 +175,6 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
175
175
|
);
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
/* -------------------------------------------------- */
|
|
179
|
-
/* Renderer Switch */
|
|
180
|
-
/* -------------------------------------------------- */
|
|
181
|
-
|
|
182
178
|
export function GameObjectRenderer(props: RendererProps) {
|
|
183
179
|
const node = props.gameObject;
|
|
184
180
|
if (!node || node.hidden || node.disabled) return null;
|
|
@@ -188,17 +184,14 @@ export function GameObjectRenderer(props: RendererProps) {
|
|
|
188
184
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
189
185
|
|
|
190
186
|
useEffect(() => {
|
|
191
|
-
// Detect instanced mode change
|
|
192
187
|
if (prevInstancedRef.current !== undefined && prevInstancedRef.current !== isInstanced) {
|
|
193
188
|
setIsTransitioning(true);
|
|
194
|
-
// Wait for cleanup, then allow new mode to render
|
|
195
189
|
const timer = setTimeout(() => setIsTransitioning(false), 100);
|
|
196
190
|
return () => clearTimeout(timer);
|
|
197
191
|
}
|
|
198
192
|
prevInstancedRef.current = isInstanced;
|
|
199
193
|
}, [isInstanced]);
|
|
200
194
|
|
|
201
|
-
// Don't render during transition to avoid physics conflicts
|
|
202
195
|
if (isTransitioning) return null;
|
|
203
196
|
|
|
204
197
|
const key = `${node.id}_${isInstanced ? 'instanced' : 'standard'}`;
|
|
@@ -207,9 +200,6 @@ export function GameObjectRenderer(props: RendererProps) {
|
|
|
207
200
|
: <StandardNode key={key} {...props} />;
|
|
208
201
|
}
|
|
209
202
|
|
|
210
|
-
/* -------------------------------------------------- */
|
|
211
|
-
/* InstancedNode (terminal) */
|
|
212
|
-
/* -------------------------------------------------- */
|
|
213
203
|
function isPhysicsProps(v: any): v is PhysicsProps {
|
|
214
204
|
return v?.type === "fixed" || v?.type === "dynamic";
|
|
215
205
|
}
|
|
@@ -217,8 +207,6 @@ function isPhysicsProps(v: any): v is PhysicsProps {
|
|
|
217
207
|
function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, registerRef, selectedId: _selectedId, onSelect, onClick }: RendererProps) {
|
|
218
208
|
const world = parentMatrix.clone().multiply(compose(gameObject));
|
|
219
209
|
const { position: worldPosition, rotation: worldRotation, scale: worldScale } = decompose(world);
|
|
220
|
-
|
|
221
|
-
// Get local transform for proxy group (used by transform controls)
|
|
222
210
|
const localTransform = getNodeTransformProps(gameObject);
|
|
223
211
|
|
|
224
212
|
const physicsProps = isPhysicsProps(
|
|
@@ -239,12 +227,9 @@ function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, register
|
|
|
239
227
|
|
|
240
228
|
const modelUrl = gameObject.components?.model?.properties?.filename;
|
|
241
229
|
|
|
242
|
-
// In edit mode, create a proxy group at the same position for transform controls
|
|
243
|
-
// The GameInstance still needs the actual position so it renders correctly
|
|
244
230
|
if (editMode) {
|
|
245
231
|
return (
|
|
246
232
|
<>
|
|
247
|
-
{/* Proxy group for transform controls - uses LOCAL transform */}
|
|
248
233
|
<group
|
|
249
234
|
ref={groupRef}
|
|
250
235
|
position={localTransform.position}
|
|
@@ -261,12 +246,10 @@ function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, register
|
|
|
261
246
|
clickValid.current = false;
|
|
262
247
|
}}
|
|
263
248
|
>
|
|
264
|
-
{/* Tiny invisible mesh for raycasting/selection */}
|
|
265
249
|
<mesh visible={false}>
|
|
266
250
|
<boxGeometry args={[0.01, 0.01, 0.01]} />
|
|
267
251
|
</mesh>
|
|
268
252
|
</group>
|
|
269
|
-
{/* Actual instance rendered by provider - uses WORLD transform */}
|
|
270
253
|
<GameInstance
|
|
271
254
|
id={gameObject.id}
|
|
272
255
|
modelUrl={modelUrl}
|
|
@@ -291,10 +274,6 @@ function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, register
|
|
|
291
274
|
);
|
|
292
275
|
}
|
|
293
276
|
|
|
294
|
-
/* -------------------------------------------------- */
|
|
295
|
-
/* StandardNode */
|
|
296
|
-
/* -------------------------------------------------- */
|
|
297
|
-
|
|
298
277
|
function StandardNode({
|
|
299
278
|
gameObject,
|
|
300
279
|
selectedId,
|
|
@@ -311,11 +290,8 @@ function StandardNode({
|
|
|
311
290
|
const helperRef = useRef<Object3D | null>(null);
|
|
312
291
|
const clickValid = useRef(false);
|
|
313
292
|
const isSelected = selectedId === gameObject.id;
|
|
314
|
-
|
|
315
|
-
// Check if this object still exists as an instance (to prevent physics overlap)
|
|
316
293
|
const stillInstanced = useInstanceCheck(gameObject.id);
|
|
317
294
|
|
|
318
|
-
// Use helperRef for BoxHelper (shows actual content bounds at correct position)
|
|
319
295
|
useHelper(
|
|
320
296
|
editMode && isSelected ? helperRef as React.RefObject<Object3D> : null,
|
|
321
297
|
BoxHelper,
|
|
@@ -348,8 +324,6 @@ function StandardNode({
|
|
|
348
324
|
loadedModels[gameObject.components.model.properties.filename];
|
|
349
325
|
const hasPhysics = physics && ready && !stillInstanced;
|
|
350
326
|
const transform = getNodeTransformProps(gameObject);
|
|
351
|
-
|
|
352
|
-
// Prepare physics wrapper if needed
|
|
353
327
|
const physicsDef = hasPhysics ? getComponent("Physics") : null;
|
|
354
328
|
const isInstanced = gameObject.components?.model?.properties?.instanced;
|
|
355
329
|
const physicsKey = `physics_${gameObject.id}_${isInstanced ? 'instanced' : 'standard'}`;
|
|
@@ -364,7 +338,6 @@ function StandardNode({
|
|
|
364
338
|
{gameObject.children?.map(child => (
|
|
365
339
|
<GameObjectRenderer
|
|
366
340
|
key={child.id}
|
|
367
|
-
{...{ child }}
|
|
368
341
|
gameObject={child}
|
|
369
342
|
selectedId={selectedId}
|
|
370
343
|
onSelect={onSelect}
|
|
@@ -379,23 +352,19 @@ function StandardNode({
|
|
|
379
352
|
</group>
|
|
380
353
|
);
|
|
381
354
|
|
|
382
|
-
// In edit mode, use proxy group pattern
|
|
383
355
|
if (editMode) {
|
|
384
356
|
return (
|
|
385
357
|
<>
|
|
386
|
-
{/* Proxy group for transform controls - uses LOCAL transform */}
|
|
387
358
|
<group
|
|
388
359
|
ref={groupRef}
|
|
389
360
|
position={transform.position}
|
|
390
361
|
rotation={transform.rotation}
|
|
391
362
|
scale={transform.scale}
|
|
392
363
|
>
|
|
393
|
-
{/* Tiny invisible mesh for raycasting/selection */}
|
|
394
364
|
<mesh visible={false}>
|
|
395
365
|
<boxGeometry args={[0.01, 0.01, 0.01]} />
|
|
396
366
|
</mesh>
|
|
397
367
|
</group>
|
|
398
|
-
{/* Helper group for BoxHelper - same transform as proxy, contains actual geometry */}
|
|
399
368
|
<group
|
|
400
369
|
ref={helperRef}
|
|
401
370
|
position={transform.position}
|
|
@@ -404,7 +373,6 @@ function StandardNode({
|
|
|
404
373
|
>
|
|
405
374
|
{inner}
|
|
406
375
|
</group>
|
|
407
|
-
{/* Actual content with physics wrapper if needed */}
|
|
408
376
|
{hasPhysics && physicsDef?.View ? (
|
|
409
377
|
<physicsDef.View
|
|
410
378
|
key={physicsKey}
|
|
@@ -419,7 +387,6 @@ function StandardNode({
|
|
|
419
387
|
);
|
|
420
388
|
}
|
|
421
389
|
|
|
422
|
-
// In play mode, apply transform directly to content
|
|
423
390
|
if (hasPhysics && physicsDef?.View) {
|
|
424
391
|
return (
|
|
425
392
|
<physicsDef.View
|
|
@@ -448,12 +415,8 @@ function StandardNode({
|
|
|
448
415
|
);
|
|
449
416
|
}
|
|
450
417
|
|
|
451
|
-
/* -------------------------------------------------- */
|
|
452
|
-
/* Types & Helpers */
|
|
453
|
-
/* -------------------------------------------------- */
|
|
454
|
-
|
|
455
418
|
interface RendererProps {
|
|
456
|
-
gameObject: GameObjectType;
|
|
419
|
+
gameObject: GameObjectType;
|
|
457
420
|
selectedId?: string | null;
|
|
458
421
|
onSelect?: (id: string) => void;
|
|
459
422
|
onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
|
|
@@ -551,7 +514,6 @@ function renderCoreNode(
|
|
|
551
514
|
const def = getComponent(comp.type);
|
|
552
515
|
if (!def?.View) return;
|
|
553
516
|
|
|
554
|
-
// crude but works with your existing component API
|
|
555
517
|
if (def.View.toString().includes("children")) {
|
|
556
518
|
wrappers.push({ key, View: def.View, properties: comp.properties });
|
|
557
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,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);
|