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.
- package/package.json +1 -1
- package/src/index.ts +15 -0
- package/src/shared/GameCanvas.tsx +48 -0
- package/src/tools/assetviewer/page.tsx +411 -0
- package/src/tools/dragdrop/DragDropLoader.tsx +105 -0
- package/src/tools/dragdrop/modelLoader.ts +65 -0
- package/src/tools/dragdrop/page.tsx +42 -0
- package/src/tools/prefabeditor/EditorTree.tsx +277 -0
- package/src/tools/prefabeditor/EditorUI.tsx +273 -0
- package/src/tools/prefabeditor/EventSystem.tsx +36 -0
- package/src/tools/prefabeditor/InstanceProvider.tsx +326 -0
- package/src/tools/prefabeditor/PrefabEditor.tsx +130 -0
- package/src/tools/prefabeditor/PrefabRoot.tsx +460 -0
- package/src/tools/prefabeditor/components/ComponentRegistry.ts +26 -0
- package/src/tools/prefabeditor/components/GeometryComponent.tsx +43 -0
- package/src/tools/prefabeditor/components/MaterialComponent.tsx +153 -0
- package/src/tools/prefabeditor/components/ModelComponent.tsx +68 -0
- package/src/tools/prefabeditor/components/PhysicsComponent.tsx +47 -0
- package/src/tools/prefabeditor/components/SpotLightComponent.tsx +53 -0
- package/src/tools/prefabeditor/components/TransformComponent.tsx +49 -0
- package/src/tools/prefabeditor/components/index.ts +16 -0
- package/src/tools/prefabeditor/page.tsx +10 -0
- package/src/tools/prefabeditor/types.ts +28 -0
- package/tsconfig.json +18 -0
|
@@ -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;
|