react-three-game 0.0.16 → 0.0.18
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/README.md +88 -113
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/tools/prefabeditor/EditorTree.js +27 -15
- package/dist/tools/prefabeditor/EditorUI.js +2 -8
- package/dist/tools/prefabeditor/InstanceProvider.d.ts +4 -4
- package/dist/tools/prefabeditor/InstanceProvider.js +21 -13
- package/dist/tools/prefabeditor/PrefabEditor.js +128 -59
- package/dist/tools/prefabeditor/PrefabRoot.js +51 -33
- package/dist/tools/prefabeditor/components/ComponentRegistry.d.ts +2 -0
- package/dist/tools/prefabeditor/components/DirectionalLightComponent.d.ts +3 -0
- package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +114 -0
- package/dist/tools/prefabeditor/components/ModelComponent.js +12 -4
- package/dist/tools/prefabeditor/components/RotatorComponent.d.ts +3 -0
- package/dist/tools/prefabeditor/components/RotatorComponent.js +42 -0
- package/dist/tools/prefabeditor/components/SpotLightComponent.js +10 -5
- package/dist/tools/prefabeditor/components/TransformComponent.js +28 -3
- package/dist/tools/prefabeditor/components/index.js +2 -0
- package/dist/tools/prefabeditor/hooks/useModelLoader.d.ts +10 -0
- package/dist/tools/prefabeditor/hooks/useModelLoader.js +40 -0
- package/package.json +8 -8
- package/src/index.ts +4 -0
- package/src/tools/prefabeditor/EditorTree.tsx +39 -16
- package/src/tools/prefabeditor/EditorUI.tsx +2 -27
- package/src/tools/prefabeditor/InstanceProvider.tsx +43 -32
- package/src/tools/prefabeditor/PrefabEditor.tsx +202 -86
- package/src/tools/prefabeditor/PrefabRoot.tsx +62 -54
- package/src/tools/prefabeditor/components/ComponentRegistry.ts +7 -1
- package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +332 -0
- package/src/tools/prefabeditor/components/ModelComponent.tsx +14 -4
- package/src/tools/prefabeditor/components/SpotLightComponent.tsx +27 -7
- package/src/tools/prefabeditor/components/TransformComponent.tsx +69 -16
- package/src/tools/prefabeditor/components/index.ts +2 -0
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
function TransformComponentEditor({ component, onUpdate }) {
|
|
3
|
-
|
|
2
|
+
function TransformComponentEditor({ component, onUpdate, transformMode, setTransformMode }) {
|
|
3
|
+
const s = {
|
|
4
|
+
button: {
|
|
5
|
+
padding: '2px 6px',
|
|
6
|
+
background: 'transparent',
|
|
7
|
+
color: 'rgba(255,255,255,0.9)',
|
|
8
|
+
border: '1px solid rgba(255,255,255,0.14)',
|
|
9
|
+
borderRadius: 4,
|
|
10
|
+
cursor: 'pointer',
|
|
11
|
+
font: 'inherit',
|
|
12
|
+
},
|
|
13
|
+
buttonActive: {
|
|
14
|
+
background: 'rgba(255,255,255,0.10)',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
return _jsxs("div", { className: "flex flex-col", children: [transformMode && setTransformMode && (_jsxs("div", { className: "mb-2", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1", children: "Transform Mode" }), _jsx("div", { style: { display: 'flex', gap: 6 }, children: ["translate", "rotate", "scale"].map(mode => (_jsx("button", { onClick: () => setTransformMode(mode), style: Object.assign(Object.assign(Object.assign({}, s.button), { flex: 1 }), (transformMode === mode ? s.buttonActive : {})), onPointerEnter: (e) => {
|
|
18
|
+
if (transformMode !== mode)
|
|
19
|
+
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
|
|
20
|
+
}, onPointerLeave: (e) => {
|
|
21
|
+
if (transformMode !== mode)
|
|
22
|
+
e.currentTarget.style.background = 'transparent';
|
|
23
|
+
}, children: mode }, mode))) })] })), _jsx(Vector3Input, { label: "Position", value: component.properties.position, onChange: v => onUpdate({ position: v }) }), _jsx(Vector3Input, { label: "Rotation", value: component.properties.rotation, onChange: v => onUpdate({ rotation: v }) }), _jsx(Vector3Input, { label: "Scale", value: component.properties.scale, onChange: v => onUpdate({ scale: v }) })] });
|
|
4
24
|
}
|
|
5
25
|
const TransformComponent = {
|
|
6
26
|
name: 'Transform',
|
|
@@ -18,5 +38,10 @@ export function Vector3Input({ label, value, onChange }) {
|
|
|
18
38
|
newValue[index] = parseFloat(val) || 0;
|
|
19
39
|
onChange(newValue);
|
|
20
40
|
};
|
|
21
|
-
|
|
41
|
+
const axes = [
|
|
42
|
+
{ key: 'x', color: 'red', index: 0 },
|
|
43
|
+
{ key: 'y', color: 'green', index: 1 },
|
|
44
|
+
{ key: 'z', color: 'blue', index: 2 }
|
|
45
|
+
];
|
|
46
|
+
return _jsxs("div", { className: "mb-2", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1", children: label }), _jsx("div", { className: "flex gap-1", children: axes.map(({ key, color, index }) => (_jsxs("div", { className: "flex-1 flex items-center gap-1 bg-black/30 border border-cyan-500/20 rounded px-1.5 py-1 min-h-[32px]", children: [_jsx("span", { className: `text-xs font-bold text-${color}-400 w-3`, children: key.toUpperCase() }), _jsx("input", { className: "flex-1 bg-transparent text-xs text-cyan-200 font-mono outline-none w-full min-w-0", type: "number", step: "0.1", value: value[index].toFixed(2), onChange: e => handleChange(index, e.target.value), onFocus: e => e.target.select() })] }, key))) })] });
|
|
22
47
|
}
|
|
@@ -3,6 +3,7 @@ import TransformComponent from './TransformComponent';
|
|
|
3
3
|
import MaterialComponent from './MaterialComponent';
|
|
4
4
|
import PhysicsComponent from './PhysicsComponent';
|
|
5
5
|
import SpotLightComponent from './SpotLightComponent';
|
|
6
|
+
import DirectionalLightComponent from './DirectionalLightComponent';
|
|
6
7
|
import ModelComponent from './ModelComponent';
|
|
7
8
|
export default [
|
|
8
9
|
GeometryComponent,
|
|
@@ -10,5 +11,6 @@ export default [
|
|
|
10
11
|
MaterialComponent,
|
|
11
12
|
PhysicsComponent,
|
|
12
13
|
SpotLightComponent,
|
|
14
|
+
DirectionalLightComponent,
|
|
13
15
|
ModelComponent
|
|
14
16
|
];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Object3D } from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* Hook to load a model (GLB/GLTF/FBX) using drei's optimized loaders
|
|
4
|
+
* Returns the loaded model with proper caching and suspense support
|
|
5
|
+
*/
|
|
6
|
+
export declare function useModel(filename: string | undefined): Object3D | null;
|
|
7
|
+
/**
|
|
8
|
+
* Preload a model to avoid suspense boundaries during runtime
|
|
9
|
+
*/
|
|
10
|
+
export declare function preloadModel(filename: string): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useGLTF, useFBX } from '@react-three/drei';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* Hook to load a model (GLB/GLTF/FBX) using drei's optimized loaders
|
|
5
|
+
* Returns the loaded model with proper caching and suspense support
|
|
6
|
+
*/
|
|
7
|
+
export function useModel(filename) {
|
|
8
|
+
const isFBX = filename === null || filename === void 0 ? void 0 : filename.toLowerCase().endsWith('.fbx');
|
|
9
|
+
const isGLTF = (filename === null || filename === void 0 ? void 0 : filename.toLowerCase().endsWith('.glb')) || (filename === null || filename === void 0 ? void 0 : filename.toLowerCase().endsWith('.gltf'));
|
|
10
|
+
// Normalize path (ensure leading slash)
|
|
11
|
+
const normalizedPath = useMemo(() => {
|
|
12
|
+
if (!filename)
|
|
13
|
+
return '';
|
|
14
|
+
return filename.startsWith('/') ? filename : `/${filename}`;
|
|
15
|
+
}, [filename]);
|
|
16
|
+
// Load models using drei hooks (these handle caching automatically)
|
|
17
|
+
const gltf = useGLTF(isGLTF && normalizedPath ? normalizedPath : '', true);
|
|
18
|
+
const fbx = useFBX(isFBX && normalizedPath ? normalizedPath : '');
|
|
19
|
+
// Return the appropriate model
|
|
20
|
+
if (!filename)
|
|
21
|
+
return null;
|
|
22
|
+
if (isGLTF)
|
|
23
|
+
return gltf.scene;
|
|
24
|
+
if (isFBX)
|
|
25
|
+
return fbx;
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Preload a model to avoid suspense boundaries during runtime
|
|
30
|
+
*/
|
|
31
|
+
export function preloadModel(filename) {
|
|
32
|
+
const normalizedPath = filename.startsWith('/') ? filename : `/${filename}`;
|
|
33
|
+
const isFBX = filename.toLowerCase().endsWith('.fbx');
|
|
34
|
+
if (isFBX) {
|
|
35
|
+
useFBX.preload(normalizedPath);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
useGLTF.preload(normalizedPath);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-three-game",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"description": "Batteries included React Three Fiber game engine",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
"type": "module",
|
|
18
18
|
"workspaces": ["docs"],
|
|
19
19
|
"peerDependencies": {
|
|
20
|
-
"@react-three/fiber": "
|
|
21
|
-
"@react-three/drei": "
|
|
22
|
-
"@react-three/rapier": "
|
|
23
|
-
"react": "
|
|
24
|
-
"react-dom": "
|
|
25
|
-
"three": "
|
|
20
|
+
"@react-three/fiber": ">=9.0.0",
|
|
21
|
+
"@react-three/drei": ">=10.0.0",
|
|
22
|
+
"@react-three/rapier": ">=2.0.0",
|
|
23
|
+
"react": ">=18.0.0",
|
|
24
|
+
"react-dom": ">=18.0.0",
|
|
25
|
+
"three": ">=0.182.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@react-three/drei": "^10.7.7",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"concurrently": "^9.2.1",
|
|
35
35
|
"react": "^19.2.0",
|
|
36
36
|
"react-dom": "^19.2.0",
|
|
37
|
-
"three": "^0.
|
|
37
|
+
"three": "^0.182.0",
|
|
38
38
|
"typescript": "^5.9.3"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,10 @@ export {
|
|
|
10
10
|
SharedCanvas,
|
|
11
11
|
} from './tools/assetviewer/page';
|
|
12
12
|
|
|
13
|
+
// Component Registry
|
|
14
|
+
export { registerComponent } from './tools/prefabeditor/components/ComponentRegistry';
|
|
15
|
+
export type { Component } from './tools/prefabeditor/components/ComponentRegistry';
|
|
16
|
+
|
|
13
17
|
// Helpers
|
|
14
18
|
export * from './helpers';
|
|
15
19
|
|
|
@@ -74,6 +74,17 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
74
74
|
overflow: "hidden",
|
|
75
75
|
textOverflow: "ellipsis",
|
|
76
76
|
},
|
|
77
|
+
dragHandle: {
|
|
78
|
+
width: 14,
|
|
79
|
+
height: 14,
|
|
80
|
+
display: "flex",
|
|
81
|
+
alignItems: "center",
|
|
82
|
+
justifyContent: "center",
|
|
83
|
+
marginRight: 4,
|
|
84
|
+
opacity: 0.4,
|
|
85
|
+
cursor: "grab",
|
|
86
|
+
fontSize: 10,
|
|
87
|
+
},
|
|
77
88
|
contextMenu: {
|
|
78
89
|
position: "fixed",
|
|
79
90
|
zIndex: 50,
|
|
@@ -179,40 +190,38 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
179
190
|
|
|
180
191
|
// Drag and Drop
|
|
181
192
|
const handleDragStart = (e: React.DragEvent, id: string) => {
|
|
182
|
-
e.stopPropagation();
|
|
183
193
|
if (id === prefabData.root.id) {
|
|
184
|
-
e.preventDefault();
|
|
194
|
+
e.preventDefault();
|
|
185
195
|
return;
|
|
186
196
|
}
|
|
187
|
-
setDraggedId(id);
|
|
188
197
|
e.dataTransfer.effectAllowed = "move";
|
|
198
|
+
e.dataTransfer.setData("text/plain", id);
|
|
199
|
+
setDraggedId(id);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const handleDragEnd = () => {
|
|
203
|
+
setDraggedId(null);
|
|
189
204
|
};
|
|
190
205
|
|
|
191
206
|
const handleDragOver = (e: React.DragEvent, targetId: string) => {
|
|
192
|
-
e.preventDefault();
|
|
193
|
-
e.stopPropagation();
|
|
194
207
|
if (!draggedId || draggedId === targetId) return;
|
|
195
|
-
|
|
196
|
-
// Check for cycles: target cannot be a descendant of dragged node
|
|
197
208
|
const draggedNode = findNode(prefabData.root, draggedId);
|
|
198
209
|
if (draggedNode && findNode(draggedNode, targetId)) return;
|
|
199
210
|
|
|
211
|
+
e.preventDefault();
|
|
200
212
|
e.dataTransfer.dropEffect = "move";
|
|
201
213
|
};
|
|
202
214
|
|
|
203
215
|
const handleDrop = (e: React.DragEvent, targetId: string) => {
|
|
204
|
-
e.preventDefault();
|
|
205
|
-
e.stopPropagation();
|
|
206
216
|
if (!draggedId || draggedId === targetId) return;
|
|
207
217
|
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
|
|
208
220
|
setPrefabData(prev => {
|
|
209
221
|
const newRoot = JSON.parse(JSON.stringify(prev.root));
|
|
222
|
+
const draggedNode = findNode(newRoot, draggedId);
|
|
223
|
+
if (draggedNode && findNode(draggedNode, targetId)) return prev;
|
|
210
224
|
|
|
211
|
-
// Check cycle again on the fresh tree
|
|
212
|
-
const draggedNodeRef = findNode(newRoot, draggedId);
|
|
213
|
-
if (draggedNodeRef && findNode(draggedNodeRef, targetId)) return prev;
|
|
214
|
-
|
|
215
|
-
// Remove from old parent
|
|
216
225
|
const parent = findParent(newRoot, draggedId);
|
|
217
226
|
if (!parent) return prev;
|
|
218
227
|
|
|
@@ -221,7 +230,6 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
221
230
|
|
|
222
231
|
parent.children = parent.children!.filter(c => c.id !== draggedId);
|
|
223
232
|
|
|
224
|
-
// Add to new parent
|
|
225
233
|
const target = findNode(newRoot, targetId);
|
|
226
234
|
if (target) {
|
|
227
235
|
target.children = target.children || [];
|
|
@@ -247,11 +255,13 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
247
255
|
...styles.row,
|
|
248
256
|
...(isSelected ? styles.rowSelected : null),
|
|
249
257
|
paddingLeft: `${depth * 10 + 6}px`,
|
|
258
|
+
cursor: node.id !== prefabData.root.id ? "grab" : "pointer",
|
|
250
259
|
}}
|
|
260
|
+
draggable={node.id !== prefabData.root.id}
|
|
251
261
|
onClick={(e) => { e.stopPropagation(); setSelectedId(node.id); }}
|
|
252
262
|
onContextMenu={(e) => handleContextMenu(e, node.id)}
|
|
253
|
-
draggable={node.id !== prefabData.root.id}
|
|
254
263
|
onDragStart={(e) => handleDragStart(e, node.id)}
|
|
264
|
+
onDragEnd={handleDragEnd}
|
|
255
265
|
onDragOver={(e) => handleDragOver(e, node.id)}
|
|
256
266
|
onDrop={(e) => handleDrop(e, node.id)}
|
|
257
267
|
onPointerEnter={(e) => {
|
|
@@ -276,6 +286,19 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
276
286
|
>
|
|
277
287
|
{isCollapsed ? '▶' : '▼'}
|
|
278
288
|
</span>
|
|
289
|
+
{node.id !== prefabData.root.id && (
|
|
290
|
+
<span
|
|
291
|
+
style={styles.dragHandle}
|
|
292
|
+
onPointerEnter={(e) => {
|
|
293
|
+
(e.currentTarget as HTMLSpanElement).style.opacity = "0.9";
|
|
294
|
+
}}
|
|
295
|
+
onPointerLeave={(e) => {
|
|
296
|
+
(e.currentTarget as HTMLSpanElement).style.opacity = "0.4";
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
⋮⋮
|
|
300
|
+
</span>
|
|
301
|
+
)}
|
|
279
302
|
<span style={styles.idText}>
|
|
280
303
|
{node.id}
|
|
281
304
|
</span>
|
|
@@ -256,33 +256,6 @@ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransfo
|
|
|
256
256
|
</button>
|
|
257
257
|
</div>
|
|
258
258
|
|
|
259
|
-
<div style={s.section}>
|
|
260
|
-
<label style={s.label}>Mode</label>
|
|
261
|
-
<div style={{ display: 'flex', gap: 6 }}>
|
|
262
|
-
{["translate", "rotate", "scale"].map(mode => (
|
|
263
|
-
<button
|
|
264
|
-
key={mode}
|
|
265
|
-
onClick={() => setTransformMode(mode as any)}
|
|
266
|
-
style={{
|
|
267
|
-
...s.button,
|
|
268
|
-
flex: 1,
|
|
269
|
-
...(transformMode === mode ? s.buttonActive : null),
|
|
270
|
-
}}
|
|
271
|
-
onPointerEnter={(e) => {
|
|
272
|
-
if (transformMode !== mode) (e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,255,255,0.08)';
|
|
273
|
-
}}
|
|
274
|
-
onPointerLeave={(e) => {
|
|
275
|
-
if (transformMode !== mode) (e.currentTarget as HTMLButtonElement).style.background = 'transparent';
|
|
276
|
-
}}
|
|
277
|
-
>
|
|
278
|
-
{mode[0].toUpperCase()}
|
|
279
|
-
</button>
|
|
280
|
-
))}
|
|
281
|
-
</div>
|
|
282
|
-
</div>
|
|
283
|
-
|
|
284
|
-
{/* Components (legacy renderer removed) */}
|
|
285
|
-
|
|
286
259
|
{node.components && Object.entries(node.components).map(([key, comp]: [string, any]) => {
|
|
287
260
|
if (!comp) return null;
|
|
288
261
|
const componentDef = ALL_COMPONENTS[comp.type];
|
|
@@ -322,6 +295,8 @@ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransfo
|
|
|
322
295
|
}
|
|
323
296
|
}))}
|
|
324
297
|
basePath={basePath}
|
|
298
|
+
transformMode={transformMode}
|
|
299
|
+
setTransformMode={setTransformMode}
|
|
325
300
|
/>
|
|
326
301
|
) : null}
|
|
327
302
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { Merged } from '@react-three/drei';
|
|
3
|
-
import * as THREE from 'three';
|
|
4
3
|
import { InstancedRigidBodies } from "@react-three/rapier";
|
|
4
|
+
import { Mesh, Matrix4, Object3D, Group } from "three";
|
|
5
5
|
|
|
6
6
|
// --- Types ---
|
|
7
7
|
export type InstanceData = {
|
|
@@ -13,7 +13,8 @@ export type InstanceData = {
|
|
|
13
13
|
physics?: { type: 'dynamic' | 'fixed' };
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Helper functions for comparison
|
|
17
|
+
function arrayEquals(a: number[], b: number[]): boolean {
|
|
17
18
|
if (a === b) return true;
|
|
18
19
|
if (a.length !== b.length) return false;
|
|
19
20
|
for (let i = 0; i < a.length; i++) {
|
|
@@ -22,7 +23,7 @@ function arrayEquals(a: number[], b: number[]) {
|
|
|
22
23
|
return true;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function instanceEquals(a: InstanceData, b: InstanceData) {
|
|
26
|
+
function instanceEquals(a: InstanceData, b: InstanceData): boolean {
|
|
26
27
|
return a.id === b.id &&
|
|
27
28
|
a.meshPath === b.meshPath &&
|
|
28
29
|
arrayEquals(a.position, b.position) &&
|
|
@@ -36,7 +37,7 @@ type GameInstanceContextType = {
|
|
|
36
37
|
addInstance: (instance: InstanceData) => void;
|
|
37
38
|
removeInstance: (id: string) => void;
|
|
38
39
|
instances: InstanceData[];
|
|
39
|
-
meshes: Record<string,
|
|
40
|
+
meshes: Record<string, Mesh>;
|
|
40
41
|
instancesMap?: Record<string, React.ComponentType<any>>;
|
|
41
42
|
modelParts?: Record<string, number>;
|
|
42
43
|
};
|
|
@@ -44,13 +45,14 @@ const GameInstanceContext = createContext<GameInstanceContextType | null>(null);
|
|
|
44
45
|
|
|
45
46
|
export function GameInstanceProvider({
|
|
46
47
|
children,
|
|
47
|
-
models
|
|
48
|
-
|
|
48
|
+
models,
|
|
49
|
+
onSelect,
|
|
50
|
+
registerRef
|
|
49
51
|
}: {
|
|
50
52
|
children: React.ReactNode,
|
|
51
|
-
models: { [filename: string]:
|
|
53
|
+
models: { [filename: string]: Object3D },
|
|
52
54
|
onSelect?: (id: string | null) => void,
|
|
53
|
-
registerRef?: (id: string, obj:
|
|
55
|
+
registerRef?: (id: string, obj: Object3D | null) => void,
|
|
54
56
|
}) {
|
|
55
57
|
const [instances, setInstances] = useState<InstanceData[]>([]);
|
|
56
58
|
|
|
@@ -58,6 +60,7 @@ export function GameInstanceProvider({
|
|
|
58
60
|
setInstances(prev => {
|
|
59
61
|
const idx = prev.findIndex(i => i.id === instance.id);
|
|
60
62
|
if (idx !== -1) {
|
|
63
|
+
// Update existing if changed
|
|
61
64
|
if (instanceEquals(prev[idx], instance)) {
|
|
62
65
|
return prev;
|
|
63
66
|
}
|
|
@@ -65,6 +68,7 @@ export function GameInstanceProvider({
|
|
|
65
68
|
copy[idx] = instance;
|
|
66
69
|
return copy;
|
|
67
70
|
}
|
|
71
|
+
// Add new
|
|
68
72
|
return [...prev, instance];
|
|
69
73
|
});
|
|
70
74
|
}, []);
|
|
@@ -76,27 +80,26 @@ export function GameInstanceProvider({
|
|
|
76
80
|
});
|
|
77
81
|
}, []);
|
|
78
82
|
|
|
79
|
-
// Flatten all model meshes once
|
|
83
|
+
// Flatten all model meshes once (models → flat mesh parts)
|
|
80
84
|
const { flatMeshes, modelParts } = useMemo(() => {
|
|
81
|
-
const flatMeshes: Record<string,
|
|
85
|
+
const flatMeshes: Record<string, Mesh> = {};
|
|
82
86
|
const modelParts: Record<string, number> = {};
|
|
83
87
|
|
|
84
88
|
Object.entries(models).forEach(([modelKey, model]) => {
|
|
85
89
|
const root = model;
|
|
86
90
|
root.updateWorldMatrix(false, true);
|
|
87
|
-
const rootInverse = new
|
|
91
|
+
const rootInverse = new Matrix4().copy(root.matrixWorld).invert();
|
|
88
92
|
|
|
89
93
|
let partIndex = 0;
|
|
90
94
|
|
|
91
95
|
root.traverse((obj: any) => {
|
|
92
96
|
if (obj.isMesh) {
|
|
93
97
|
const geom = obj.geometry.clone();
|
|
94
|
-
|
|
95
98
|
const relativeTransform = obj.matrixWorld.clone().premultiply(rootInverse);
|
|
96
99
|
geom.applyMatrix4(relativeTransform);
|
|
97
100
|
|
|
98
101
|
const partKey = `${modelKey}__${partIndex}`;
|
|
99
|
-
flatMeshes[partKey] = new
|
|
102
|
+
flatMeshes[partKey] = new Mesh(geom, obj.material);
|
|
100
103
|
partIndex++;
|
|
101
104
|
}
|
|
102
105
|
});
|
|
@@ -106,7 +109,7 @@ export function GameInstanceProvider({
|
|
|
106
109
|
return { flatMeshes, modelParts };
|
|
107
110
|
}, [models]);
|
|
108
111
|
|
|
109
|
-
// Group instances by meshPath + physics type
|
|
112
|
+
// Group instances by meshPath + physics type for batch rendering
|
|
110
113
|
const grouped = useMemo(() => {
|
|
111
114
|
const groups: Record<string, { physicsType: string, instances: InstanceData[] }> = {};
|
|
112
115
|
for (const inst of instances) {
|
|
@@ -128,10 +131,10 @@ export function GameInstanceProvider({
|
|
|
128
131
|
modelParts
|
|
129
132
|
}}
|
|
130
133
|
>
|
|
131
|
-
{/*
|
|
134
|
+
{/* Render normal prefab hierarchy (non-instanced objects) */}
|
|
132
135
|
{children}
|
|
133
136
|
|
|
134
|
-
{/*
|
|
137
|
+
{/* Render physics-enabled instanced groups using InstancedRigidBodies */}
|
|
135
138
|
{Object.entries(grouped).map(([key, group]) => {
|
|
136
139
|
if (group.physicsType === 'none') return null;
|
|
137
140
|
const modelKey = group.instances[0].meshPath;
|
|
@@ -149,7 +152,7 @@ export function GameInstanceProvider({
|
|
|
149
152
|
);
|
|
150
153
|
})}
|
|
151
154
|
|
|
152
|
-
{/*
|
|
155
|
+
{/* Render non-physics instanced visuals using Merged (one per model type) */}
|
|
153
156
|
{Object.entries(grouped).map(([key, group]) => {
|
|
154
157
|
if (group.physicsType !== 'none') return null;
|
|
155
158
|
|
|
@@ -157,8 +160,8 @@ export function GameInstanceProvider({
|
|
|
157
160
|
const partCount = modelParts[modelKey] || 0;
|
|
158
161
|
if (partCount === 0) return null;
|
|
159
162
|
|
|
160
|
-
//
|
|
161
|
-
const meshesForModel: Record<string,
|
|
163
|
+
// Create mesh subset for this specific model
|
|
164
|
+
const meshesForModel: Record<string, Mesh> = {};
|
|
162
165
|
for (let i = 0; i < partCount; i++) {
|
|
163
166
|
const partKey = `${modelKey}__${i}`;
|
|
164
167
|
meshesForModel[partKey] = flatMeshes[partKey];
|
|
@@ -188,7 +191,7 @@ export function GameInstanceProvider({
|
|
|
188
191
|
);
|
|
189
192
|
}
|
|
190
193
|
|
|
191
|
-
//
|
|
194
|
+
// Render physics-enabled instances using InstancedRigidBodies
|
|
192
195
|
function InstancedRigidGroup({
|
|
193
196
|
group,
|
|
194
197
|
modelKey,
|
|
@@ -198,7 +201,7 @@ function InstancedRigidGroup({
|
|
|
198
201
|
group: { physicsType: string, instances: InstanceData[] },
|
|
199
202
|
modelKey: string,
|
|
200
203
|
partCount: number,
|
|
201
|
-
flatMeshes: Record<string,
|
|
204
|
+
flatMeshes: Record<string, Mesh>
|
|
202
205
|
}) {
|
|
203
206
|
const instances = useMemo(
|
|
204
207
|
() => group.instances.map(inst => ({
|
|
@@ -232,24 +235,33 @@ function InstancedRigidGroup({
|
|
|
232
235
|
);
|
|
233
236
|
}
|
|
234
237
|
|
|
235
|
-
//
|
|
238
|
+
// Render non-physics instances using Merged's per-instance groups
|
|
236
239
|
function NonPhysicsInstancedGroup({
|
|
237
240
|
modelKey,
|
|
238
241
|
group,
|
|
239
242
|
partCount,
|
|
240
|
-
instancesMap
|
|
241
|
-
|
|
243
|
+
instancesMap,
|
|
244
|
+
onSelect,
|
|
245
|
+
registerRef
|
|
242
246
|
}: {
|
|
243
247
|
modelKey: string;
|
|
244
248
|
group: { physicsType: string, instances: InstanceData[] };
|
|
245
249
|
partCount: number;
|
|
246
250
|
instancesMap: Record<string, React.ComponentType<any>>;
|
|
247
251
|
onSelect?: (id: string | null) => void;
|
|
248
|
-
registerRef?: (id: string, obj:
|
|
252
|
+
registerRef?: (id: string, obj: Object3D | null) => void;
|
|
249
253
|
}) {
|
|
250
254
|
const clickValid = useRef(false);
|
|
251
|
-
|
|
252
|
-
const
|
|
255
|
+
|
|
256
|
+
const handlePointerDown = (e: any) => {
|
|
257
|
+
e.stopPropagation();
|
|
258
|
+
clickValid.current = true;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const handlePointerMove = () => {
|
|
262
|
+
if (clickValid.current) clickValid.current = false;
|
|
263
|
+
};
|
|
264
|
+
|
|
253
265
|
const handlePointerUp = (e: any, id: string) => {
|
|
254
266
|
if (clickValid.current) {
|
|
255
267
|
e.stopPropagation();
|
|
@@ -263,7 +275,7 @@ function NonPhysicsInstancedGroup({
|
|
|
263
275
|
{group.instances.map(inst => (
|
|
264
276
|
<group
|
|
265
277
|
key={inst.id}
|
|
266
|
-
ref={(el) => { registerRef?.(inst.id, el as unknown as
|
|
278
|
+
ref={(el) => { registerRef?.(inst.id, el as unknown as Object3D | null); }}
|
|
267
279
|
position={inst.position}
|
|
268
280
|
rotation={inst.rotation}
|
|
269
281
|
scale={inst.scale}
|
|
@@ -283,8 +295,8 @@ function NonPhysicsInstancedGroup({
|
|
|
283
295
|
}
|
|
284
296
|
|
|
285
297
|
|
|
286
|
-
//
|
|
287
|
-
export const GameInstance = React.forwardRef<
|
|
298
|
+
// GameInstance component: registers an instance for batch rendering (renders nothing itself)
|
|
299
|
+
export const GameInstance = React.forwardRef<Group, {
|
|
288
300
|
id: string;
|
|
289
301
|
modelUrl: string;
|
|
290
302
|
position: [number, number, number];
|
|
@@ -320,7 +332,6 @@ export const GameInstance = React.forwardRef<THREE.Group, {
|
|
|
320
332
|
};
|
|
321
333
|
}, [addInstance, removeInstance, instance]);
|
|
322
334
|
|
|
323
|
-
|
|
324
|
-
// No visual here – provider will render visuals for all instances
|
|
335
|
+
// No visual rendering - provider handles all instanced visuals
|
|
325
336
|
return null;
|
|
326
337
|
});
|