react-three-game 0.0.7 → 0.0.8

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,42 @@
1
+ "use client";
2
+
3
+ import { Physics, RigidBody } from "@react-three/rapier";
4
+ import { OrbitControls } from "@react-three/drei";
5
+ import { useState } from "react";
6
+ import { DragDropLoader } from "./DragDropLoader";
7
+ import GameCanvas from "../../shared/GameCanvas";
8
+
9
+ export default function Home() {
10
+ const [models, setModels] = useState<any[]>([]);
11
+
12
+ return (
13
+ <>
14
+ <DragDropLoader onModelLoaded={model => setModels(prev => [...prev, model])} />
15
+ <div className="w-full items-center justify-items-center min-h-screen" style={{ height: "100vh" }}>
16
+ <GameCanvas>
17
+ <Physics>
18
+ <RigidBody>
19
+ <mesh castShadow>
20
+ <boxGeometry args={[1, 1, 1]} />
21
+ <meshStandardMaterial color="orange" />
22
+ </mesh>
23
+ </RigidBody>
24
+ <RigidBody type="fixed">
25
+ <mesh position={[0, -2, 0]} scale={[10, 0.1, 10]} receiveShadow>
26
+ <boxGeometry />
27
+ <meshStandardMaterial color="gray" />
28
+ </mesh>
29
+ </RigidBody>
30
+ {/* Render loaded models */}
31
+ {models.map((model, idx) => (
32
+ <primitive object={model} key={idx} position={[0, 0, 0]} />
33
+ ))}
34
+ <ambientLight intensity={0.5} />
35
+ <pointLight position={[10, 10, 10]} castShadow intensity={1000} />
36
+ <OrbitControls />
37
+ </Physics>
38
+ </GameCanvas>
39
+ </div>
40
+ </>
41
+ );
42
+ }
@@ -0,0 +1,277 @@
1
+ import { Dispatch, SetStateAction, useState, MouseEvent } from 'react';
2
+ import { Prefab, GameObject } from "./types";
3
+ import { getComponent } from './components/ComponentRegistry';
4
+
5
+ interface EditorTreeProps {
6
+ prefabData?: Prefab;
7
+ setPrefabData?: Dispatch<SetStateAction<Prefab>>;
8
+ selectedId: string | null;
9
+ setSelectedId: Dispatch<SetStateAction<string | null>>;
10
+ }
11
+
12
+ export default function EditorTree({ prefabData, setPrefabData, selectedId, setSelectedId }: EditorTreeProps) {
13
+ const [contextMenu, setContextMenu] = useState<{ x: number, y: number, nodeId: string } | null>(null);
14
+ const [draggedId, setDraggedId] = useState<string | null>(null);
15
+ const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
16
+ const [isTreeCollapsed, setIsTreeCollapsed] = useState(false);
17
+
18
+ if (!prefabData || !setPrefabData) return null;
19
+
20
+ const handleContextMenu = (e: MouseEvent, nodeId: string) => {
21
+ e.preventDefault();
22
+ e.stopPropagation();
23
+ setContextMenu({ x: e.clientX, y: e.clientY, nodeId });
24
+ };
25
+
26
+ const closeContextMenu = () => setContextMenu(null);
27
+
28
+ const toggleCollapse = (e: MouseEvent, id: string) => {
29
+ e.stopPropagation();
30
+ setCollapsedIds(prev => {
31
+ const next = new Set(prev);
32
+ if (next.has(id)) next.delete(id);
33
+ else next.add(id);
34
+ return next;
35
+ });
36
+ };
37
+
38
+ // Actions
39
+ const handleAddChild = (parentId: string) => {
40
+ const newNode: GameObject = {
41
+ id: crypto.randomUUID(),
42
+ enabled: true,
43
+ visible: true,
44
+ components: {
45
+ transform: {
46
+ type: "Transform",
47
+ properties: { ...getComponent('Transform')?.defaultProperties }
48
+ }
49
+ }
50
+ };
51
+
52
+ setPrefabData(prev => {
53
+ const newRoot = JSON.parse(JSON.stringify(prev.root)); // Deep clone for safety
54
+ const parent = findNode(newRoot, parentId);
55
+ if (parent) {
56
+ parent.children = parent.children || [];
57
+ parent.children.push(newNode);
58
+ }
59
+ return { ...prev, root: newRoot };
60
+ });
61
+ closeContextMenu();
62
+ };
63
+
64
+ const handleDuplicate = (nodeId: string) => {
65
+ if (nodeId === prefabData.root.id) return; // Cannot duplicate root
66
+
67
+ setPrefabData(prev => {
68
+ const newRoot = JSON.parse(JSON.stringify(prev.root));
69
+ const parent = findParent(newRoot, nodeId);
70
+ const node = findNode(newRoot, nodeId);
71
+
72
+ if (parent && node) {
73
+ const clone = cloneNode(node);
74
+ parent.children = parent.children || [];
75
+ parent.children.push(clone);
76
+ }
77
+ return { ...prev, root: newRoot };
78
+ });
79
+ closeContextMenu();
80
+ };
81
+
82
+ const handleDelete = (nodeId: string) => {
83
+ if (nodeId === prefabData.root.id) return; // Cannot delete root
84
+
85
+ setPrefabData(prev => {
86
+ const newRoot = deleteNodeFromTree(JSON.parse(JSON.stringify(prev.root)), nodeId);
87
+ return { ...prev, root: newRoot! };
88
+ });
89
+ if (selectedId === nodeId) setSelectedId(null);
90
+ closeContextMenu();
91
+ };
92
+
93
+ // Drag and Drop
94
+ const handleDragStart = (e: React.DragEvent, id: string) => {
95
+ e.stopPropagation();
96
+ if (id === prefabData.root.id) {
97
+ e.preventDefault(); // Cannot drag root
98
+ return;
99
+ }
100
+ setDraggedId(id);
101
+ e.dataTransfer.effectAllowed = "move";
102
+ };
103
+
104
+ const handleDragOver = (e: React.DragEvent, targetId: string) => {
105
+ e.preventDefault();
106
+ e.stopPropagation();
107
+ if (!draggedId || draggedId === targetId) return;
108
+
109
+ // Check for cycles: target cannot be a descendant of dragged node
110
+ const draggedNode = findNode(prefabData.root, draggedId);
111
+ if (draggedNode && findNode(draggedNode, targetId)) return;
112
+
113
+ e.dataTransfer.dropEffect = "move";
114
+ };
115
+
116
+ const handleDrop = (e: React.DragEvent, targetId: string) => {
117
+ e.preventDefault();
118
+ e.stopPropagation();
119
+ if (!draggedId || draggedId === targetId) return;
120
+
121
+ setPrefabData(prev => {
122
+ const newRoot = JSON.parse(JSON.stringify(prev.root));
123
+
124
+ // Check cycle again on the fresh tree
125
+ const draggedNodeRef = findNode(newRoot, draggedId);
126
+ if (draggedNodeRef && findNode(draggedNodeRef, targetId)) return prev;
127
+
128
+ // Remove from old parent
129
+ const parent = findParent(newRoot, draggedId);
130
+ if (!parent) return prev;
131
+
132
+ const nodeToMove = parent.children?.find(c => c.id === draggedId);
133
+ if (!nodeToMove) return prev;
134
+
135
+ parent.children = parent.children!.filter(c => c.id !== draggedId);
136
+
137
+ // Add to new parent
138
+ const target = findNode(newRoot, targetId);
139
+ if (target) {
140
+ target.children = target.children || [];
141
+ target.children.push(nodeToMove);
142
+ }
143
+
144
+ return { ...prev, root: newRoot };
145
+ });
146
+ setDraggedId(null);
147
+ };
148
+
149
+ const renderNode = (node: GameObject, depth: number = 0) => {
150
+ if (!node) return null;
151
+
152
+ const isSelected = node.id === selectedId;
153
+ const isCollapsed = collapsedIds.has(node.id);
154
+ const hasChildren = node.children && node.children.length > 0;
155
+
156
+ return (
157
+ <div key={node.id} className="select-none">
158
+ <div
159
+ className={`flex items-center py-0.5 px-1 cursor-pointer border-b border-cyan-500/10 ${isSelected ? 'bg-cyan-500/30 hover:bg-cyan-500/40 border-cyan-400/30' : 'hover:bg-cyan-500/10'}`}
160
+ style={{ paddingLeft: `${depth * 8 + 4}px` }}
161
+ onClick={(e) => { e.stopPropagation(); setSelectedId(node.id); }}
162
+ onContextMenu={(e) => handleContextMenu(e, node.id)}
163
+ draggable={node.id !== prefabData.root.id}
164
+ onDragStart={(e) => handleDragStart(e, node.id)}
165
+ onDragOver={(e) => handleDragOver(e, node.id)}
166
+ onDrop={(e) => handleDrop(e, node.id)}
167
+ >
168
+ <span
169
+ className={`mr-0.5 w-3 text-center text-cyan-400/50 hover:text-cyan-400 cursor-pointer text-[8px] ${hasChildren ? '' : 'invisible'}`}
170
+ onClick={(e) => hasChildren && toggleCollapse(e, node.id)}
171
+ >
172
+ {isCollapsed ? '▶' : '▼'}
173
+ </span>
174
+ <span className="text-[10px] truncate font-mono text-cyan-300">
175
+ {node.id}
176
+ </span>
177
+ </div>
178
+ {!isCollapsed && node.children && (
179
+ <div>
180
+ {node.children.map(child => renderNode(child, depth + 1))}
181
+ </div>
182
+ )}
183
+ </div>
184
+ );
185
+ };
186
+
187
+ return (
188
+ <>
189
+ <div className="bg-black/70 backdrop-blur-sm text-white border border-cyan-500/30 max-h-[85vh] overflow-y-auto flex flex-col" style={{ width: isTreeCollapsed ? 'auto' : '14rem' }} onClick={closeContextMenu}>
190
+ <div
191
+ className="px-1.5 py-1 font-mono text-[10px] bg-cyan-500/10 border-b border-cyan-500/30 sticky top-0 uppercase tracking-wider text-cyan-400/80 cursor-pointer hover:bg-cyan-500/20 flex items-center justify-between"
192
+ onClick={(e) => { e.stopPropagation(); setIsTreeCollapsed(!isTreeCollapsed); }}
193
+ >
194
+ <span>Prefab Graph</span>
195
+ <span className="text-[8px]">{isTreeCollapsed ? '▶' : '◀'}</span>
196
+ </div>
197
+ {!isTreeCollapsed && (
198
+ <div className="flex-1 py-0.5">
199
+ {renderNode(prefabData.root)}
200
+ </div>
201
+ )}
202
+ </div>
203
+
204
+ {contextMenu && (
205
+ <div
206
+ className="fixed bg-black/90 backdrop-blur-sm border border-cyan-500/40 z-50 min-w-[100px]"
207
+ style={{ top: contextMenu.y, left: contextMenu.x }}
208
+ onClick={(e) => e.stopPropagation()}
209
+ >
210
+ <button
211
+ className="w-full text-left px-2 py-1 hover:bg-cyan-500/20 text-[10px] text-cyan-300 font-mono border-b border-cyan-500/20"
212
+ onClick={() => handleAddChild(contextMenu.nodeId)}
213
+ >
214
+ Add Child
215
+ </button>
216
+ {contextMenu.nodeId !== prefabData.root.id && (
217
+ <>
218
+ <button
219
+ className="w-full text-left px-2 py-1 hover:bg-cyan-500/20 text-[10px] text-cyan-300 font-mono border-b border-cyan-500/20"
220
+ onClick={() => handleDuplicate(contextMenu.nodeId)}
221
+ >
222
+ Duplicate
223
+ </button>
224
+ <button
225
+ className="w-full text-left px-2 py-1 hover:bg-red-500/20 text-[10px] text-red-400 font-mono"
226
+ onClick={() => handleDelete(contextMenu.nodeId)}
227
+ >
228
+ Delete
229
+ </button>
230
+ </>
231
+ )}
232
+ </div>
233
+ )}
234
+ </>
235
+ );
236
+ }
237
+
238
+ // --- Helpers ---
239
+
240
+ function findNode(root: GameObject, id: string): GameObject | null {
241
+ if (root.id === id) return root;
242
+ if (root.children) {
243
+ for (const child of root.children) {
244
+ const found = findNode(child, id);
245
+ if (found) return found;
246
+ }
247
+ }
248
+ return null;
249
+ }
250
+
251
+ function findParent(root: GameObject, id: string): GameObject | null {
252
+ if (!root.children) return null;
253
+ for (const child of root.children) {
254
+ if (child.id === id) return root;
255
+ const found = findParent(child, id);
256
+ if (found) return found;
257
+ }
258
+ return null;
259
+ }
260
+
261
+ function deleteNodeFromTree(root: GameObject, id: string): GameObject | null {
262
+ if (root.id === id) return null;
263
+ if (root.children) {
264
+ root.children = root.children
265
+ .map(child => deleteNodeFromTree(child, id))
266
+ .filter((child): child is GameObject => child !== null);
267
+ }
268
+ return root;
269
+ }
270
+
271
+ function cloneNode(node: GameObject): GameObject {
272
+ const newNode = { ...node, id: crypto.randomUUID() };
273
+ if (newNode.children) {
274
+ newNode.children = newNode.children.map(child => cloneNode(child));
275
+ }
276
+ return newNode;
277
+ }
@@ -0,0 +1,273 @@
1
+ import { Dispatch, SetStateAction, useState, useEffect } from 'react';
2
+ import { Prefab, GameObject as GameObjectType } from "./types";
3
+ import EditorTree from './EditorTree';
4
+ import { getAllComponents } from './components/ComponentRegistry';
5
+
6
+
7
+ function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transformMode, setTransformMode, basePath }: {
8
+ prefabData?: Prefab;
9
+ setPrefabData?: Dispatch<SetStateAction<Prefab>>;
10
+ selectedId: string | null;
11
+ setSelectedId: Dispatch<SetStateAction<string | null>>;
12
+ transformMode: "translate" | "rotate" | "scale";
13
+ setTransformMode: (m: "translate" | "rotate" | "scale") => void;
14
+ basePath?: string;
15
+ }) {
16
+ const [isInspectorCollapsed, setIsInspectorCollapsed] = useState(false);
17
+
18
+ const updateNode = (updater: (n: GameObjectType) => GameObjectType) => {
19
+ if (!prefabData || !setPrefabData || !selectedId) return;
20
+ setPrefabData(prev => ({
21
+ ...prev,
22
+ root: updatePrefabNode(prev.root, selectedId, updater)
23
+ }));
24
+ };
25
+
26
+ const deleteNode = () => {
27
+ if (!prefabData || !setPrefabData || !selectedId) return;
28
+ if (selectedId === prefabData.root.id) {
29
+ alert("Cannot delete root node");
30
+ return;
31
+ }
32
+ setPrefabData(prev => {
33
+ const newRoot = deletePrefabNode(prev.root, selectedId);
34
+ return { ...prev, root: newRoot! };
35
+ });
36
+ setSelectedId(null);
37
+ };
38
+
39
+ const selectedNode = selectedId && prefabData ? findNode(prefabData.root, selectedId) : null;
40
+
41
+ // if (!selectedNode) return null;
42
+ return <>
43
+ <div style={{ position: 'absolute', top: "0.5rem", right: "0.5rem", zIndex: 20, backgroundColor: "rgba(0,0,0,0.7)", backdropFilter: "blur(4px)", color: "white", border: "1px solid rgba(0,255,255,0.3)" }} >
44
+ <div
45
+ className="px-1.5 py-1 font-mono text-[10px] bg-cyan-500/10 border-b border-cyan-500/30 sticky top-0 uppercase tracking-wider text-cyan-400/80 cursor-pointer hover:bg-cyan-500/20 flex items-center justify-between"
46
+ onClick={() => setIsInspectorCollapsed(!isInspectorCollapsed)}
47
+ >
48
+ <span>Inspector</span>
49
+ <span className="text-[8px]">{isInspectorCollapsed ? '◀' : '▶'}</span>
50
+ </div>
51
+ {!isInspectorCollapsed && selectedNode && (
52
+ <NodeInspector
53
+ node={selectedNode}
54
+ updateNode={updateNode}
55
+ deleteNode={deleteNode}
56
+ transformMode={transformMode}
57
+ setTransformMode={setTransformMode}
58
+ basePath={basePath}
59
+ />
60
+ )}
61
+ </div>
62
+ <div style={{ position: 'absolute', top: "0.5rem", left: "0.5rem", zIndex: 20 }} >
63
+ <EditorTree
64
+ prefabData={prefabData}
65
+ setPrefabData={setPrefabData}
66
+ selectedId={selectedId}
67
+ setSelectedId={setSelectedId}
68
+ />
69
+ </div>
70
+ </>;
71
+ }
72
+
73
+ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransformMode, basePath }: {
74
+ node: GameObjectType;
75
+ updateNode: (updater: (n: GameObjectType) => GameObjectType) => void;
76
+ deleteNode: () => void;
77
+ transformMode: "translate" | "rotate" | "scale";
78
+ setTransformMode: (m: "translate" | "rotate" | "scale") => void;
79
+ basePath?: string;
80
+ }) {
81
+ const ALL_COMPONENTS = getAllComponents();
82
+ const allComponentKeys = Object.keys(ALL_COMPONENTS);
83
+ const [addComponentType, setAddComponentType] = useState(allComponentKeys[0]);
84
+
85
+ const componentKeys = Object.keys(node.components || {}).join(',');
86
+ useEffect(() => {
87
+ // Components stored on nodes use lowercase keys (e.g. 'geometry'),
88
+ // while the registry keys are the component names (e.g. 'Geometry').
89
+ const available = allComponentKeys.filter(k => !node.components?.[k.toLowerCase()]);
90
+ if (!available.includes(addComponentType)) {
91
+ setAddComponentType(available[0] || "");
92
+ }
93
+ }, [componentKeys, addComponentType, node.components, allComponentKeys]);
94
+
95
+ return <div className="flex flex-col gap-1 text-[11px] max-w-[250px] max-h-[80vh] overflow-y-auto">
96
+ <div className="border-b border-cyan-500/20 pb-1 px-1.5 pt-1">
97
+ <input
98
+ className="w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[11px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50"
99
+ value={node.id}
100
+ onChange={e => updateNode(n => ({ ...n, id: e.target.value }))}
101
+ />
102
+ </div>
103
+
104
+ <div className="flex justify-between items-center px-1.5 py-0.5 border-b border-cyan-500/20">
105
+ <label className="text-[10px] font-mono text-cyan-400/80 uppercase tracking-wider">Components</label>
106
+ <button onClick={deleteNode} className="text-[10px] text-red-400/80 hover:text-red-400">✕</button>
107
+ </div>
108
+
109
+ <div className="px-1.5 py-1 border-b border-cyan-500/20">
110
+ <label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Mode</label>
111
+ <div className="flex gap-0.5">
112
+ {["translate", "rotate", "scale"].map(mode => (
113
+ <button
114
+ key={mode}
115
+ onClick={() => setTransformMode(mode as any)}
116
+ className={`flex-1 px-1 py-0.5 text-[10px] font-mono border ${transformMode === mode ? 'bg-cyan-500/30 border-cyan-400/50 text-cyan-200' : 'bg-black/30 border-cyan-500/20 text-cyan-400/60 hover:border-cyan-400/30'}`}
117
+ >
118
+ {mode[0].toUpperCase()}
119
+ </button>
120
+ ))}
121
+ </div>
122
+ </div>
123
+
124
+ {/* Components */}
125
+ {/* {node.components && Object.entries(node.components).map(([key, comp]: [string, any]) => {
126
+ if (!comp) return null;
127
+ return (
128
+ <div key={key} className="border border-cyan-500/20 mx-1 my-0.5 bg-black/20">
129
+ <div className="flex justify-between items-center px-1 py-0.5 border-b border-cyan-500/20 bg-cyan-500/5">
130
+ <span className="font-mono text-[10px] text-cyan-300 uppercase">{key}</span>
131
+ <button
132
+ onClick={() => updateNode(n => {
133
+ const components = { ...n.components };
134
+ delete components[key as keyof typeof components];
135
+ return { ...n, components };
136
+ })}
137
+ className="text-[9px] text-red-400/60 hover:text-red-400"
138
+ >
139
+
140
+ </button>
141
+ </div>
142
+ <div className="px-1 py-0.5">
143
+ <ComponentEditor component={comp} onChange={(newComp: any) => updateNode(n => ({
144
+ ...n,
145
+ components: { ...n.components, [key]: newComp }
146
+ }))} />
147
+ </div>
148
+ </div>
149
+ );
150
+ })} */}
151
+
152
+ {node.components && Object.entries(node.components).map(([key, comp]: [string, any]) => {
153
+ if (!comp) return null;
154
+ const componentDef = ALL_COMPONENTS[comp.type];
155
+ if (!componentDef) return <div key={key} className="px-1 py-0.5 text-red-400 text-[10px]">Unknown component type: {comp.type}
156
+ <textarea defaultValue={JSON.stringify(comp)} />
157
+ </div>;
158
+
159
+ const EditorComp = componentDef.Editor;
160
+ return (
161
+ <div key={key} className='px-1'>
162
+ <div className="flex justify-between items-center py-0.5 border-b border-cyan-500/20 bg-cyan-500/5">
163
+ <span className="font-mono text-[10px] text-cyan-300 uppercase">{key}</span>
164
+ <button
165
+ onClick={() => updateNode(n => {
166
+ const components = { ...n.components };
167
+ delete components[key as keyof typeof components];
168
+ return { ...n, components };
169
+ })}
170
+ className="text-[9px] text-red-400/60 hover:text-red-400"
171
+ >
172
+
173
+ </button>
174
+ </div>
175
+ {EditorComp ? (
176
+ <EditorComp
177
+ component={comp}
178
+ onUpdate={(newProps: any) => updateNode(n => ({
179
+ ...n,
180
+ components: {
181
+ ...n.components,
182
+ [key]: {
183
+ ...comp,
184
+ properties: { ...comp.properties, ...newProps }
185
+ }
186
+ }
187
+ }))}
188
+ basePath={basePath}
189
+ />
190
+ ) : null}
191
+ </div>
192
+ );
193
+ })}
194
+
195
+ {/* Add Component */}
196
+ <div className="px-1.5 py-1 border-t border-cyan-500/20">
197
+ <label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Add Component</label>
198
+ <div className="flex gap-0.5">
199
+ <select
200
+ className="bg-black/40 border border-cyan-500/30 px-1 py-0.5 flex-1 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50"
201
+ value={addComponentType}
202
+ onChange={e => setAddComponentType(e.target.value)}
203
+ >
204
+ {allComponentKeys.filter(k => !node.components?.[k.toLowerCase()]).map(k => (
205
+ <option key={k} value={k}>{k}</option>
206
+ ))}
207
+ </select>
208
+ <button
209
+ className="bg-cyan-500/20 hover:bg-cyan-500/30 border border-cyan-500/30 px-2 py-0.5 text-[10px] text-cyan-300 font-mono disabled:opacity-30"
210
+ disabled={!addComponentType}
211
+ onClick={() => {
212
+ if (!addComponentType) return;
213
+ const def = ALL_COMPONENTS[addComponentType];
214
+ if (def && !node.components?.[addComponentType.toLowerCase()]) {
215
+ const key = addComponentType.toLowerCase();
216
+ updateNode(n => ({
217
+ ...n,
218
+ components: {
219
+ ...n.components,
220
+ [key]: { type: def.name, properties: def.defaultProperties }
221
+ }
222
+ }));
223
+ }
224
+ }}
225
+ >
226
+ +
227
+ </button>
228
+ </div>
229
+ </div>
230
+
231
+
232
+ </div>
233
+ }
234
+
235
+ function findNode(root: GameObjectType, id: string): GameObjectType | null {
236
+ if (root.id === id) return root;
237
+ if (root.children) {
238
+ for (const child of root.children) {
239
+ const found = findNode(child, id);
240
+ if (found) return found;
241
+ }
242
+ }
243
+ return null;
244
+ }
245
+
246
+ function updatePrefabNode(root: GameObjectType, id: string, update: (node: GameObjectType) => GameObjectType): GameObjectType {
247
+ if (root.id === id) {
248
+ return update(root);
249
+ }
250
+ if (root.children) {
251
+ return {
252
+ ...root,
253
+ children: root.children.map(child => updatePrefabNode(child, id, update))
254
+ };
255
+ }
256
+ return root;
257
+ }
258
+
259
+ function deletePrefabNode(root: GameObjectType, id: string): GameObjectType | null {
260
+ if (root.id === id) return null;
261
+
262
+ if (root.children) {
263
+ return {
264
+ ...root,
265
+ children: root.children
266
+ .map(child => deletePrefabNode(child, id))
267
+ .filter((child): child is GameObjectType => child !== null)
268
+ };
269
+ }
270
+ return root;
271
+ }
272
+
273
+ export default EditorUI;
@@ -0,0 +1,36 @@
1
+ import { useRef, useImperativeHandle, forwardRef, useCallback } from 'react';
2
+
3
+ interface EventSystemRef {
4
+ fire: (eventType: string, data?: any) => void;
5
+ }
6
+
7
+ const EventSystemHook = forwardRef<EventSystemRef, { entityId: string }>(
8
+ ({ entityId }, ref) => {
9
+ const targetRef = useRef<EventTarget>(typeof window !== 'undefined' ? window : null);
10
+
11
+ // Fire a global JS event with entityId as source
12
+ const fire = useCallback((eventType: string, data?: any) => {
13
+ if (!targetRef.current) return;
14
+
15
+ const event = new CustomEvent(eventType, {
16
+ detail: {
17
+ entityId,
18
+ data,
19
+ },
20
+ });
21
+
22
+ targetRef.current.dispatchEvent(event);
23
+ }, [entityId]);
24
+
25
+ // Expose ref API
26
+ useImperativeHandle(ref, () => ({
27
+ fire,
28
+ }), [fire]);
29
+
30
+ return null;
31
+ }
32
+ );
33
+
34
+ EventSystemHook.displayName = 'EventSystemHook';
35
+
36
+ export default EventSystemHook;