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
|
@@ -4,17 +4,14 @@ import EditorTree from './EditorTree';
|
|
|
4
4
|
import { getAllComponents } from './components/ComponentRegistry';
|
|
5
5
|
import { base, inspector } from './styles';
|
|
6
6
|
import { findNode, updateNode, deleteNode } from './utils';
|
|
7
|
+
import { useEditorContext } from './EditorContext';
|
|
7
8
|
|
|
8
9
|
function EditorUI({
|
|
9
10
|
prefabData,
|
|
10
11
|
setPrefabData,
|
|
11
12
|
selectedId,
|
|
12
13
|
setSelectedId,
|
|
13
|
-
transformMode,
|
|
14
|
-
setTransformMode,
|
|
15
14
|
basePath,
|
|
16
|
-
onSave,
|
|
17
|
-
onLoad,
|
|
18
15
|
onUndo,
|
|
19
16
|
onRedo,
|
|
20
17
|
canUndo,
|
|
@@ -24,17 +21,14 @@ function EditorUI({
|
|
|
24
21
|
setPrefabData?: Dispatch<SetStateAction<Prefab>>;
|
|
25
22
|
selectedId: string | null;
|
|
26
23
|
setSelectedId: Dispatch<SetStateAction<string | null>>;
|
|
27
|
-
transformMode: "translate" | "rotate" | "scale";
|
|
28
|
-
setTransformMode: (m: "translate" | "rotate" | "scale") => void;
|
|
29
24
|
basePath?: string;
|
|
30
|
-
onSave?: () => void;
|
|
31
|
-
onLoad?: () => void;
|
|
32
25
|
onUndo?: () => void;
|
|
33
26
|
onRedo?: () => void;
|
|
34
27
|
canUndo?: boolean;
|
|
35
28
|
canRedo?: boolean;
|
|
36
29
|
}) {
|
|
37
30
|
const [collapsed, setCollapsed] = useState(false);
|
|
31
|
+
const { transformMode, setTransformMode } = useEditorContext();
|
|
38
32
|
|
|
39
33
|
const updateNodeHandler = (updater: (n: GameObjectType) => GameObjectType) => {
|
|
40
34
|
if (!prefabData || !setPrefabData || !selectedId) return;
|
|
@@ -81,8 +75,6 @@ function EditorUI({
|
|
|
81
75
|
setPrefabData={setPrefabData}
|
|
82
76
|
selectedId={selectedId}
|
|
83
77
|
setSelectedId={setSelectedId}
|
|
84
|
-
onSave={onSave}
|
|
85
|
-
onLoad={onLoad}
|
|
86
78
|
onUndo={onUndo}
|
|
87
79
|
onRedo={onRedo}
|
|
88
80
|
canUndo={canUndo}
|
|
@@ -40,6 +40,7 @@ type GameInstanceContextType = {
|
|
|
40
40
|
instances: InstanceData[];
|
|
41
41
|
meshes: Record<string, Mesh>;
|
|
42
42
|
modelParts?: Record<string, number>;
|
|
43
|
+
hasInstance: (id: string) => boolean;
|
|
43
44
|
};
|
|
44
45
|
const GameInstanceContext = createContext<GameInstanceContextType | null>(null);
|
|
45
46
|
|
|
@@ -84,6 +85,10 @@ export function GameInstanceProvider({
|
|
|
84
85
|
});
|
|
85
86
|
}, []);
|
|
86
87
|
|
|
88
|
+
const hasInstance = useCallback((id: string) => {
|
|
89
|
+
return instances.some(i => i.id === id);
|
|
90
|
+
}, [instances]);
|
|
91
|
+
|
|
87
92
|
// Flatten all model meshes once (models → flat mesh parts)
|
|
88
93
|
// Note: Geometry is cloned with baked transforms for instancing
|
|
89
94
|
const { flatMeshes, modelParts } = useMemo(() => {
|
|
@@ -138,7 +143,8 @@ export function GameInstanceProvider({
|
|
|
138
143
|
removeInstance,
|
|
139
144
|
instances,
|
|
140
145
|
meshes: flatMeshes,
|
|
141
|
-
modelParts
|
|
146
|
+
modelParts,
|
|
147
|
+
hasInstance
|
|
142
148
|
}}
|
|
143
149
|
>
|
|
144
150
|
{/* Render normal prefab hierarchy (non-instanced objects) */}
|
|
@@ -158,6 +164,8 @@ export function GameInstanceProvider({
|
|
|
158
164
|
modelKey={modelKey}
|
|
159
165
|
partCount={partCount}
|
|
160
166
|
flatMeshes={flatMeshes}
|
|
167
|
+
onSelect={onSelect}
|
|
168
|
+
editMode={editMode}
|
|
161
169
|
/>
|
|
162
170
|
);
|
|
163
171
|
})}
|
|
@@ -208,14 +216,19 @@ function InstancedRigidGroup({
|
|
|
208
216
|
group,
|
|
209
217
|
modelKey,
|
|
210
218
|
partCount,
|
|
211
|
-
flatMeshes
|
|
219
|
+
flatMeshes,
|
|
220
|
+
onSelect,
|
|
221
|
+
editMode
|
|
212
222
|
}: {
|
|
213
223
|
group: { physicsType: string, instances: InstanceData[] },
|
|
214
224
|
modelKey: string,
|
|
215
225
|
partCount: number,
|
|
216
|
-
flatMeshes: Record<string, Mesh
|
|
226
|
+
flatMeshes: Record<string, Mesh>,
|
|
227
|
+
onSelect?: (id: string | null) => void,
|
|
228
|
+
editMode?: boolean
|
|
217
229
|
}) {
|
|
218
230
|
const meshRefs = useRef<(InstancedMesh | null)[]>([]);
|
|
231
|
+
const rigidBodiesRef = useRef<any>(null);
|
|
219
232
|
|
|
220
233
|
const instances = useMemo(
|
|
221
234
|
() => group.instances.map(inst => ({
|
|
@@ -248,12 +261,48 @@ function InstancedRigidGroup({
|
|
|
248
261
|
});
|
|
249
262
|
mesh.instanceMatrix.needsUpdate = true;
|
|
250
263
|
});
|
|
264
|
+
|
|
265
|
+
// Update rigid body positions when instances change
|
|
266
|
+
if (rigidBodiesRef.current) {
|
|
267
|
+
try {
|
|
268
|
+
group.instances.forEach((inst, i) => {
|
|
269
|
+
const body = rigidBodiesRef.current?.at(i);
|
|
270
|
+
if (body && body.setTranslation && body.setRotation) {
|
|
271
|
+
pos.set(...inst.position);
|
|
272
|
+
euler.set(...inst.rotation);
|
|
273
|
+
quat.setFromEuler(euler);
|
|
274
|
+
body.setTranslation(pos, false);
|
|
275
|
+
body.setRotation(quat, false);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
// Ignore errors when switching between instanced/non-instanced states
|
|
280
|
+
console.warn('Failed to update rigidbody positions:', error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
251
283
|
}, [group.instances]);
|
|
252
284
|
|
|
253
285
|
const colliders = group.physicsType === 'fixed' ? 'trimesh' : 'hull';
|
|
254
286
|
|
|
287
|
+
// Handle click on instanced mesh in edit mode
|
|
288
|
+
const handleClick = (e: any) => {
|
|
289
|
+
if (!editMode || !onSelect) return;
|
|
290
|
+
e.stopPropagation();
|
|
291
|
+
|
|
292
|
+
// Get the instance index from the intersection
|
|
293
|
+
const instanceId = e.instanceId;
|
|
294
|
+
if (instanceId !== undefined && group.instances[instanceId]) {
|
|
295
|
+
onSelect(group.instances[instanceId].id);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// Add key to force remount when instance count changes significantly (helps with cleanup)
|
|
300
|
+
const rigidBodyKey = `rb_${modelKey}_${group.physicsType}_${group.instances.length}`;
|
|
301
|
+
|
|
255
302
|
return (
|
|
256
303
|
<InstancedRigidBodies
|
|
304
|
+
key={rigidBodyKey}
|
|
305
|
+
ref={rigidBodiesRef}
|
|
257
306
|
instances={instances}
|
|
258
307
|
colliders={colliders}
|
|
259
308
|
type={group.physicsType as 'dynamic' | 'fixed'}
|
|
@@ -269,6 +318,7 @@ function InstancedRigidGroup({
|
|
|
269
318
|
castShadow
|
|
270
319
|
receiveShadow
|
|
271
320
|
frustumCulled={false}
|
|
321
|
+
onClick={editMode ? handleClick : undefined}
|
|
272
322
|
/>
|
|
273
323
|
);
|
|
274
324
|
})}
|
|
@@ -368,6 +418,12 @@ function InstanceGroupItem({
|
|
|
368
418
|
}
|
|
369
419
|
|
|
370
420
|
|
|
421
|
+
// Hook to check if an instance exists
|
|
422
|
+
export function useInstanceCheck(id: string): boolean {
|
|
423
|
+
const ctx = useContext(GameInstanceContext);
|
|
424
|
+
return ctx?.hasInstance(id) ?? false;
|
|
425
|
+
}
|
|
426
|
+
|
|
371
427
|
// GameInstance component: registers an instance for batch rendering (renders nothing itself)
|
|
372
428
|
export const GameInstance = React.forwardRef<Group, {
|
|
373
429
|
id: string;
|
|
@@ -395,7 +451,7 @@ export const GameInstance = React.forwardRef<Group, {
|
|
|
395
451
|
rotation,
|
|
396
452
|
scale,
|
|
397
453
|
physics,
|
|
398
|
-
}), [id, modelUrl, position, rotation, scale, physics]);
|
|
454
|
+
}), [id, modelUrl, JSON.stringify(position), JSON.stringify(rotation), JSON.stringify(scale), JSON.stringify(physics)]);
|
|
399
455
|
|
|
400
456
|
useEffect(() => {
|
|
401
457
|
if (!addInstance || !removeInstance) return;
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import GameCanvas from "../../shared/GameCanvas";
|
|
4
|
-
import { useState, useRef, useEffect } from "react";
|
|
4
|
+
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";
|
|
5
5
|
import { Prefab } from "./types";
|
|
6
|
-
import PrefabRoot from "./PrefabRoot";
|
|
6
|
+
import PrefabRoot, { PrefabRootRef } from "./PrefabRoot";
|
|
7
7
|
import { Physics } from "@react-three/rapier";
|
|
8
8
|
import EditorUI from "./EditorUI";
|
|
9
9
|
import { base, toolbar } from "./styles";
|
|
10
|
+
import { EditorContext } from "./EditorContext";
|
|
11
|
+
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
|
|
12
|
+
import { Group } from "three";
|
|
13
|
+
|
|
14
|
+
export interface PrefabEditorRef {
|
|
15
|
+
screenshot: () => void;
|
|
16
|
+
exportGLB: () => void;
|
|
17
|
+
prefab: Prefab;
|
|
18
|
+
setPrefab: (prefab: Prefab) => void;
|
|
19
|
+
rootRef: React.RefObject<PrefabRootRef | null>;
|
|
20
|
+
}
|
|
10
21
|
|
|
11
22
|
const DEFAULT_PREFAB: Prefab = {
|
|
12
23
|
id: "prefab-default",
|
|
@@ -22,20 +33,23 @@ const DEFAULT_PREFAB: Prefab = {
|
|
|
22
33
|
}
|
|
23
34
|
};
|
|
24
35
|
|
|
25
|
-
const PrefabEditor =
|
|
36
|
+
const PrefabEditor = forwardRef<PrefabEditorRef, {
|
|
26
37
|
basePath?: string;
|
|
27
38
|
initialPrefab?: Prefab;
|
|
28
39
|
onPrefabChange?: (prefab: Prefab) => void;
|
|
29
40
|
children?: React.ReactNode;
|
|
30
|
-
}) => {
|
|
41
|
+
}>(({ basePath, initialPrefab, onPrefabChange, children }, ref) => {
|
|
31
42
|
const [editMode, setEditMode] = useState(true);
|
|
32
43
|
const [loadedPrefab, setLoadedPrefab] = useState<Prefab>(initialPrefab ?? DEFAULT_PREFAB);
|
|
33
44
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
34
45
|
const [transformMode, setTransformMode] = useState<"translate" | "rotate" | "scale">("translate");
|
|
46
|
+
const [snapResolution, setSnapResolution] = useState(0);
|
|
35
47
|
const [history, setHistory] = useState<Prefab[]>([loadedPrefab]);
|
|
36
48
|
const [historyIndex, setHistoryIndex] = useState(0);
|
|
37
49
|
const throttleRef = useRef<NodeJS.Timeout | null>(null);
|
|
38
50
|
const lastDataRef = useRef(JSON.stringify(loadedPrefab));
|
|
51
|
+
const prefabRootRef = useRef<PrefabRootRef>(null);
|
|
52
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
39
53
|
|
|
40
54
|
useEffect(() => {
|
|
41
55
|
if (initialPrefab) setLoadedPrefab(initialPrefab);
|
|
@@ -84,29 +98,76 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: {
|
|
|
84
98
|
return () => { if (throttleRef.current) clearTimeout(throttleRef.current); };
|
|
85
99
|
}, [loadedPrefab]);
|
|
86
100
|
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
const handleScreenshot = () => {
|
|
102
|
+
const canvas = canvasRef.current;
|
|
103
|
+
if (!canvas) return;
|
|
104
|
+
|
|
105
|
+
canvas.toBlob((blob) => {
|
|
106
|
+
if (!blob) return;
|
|
107
|
+
const url = URL.createObjectURL(blob);
|
|
108
|
+
const a = document.createElement('a');
|
|
109
|
+
a.href = url;
|
|
110
|
+
a.download = `${loadedPrefab.name || 'screenshot'}.png`;
|
|
111
|
+
a.click();
|
|
112
|
+
URL.revokeObjectURL(url);
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleExportGLB = () => {
|
|
117
|
+
const sceneRoot = prefabRootRef.current?.root;
|
|
118
|
+
if (!sceneRoot) return;
|
|
119
|
+
|
|
120
|
+
const exporter = new GLTFExporter();
|
|
121
|
+
exporter.parse(
|
|
122
|
+
sceneRoot,
|
|
123
|
+
(result) => {
|
|
124
|
+
const blob = new Blob([result as ArrayBuffer], { type: 'application/octet-stream' });
|
|
125
|
+
const url = URL.createObjectURL(blob);
|
|
126
|
+
const a = document.createElement('a');
|
|
127
|
+
a.href = url;
|
|
128
|
+
a.download = `${loadedPrefab.name || 'scene'}.glb`;
|
|
129
|
+
a.click();
|
|
130
|
+
URL.revokeObjectURL(url);
|
|
131
|
+
},
|
|
132
|
+
(error) => {
|
|
133
|
+
console.error('Error exporting GLB:', error);
|
|
134
|
+
},
|
|
135
|
+
{ binary: true }
|
|
136
|
+
);
|
|
96
137
|
};
|
|
97
138
|
|
|
98
|
-
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const canvas = document.querySelector('canvas');
|
|
141
|
+
if (canvas) canvasRef.current = canvas;
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
useImperativeHandle(ref, () => ({
|
|
145
|
+
screenshot: handleScreenshot,
|
|
146
|
+
exportGLB: handleExportGLB,
|
|
147
|
+
prefab: loadedPrefab,
|
|
148
|
+
setPrefab: setLoadedPrefab,
|
|
149
|
+
rootRef: prefabRootRef
|
|
150
|
+
}), [loadedPrefab]);
|
|
151
|
+
|
|
152
|
+
return <EditorContext.Provider value={{
|
|
153
|
+
transformMode,
|
|
154
|
+
setTransformMode,
|
|
155
|
+
snapResolution,
|
|
156
|
+
setSnapResolution,
|
|
157
|
+
onScreenshot: handleScreenshot,
|
|
158
|
+
onExportGLB: handleExportGLB
|
|
159
|
+
}}>
|
|
99
160
|
<GameCanvas>
|
|
100
|
-
<Physics paused={editMode}>
|
|
161
|
+
<Physics debug={editMode} paused={editMode}>
|
|
101
162
|
<ambientLight intensity={1.5} />
|
|
102
163
|
<gridHelper args={[10, 10]} position={[0, -1, 0]} />
|
|
103
164
|
<PrefabRoot
|
|
165
|
+
ref={prefabRootRef}
|
|
104
166
|
data={loadedPrefab}
|
|
105
167
|
editMode={editMode}
|
|
106
168
|
onPrefabChange={updatePrefab}
|
|
107
169
|
selectedId={selectedId}
|
|
108
170
|
onSelect={setSelectedId}
|
|
109
|
-
transformMode={transformMode}
|
|
110
171
|
basePath={basePath}
|
|
111
172
|
/>
|
|
112
173
|
{children}
|
|
@@ -123,47 +184,15 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: {
|
|
|
123
184
|
setPrefabData={updatePrefab}
|
|
124
185
|
selectedId={selectedId}
|
|
125
186
|
setSelectedId={setSelectedId}
|
|
126
|
-
transformMode={transformMode}
|
|
127
|
-
setTransformMode={setTransformMode}
|
|
128
187
|
basePath={basePath}
|
|
129
|
-
onSave={() => saveJson(loadedPrefab, "prefab")}
|
|
130
|
-
onLoad={handleLoad}
|
|
131
188
|
onUndo={undo}
|
|
132
189
|
onRedo={redo}
|
|
133
190
|
canUndo={historyIndex > 0}
|
|
134
191
|
canRedo={historyIndex < history.length - 1}
|
|
135
192
|
/>}
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const saveJson = (data: Prefab, filename: string) => {
|
|
141
|
-
const a = document.createElement('a');
|
|
142
|
-
a.href = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
|
|
143
|
-
a.download = `${filename || 'prefab'}.json`;
|
|
144
|
-
a.click();
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
const loadJson = () => new Promise<Prefab | undefined>(resolve => {
|
|
148
|
-
const input = document.createElement('input');
|
|
149
|
-
input.type = 'file';
|
|
150
|
-
input.accept = '.json,application/json';
|
|
151
|
-
input.onchange = e => {
|
|
152
|
-
const file = (e.target as HTMLInputElement).files?.[0];
|
|
153
|
-
if (!file) return resolve(undefined);
|
|
154
|
-
const reader = new FileReader();
|
|
155
|
-
reader.onload = e => {
|
|
156
|
-
try {
|
|
157
|
-
const text = e.target?.result;
|
|
158
|
-
if (typeof text === 'string') resolve(JSON.parse(text) as Prefab);
|
|
159
|
-
} catch (err) {
|
|
160
|
-
console.error('Error parsing prefab JSON:', err);
|
|
161
|
-
resolve(undefined);
|
|
162
|
-
}
|
|
163
|
-
};
|
|
164
|
-
reader.readAsText(file);
|
|
165
|
-
};
|
|
166
|
-
input.click();
|
|
193
|
+
</EditorContext.Provider>
|
|
167
194
|
});
|
|
168
195
|
|
|
196
|
+
PrefabEditor.displayName = "PrefabEditor";
|
|
197
|
+
|
|
169
198
|
export default PrefabEditor;
|