react-three-game 0.0.7 → 0.0.9

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.
@@ -0,0 +1,326 @@
1
+ import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
2
+ import { Merged } from '@react-three/drei';
3
+ import * as THREE from 'three';
4
+ import { InstancedRigidBodies } from "@react-three/rapier";
5
+
6
+ // --- Types ---
7
+ export type InstanceData = {
8
+ id: string;
9
+ position: [number, number, number];
10
+ rotation: [number, number, number];
11
+ scale: [number, number, number];
12
+ meshPath: string;
13
+ physics?: { type: 'dynamic' | 'fixed' };
14
+ };
15
+
16
+ function arrayEquals(a: number[], b: number[]) {
17
+ if (a === b) return true;
18
+ if (a.length !== b.length) return false;
19
+ for (let i = 0; i < a.length; i++) {
20
+ if (a[i] !== b[i]) return false;
21
+ }
22
+ return true;
23
+ }
24
+
25
+ function instanceEquals(a: InstanceData, b: InstanceData) {
26
+ return a.id === b.id &&
27
+ a.meshPath === b.meshPath &&
28
+ arrayEquals(a.position, b.position) &&
29
+ arrayEquals(a.rotation, b.rotation) &&
30
+ arrayEquals(a.scale, b.scale) &&
31
+ a.physics?.type === b.physics?.type;
32
+ }
33
+
34
+ // --- Context ---
35
+ type GameInstanceContextType = {
36
+ addInstance: (instance: InstanceData) => void;
37
+ removeInstance: (id: string) => void;
38
+ instances: InstanceData[];
39
+ meshes: Record<string, THREE.Mesh>;
40
+ instancesMap?: Record<string, React.ComponentType<any>>;
41
+ modelParts?: Record<string, number>;
42
+ };
43
+ const GameInstanceContext = createContext<GameInstanceContextType | null>(null);
44
+
45
+ export function GameInstanceProvider({
46
+ children,
47
+ models
48
+ , onSelect, registerRef
49
+ }: {
50
+ children: React.ReactNode,
51
+ models: { [filename: string]: THREE.Object3D },
52
+ onSelect?: (id: string | null) => void,
53
+ registerRef?: (id: string, obj: THREE.Object3D | null) => void,
54
+ }) {
55
+ const [instances, setInstances] = useState<InstanceData[]>([]);
56
+
57
+ const addInstance = useCallback((instance: InstanceData) => {
58
+ setInstances(prev => {
59
+ const idx = prev.findIndex(i => i.id === instance.id);
60
+ if (idx !== -1) {
61
+ if (instanceEquals(prev[idx], instance)) {
62
+ return prev;
63
+ }
64
+ const copy = [...prev];
65
+ copy[idx] = instance;
66
+ return copy;
67
+ }
68
+ return [...prev, instance];
69
+ });
70
+ }, []);
71
+
72
+ const removeInstance = useCallback((id: string) => {
73
+ setInstances(prev => {
74
+ if (!prev.find(i => i.id === id)) return prev;
75
+ return prev.filter(i => i.id !== id);
76
+ });
77
+ }, []);
78
+
79
+ // Flatten all model meshes once
80
+ const { flatMeshes, modelParts } = useMemo(() => {
81
+ const flatMeshes: Record<string, THREE.Mesh> = {};
82
+ const modelParts: Record<string, number> = {};
83
+
84
+ Object.entries(models).forEach(([modelKey, model]) => {
85
+ const root = model;
86
+ root.updateWorldMatrix(false, true);
87
+ const rootInverse = new THREE.Matrix4().copy(root.matrixWorld).invert();
88
+
89
+ let partIndex = 0;
90
+
91
+ root.traverse((obj: any) => {
92
+ if (obj.isMesh) {
93
+ const geom = obj.geometry.clone();
94
+
95
+ const relativeTransform = obj.matrixWorld.clone().premultiply(rootInverse);
96
+ geom.applyMatrix4(relativeTransform);
97
+
98
+ const partKey = `${modelKey}__${partIndex}`;
99
+ flatMeshes[partKey] = new THREE.Mesh(geom, obj.material);
100
+ partIndex++;
101
+ }
102
+ });
103
+ modelParts[modelKey] = partIndex;
104
+ });
105
+
106
+ return { flatMeshes, modelParts };
107
+ }, [models]);
108
+
109
+ // Group instances by meshPath + physics type
110
+ const grouped = useMemo(() => {
111
+ const groups: Record<string, { physicsType: string, instances: InstanceData[] }> = {};
112
+ for (const inst of instances) {
113
+ const type = inst.physics?.type || 'none';
114
+ const key = `${inst.meshPath}__${type}`;
115
+ if (!groups[key]) groups[key] = { physicsType: type, instances: [] };
116
+ groups[key].instances.push(inst);
117
+ }
118
+ return groups;
119
+ }, [instances]);
120
+
121
+ return (
122
+ <GameInstanceContext.Provider
123
+ value={{
124
+ addInstance,
125
+ removeInstance,
126
+ instances,
127
+ meshes: flatMeshes,
128
+ modelParts
129
+ }}
130
+ >
131
+ {/* 1) Normal prefab hierarchy: NOT inside any <Merged> */}
132
+ {children}
133
+
134
+ {/* 2) Physics instanced groups: no <Merged>, just InstancedRigidBodies */}
135
+ {Object.entries(grouped).map(([key, group]) => {
136
+ if (group.physicsType === 'none') return null;
137
+ const modelKey = group.instances[0].meshPath;
138
+ const partCount = modelParts[modelKey] || 0;
139
+ if (partCount === 0) return null;
140
+
141
+ return (
142
+ <InstancedRigidGroup
143
+ key={key}
144
+ group={group}
145
+ modelKey={modelKey}
146
+ partCount={partCount}
147
+ flatMeshes={flatMeshes}
148
+ />
149
+ );
150
+ })}
151
+
152
+ {/* 3) Non-physics instanced visuals: own <Merged> per model */}
153
+ {Object.entries(grouped).map(([key, group]) => {
154
+ if (group.physicsType !== 'none') return null;
155
+
156
+ const modelKey = group.instances[0].meshPath;
157
+ const partCount = modelParts[modelKey] || 0;
158
+ if (partCount === 0) return null;
159
+
160
+ // Restrict meshes to just this model's parts for this Merged
161
+ const meshesForModel: Record<string, THREE.Mesh> = {};
162
+ for (let i = 0; i < partCount; i++) {
163
+ const partKey = `${modelKey}__${i}`;
164
+ meshesForModel[partKey] = flatMeshes[partKey];
165
+ }
166
+
167
+ return (
168
+ <Merged
169
+ key={key}
170
+ meshes={meshesForModel}
171
+ castShadow
172
+ receiveShadow
173
+ >
174
+ {(instancesMap: any) => (
175
+ <NonPhysicsInstancedGroup
176
+ modelKey={modelKey}
177
+ group={group}
178
+ partCount={partCount}
179
+ instancesMap={instancesMap}
180
+ onSelect={onSelect}
181
+ registerRef={registerRef}
182
+ />
183
+ )}
184
+ </Merged>
185
+ );
186
+ })}
187
+ </GameInstanceContext.Provider>
188
+ );
189
+ }
190
+
191
+ // Physics instancing stays the same
192
+ function InstancedRigidGroup({
193
+ group,
194
+ modelKey,
195
+ partCount,
196
+ flatMeshes
197
+ }: {
198
+ group: { physicsType: string, instances: InstanceData[] },
199
+ modelKey: string,
200
+ partCount: number,
201
+ flatMeshes: Record<string, THREE.Mesh>
202
+ }) {
203
+ const instances = useMemo(
204
+ () => group.instances.map(inst => ({
205
+ key: inst.id,
206
+ position: inst.position,
207
+ rotation: inst.rotation,
208
+ scale: inst.scale,
209
+ })),
210
+ [group.instances]
211
+ );
212
+
213
+ return (
214
+ <InstancedRigidBodies
215
+ instances={instances}
216
+ colliders={group.physicsType === 'fixed' ? 'trimesh' : 'hull'}
217
+ type={group.physicsType as 'dynamic' | 'fixed'}
218
+ >
219
+ {Array.from({ length: partCount }).map((_, i) => {
220
+ const mesh = flatMeshes[`${modelKey}__${i}`];
221
+ return (
222
+ <instancedMesh
223
+ key={i}
224
+ args={[mesh.geometry, mesh.material, group.instances.length]}
225
+ castShadow
226
+ receiveShadow
227
+ frustumCulled={false}
228
+ />
229
+ );
230
+ })}
231
+ </InstancedRigidBodies>
232
+ );
233
+ }
234
+
235
+ // Non-physics instanced visuals: per-instance group using Merged's Instance components
236
+ function NonPhysicsInstancedGroup({
237
+ modelKey,
238
+ group,
239
+ partCount,
240
+ instancesMap
241
+ , onSelect, registerRef
242
+ }: {
243
+ modelKey: string;
244
+ group: { physicsType: string, instances: InstanceData[] };
245
+ partCount: number;
246
+ instancesMap: Record<string, React.ComponentType<any>>;
247
+ onSelect?: (id: string | null) => void;
248
+ registerRef?: (id: string, obj: THREE.Object3D | null) => void;
249
+ }) {
250
+ const clickValid = useRef(false);
251
+ const handlePointerDown = (e: any) => { e.stopPropagation(); clickValid.current = true; };
252
+ const handlePointerMove = () => { if (clickValid.current) clickValid.current = false; };
253
+ const handlePointerUp = (e: any, id: string) => {
254
+ if (clickValid.current) {
255
+ e.stopPropagation();
256
+ onSelect?.(id);
257
+ }
258
+ clickValid.current = false;
259
+ };
260
+
261
+ return (
262
+ <>
263
+ {group.instances.map(inst => (
264
+ <group
265
+ key={inst.id}
266
+ ref={(el) => { registerRef?.(inst.id, el as unknown as THREE.Object3D | null); }}
267
+ position={inst.position}
268
+ rotation={inst.rotation}
269
+ scale={inst.scale}
270
+ onPointerDown={handlePointerDown}
271
+ onPointerMove={handlePointerMove}
272
+ onPointerUp={(e) => handlePointerUp(e, inst.id)}
273
+ >
274
+ {Array.from({ length: partCount }).map((_, i) => {
275
+ const Instance = instancesMap[`${modelKey}__${i}`];
276
+ if (!Instance) return null;
277
+ return <Instance key={i} />;
278
+ })}
279
+ </group>
280
+ ))}
281
+ </>
282
+ );
283
+ }
284
+
285
+
286
+ // --- GameInstance: just registers an instance, renders nothing ---
287
+ export const GameInstance = React.forwardRef<THREE.Group, {
288
+ id: string;
289
+ modelUrl: string;
290
+ position: [number, number, number];
291
+ rotation: [number, number, number];
292
+ scale: [number, number, number];
293
+ physics?: { type: 'dynamic' | 'fixed' };
294
+ }>(({
295
+ id,
296
+ modelUrl,
297
+ position,
298
+ rotation,
299
+ scale,
300
+ physics = undefined,
301
+ }, ref) => {
302
+ const ctx = useContext(GameInstanceContext);
303
+ const addInstance = ctx?.addInstance;
304
+ const removeInstance = ctx?.removeInstance;
305
+
306
+ const instance = useMemo<InstanceData>(() => ({
307
+ id,
308
+ meshPath: modelUrl,
309
+ position,
310
+ rotation,
311
+ scale,
312
+ physics,
313
+ }), [id, modelUrl, position, rotation, scale, physics]);
314
+
315
+ useEffect(() => {
316
+ if (!addInstance || !removeInstance) return;
317
+ addInstance(instance);
318
+ return () => {
319
+ removeInstance(instance.id);
320
+ };
321
+ }, [addInstance, removeInstance, instance]);
322
+
323
+
324
+ // No visual here – provider will render visuals for all instances
325
+ return null;
326
+ });
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ import GameCanvas from "../../shared/GameCanvas";
4
+ import { useState, useRef, } from "react";
5
+ import { Group, } from "three";
6
+ import { Prefab, } from "./types";
7
+ import PrefabRoot from "./PrefabRoot";
8
+ import { Physics } from "@react-three/rapier";
9
+ import EditorUI from "./EditorUI";
10
+
11
+ const PrefabEditor = ({ basePath, initialPrefab, children }: { basePath?: string, initialPrefab?: Prefab, children?: React.ReactNode }) => {
12
+ const [editMode, setEditMode] = useState(true);
13
+ const [loadedPrefab, setLoadedPrefab] = useState<Prefab>(initialPrefab ?? {
14
+ "id": "prefab-default",
15
+ "name": "New Prefab",
16
+ "root": {
17
+ "id": "root",
18
+ "enabled": true,
19
+ "visible": true,
20
+ "components": {
21
+ "transform": {
22
+ "type": "Transform",
23
+ "properties": {
24
+ "position": [0, 0, 0],
25
+ "rotation": [0, 0, 0],
26
+ "scale": [1, 1, 1]
27
+ }
28
+ }
29
+ }
30
+ }
31
+ });
32
+ const [selectedId, setSelectedId] = useState<string | null>(null);
33
+ const [transformMode, setTransformMode] = useState<"translate" | "rotate" | "scale">("translate");
34
+ const prefabRef = useRef<Group>(null);
35
+
36
+ return <>
37
+ <GameCanvas>
38
+ <Physics paused={editMode}>
39
+ <ambientLight intensity={1.5} />
40
+ <gridHelper args={[10, 10]} position={[0, -1, 0]} />
41
+ <PrefabRoot
42
+ data={loadedPrefab}
43
+ ref={prefabRef}
44
+
45
+ // props for edit mode
46
+ editMode={editMode}
47
+ onPrefabChange={setLoadedPrefab}
48
+ selectedId={selectedId}
49
+ onSelect={setSelectedId}
50
+ transformMode={transformMode}
51
+ setTransformMode={setTransformMode}
52
+ basePath={basePath}
53
+ />
54
+ {children}
55
+ </Physics>
56
+ </GameCanvas>
57
+
58
+ <div style={{ position: "absolute", top: "0.5rem", left: "50%", transform: "translateX(-50%)" }} className="bg-black/70 backdrop-blur-sm border border-cyan-500/30 px-2 py-1 flex items-center gap-1">
59
+ <button
60
+ className="px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30"
61
+ onClick={() => setEditMode(!editMode)}
62
+ >
63
+ {editMode ? "▶" : "⏸"}
64
+ </button>
65
+ <span className="text-cyan-500/30 text-[10px]">|</span>
66
+ <button
67
+ className="px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30"
68
+ onClick={async () => {
69
+ const prefab = await loadJson();
70
+ if (prefab) setLoadedPrefab(prefab);
71
+ }}
72
+ >
73
+ 📥
74
+ </button>
75
+ <button
76
+ className="px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30"
77
+ onClick={() => saveJson(loadedPrefab, "prefab")}
78
+ >
79
+ 💾
80
+ </button>
81
+ </div>
82
+ {editMode && <EditorUI
83
+ prefabData={loadedPrefab}
84
+ setPrefabData={setLoadedPrefab}
85
+ selectedId={selectedId}
86
+ setSelectedId={setSelectedId}
87
+ transformMode={transformMode}
88
+ setTransformMode={setTransformMode}
89
+ basePath={basePath}
90
+ />}
91
+ </>
92
+ }
93
+
94
+ const saveJson = (data: any, filename: string) => {
95
+ const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
96
+ const downloadAnchorNode = document.createElement('a');
97
+ downloadAnchorNode.setAttribute("href", dataStr);
98
+ downloadAnchorNode.setAttribute("download", (filename || 'prefab') + ".json");
99
+ document.body.appendChild(downloadAnchorNode);
100
+ downloadAnchorNode.click();
101
+ downloadAnchorNode.remove();
102
+ };
103
+
104
+ const loadJson = async () => {
105
+ return new Promise<Prefab | undefined>((resolve) => {
106
+ const input = document.createElement('input');
107
+ input.type = 'file';
108
+ input.accept = '.json,application/json';
109
+ input.onchange = e => {
110
+ const file = (e.target as HTMLInputElement).files?.[0];
111
+ if (!file) return resolve(undefined);
112
+ const reader = new FileReader();
113
+ reader.onload = e => {
114
+ try {
115
+ const text = e.target?.result;
116
+ if (typeof text === 'string') {
117
+ const json = JSON.parse(text);
118
+ resolve(json as Prefab);
119
+ }
120
+ } catch (err) {
121
+ console.error('Error parsing prefab JSON:', err);
122
+ resolve(undefined);
123
+ }
124
+ };
125
+ reader.readAsText(file);
126
+ };
127
+ input.click();
128
+ });
129
+ };
130
+ export default PrefabEditor;