react-three-game 0.0.6 → 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/README.md +2 -0
- package/assets/editor.gif +0 -0
- package/dist/tools/prefabeditor/PrefabRoot.js +5 -3
- package/package.json +2 -2
- 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 +17 -17
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { MapControls, TransformControls } from "@react-three/drei";
|
|
4
|
+
import { useState, useRef, useEffect, forwardRef, useMemo, useCallback } from "react";
|
|
5
|
+
import { Vector3, Euler, Quaternion, ClampToEdgeWrapping, DoubleSide, Group, Object3D, RepeatWrapping, SRGBColorSpace, Texture, TextureLoader, Matrix4 } from "three";
|
|
6
|
+
import { Prefab, GameObject as GameObjectType } from "./types";
|
|
7
|
+
import { getComponent } from "./components/ComponentRegistry";
|
|
8
|
+
import { ThreeEvent } from "@react-three/fiber";
|
|
9
|
+
import { loadModel } from "../dragdrop/modelLoader";
|
|
10
|
+
import { GameInstance, GameInstanceProvider } from "./InstanceProvider";
|
|
11
|
+
|
|
12
|
+
// register all components
|
|
13
|
+
import { registerComponent } from './components/ComponentRegistry';
|
|
14
|
+
import components from './components/';
|
|
15
|
+
components.forEach(registerComponent);
|
|
16
|
+
|
|
17
|
+
function updatePrefabNode(root: GameObjectType, id: string, update: (node: GameObjectType) => GameObjectType): GameObjectType {
|
|
18
|
+
if (root.id === id) {
|
|
19
|
+
return update(root);
|
|
20
|
+
}
|
|
21
|
+
if (root.children) {
|
|
22
|
+
return {
|
|
23
|
+
...root,
|
|
24
|
+
children: root.children.map(child => updatePrefabNode(child, id, update))
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const PrefabRoot = forwardRef<Group, {
|
|
31
|
+
editMode?: boolean;
|
|
32
|
+
data: Prefab;
|
|
33
|
+
onPrefabChange?: (data: Prefab) => void;
|
|
34
|
+
selectedId?: string | null;
|
|
35
|
+
onSelect?: (id: string | null) => void;
|
|
36
|
+
transformMode?: "translate" | "rotate" | "scale";
|
|
37
|
+
setTransformMode?: (mode: "translate" | "rotate" | "scale") => void;
|
|
38
|
+
basePath?: string;
|
|
39
|
+
}>(({ editMode, data, onPrefabChange, selectedId, onSelect, transformMode, setTransformMode, basePath = "" }, ref) => {
|
|
40
|
+
const [loadedModels, setLoadedModels] = useState<Record<string, Object3D>>({});
|
|
41
|
+
const [loadedTextures, setLoadedTextures] = useState<Record<string, Texture>>({});
|
|
42
|
+
// const [prefabRoot, setPrefabRoot] = useState<Prefab>(data); // Removed local state
|
|
43
|
+
const loadingRefs = useRef<Set<string>>(new Set());
|
|
44
|
+
const objectRefs = useRef<Record<string, Object3D | null>>({});
|
|
45
|
+
const [selectedObject, setSelectedObject] = useState<Object3D | null>(null);
|
|
46
|
+
|
|
47
|
+
const registerRef = useCallback((id: string, obj: Object3D | null) => {
|
|
48
|
+
objectRefs.current[id] = obj;
|
|
49
|
+
if (id === selectedId) {
|
|
50
|
+
setSelectedObject(obj);
|
|
51
|
+
}
|
|
52
|
+
}, [selectedId]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (selectedId) {
|
|
56
|
+
setSelectedObject(objectRefs.current[selectedId] || null);
|
|
57
|
+
} else {
|
|
58
|
+
setSelectedObject(null);
|
|
59
|
+
}
|
|
60
|
+
}, [selectedId]);
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
// const [transformMode, setTransformMode] = useState<"translate" | "rotate" | "scale">("translate"); // Removed local state
|
|
64
|
+
|
|
65
|
+
const updateNode = (updater: (node: GameObjectType) => GameObjectType) => {
|
|
66
|
+
if (!selectedId || !onPrefabChange) return;
|
|
67
|
+
const newRoot = updatePrefabNode(data.root, selectedId, updater);
|
|
68
|
+
onPrefabChange({ ...data, root: newRoot });
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const onTransformChange = () => {
|
|
72
|
+
if (!selectedId || !onPrefabChange) return;
|
|
73
|
+
const obj = objectRefs.current[selectedId];
|
|
74
|
+
if (!obj) return;
|
|
75
|
+
|
|
76
|
+
// 1. Get world matrix from the actual Three object
|
|
77
|
+
const worldMatrix = obj.matrixWorld.clone();
|
|
78
|
+
|
|
79
|
+
// 2. Compute parent world matrix from the prefab tree
|
|
80
|
+
const parentWorld = computeParentWorldMatrix(data.root, selectedId);
|
|
81
|
+
const parentInv = parentWorld.clone().invert();
|
|
82
|
+
|
|
83
|
+
// 3. Convert world -> local
|
|
84
|
+
const localMatrix = new Matrix4().multiplyMatrices(parentInv, worldMatrix);
|
|
85
|
+
|
|
86
|
+
const lp = new Vector3();
|
|
87
|
+
const lq = new Quaternion();
|
|
88
|
+
const ls = new Vector3();
|
|
89
|
+
localMatrix.decompose(lp, lq, ls);
|
|
90
|
+
|
|
91
|
+
const le = new Euler().setFromQuaternion(lq);
|
|
92
|
+
|
|
93
|
+
// 4. Write back LOCAL transform into the prefab node
|
|
94
|
+
const newRoot = updatePrefabNode(data.root, selectedId, (node) => ({
|
|
95
|
+
...node,
|
|
96
|
+
components: {
|
|
97
|
+
...node?.components,
|
|
98
|
+
transform: {
|
|
99
|
+
type: "Transform",
|
|
100
|
+
properties: {
|
|
101
|
+
position: [lp.x, lp.y, lp.z] as [number, number, number],
|
|
102
|
+
rotation: [le.x, le.y, le.z] as [number, number, number],
|
|
103
|
+
scale: [ls.x, ls.y, ls.z] as [number, number, number],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
geometry: node.components?.geometry,
|
|
107
|
+
material: node.components?.material,
|
|
108
|
+
model: node.components?.model,
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
onPrefabChange({ ...data, root: newRoot });
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const loadAssets = async () => {
|
|
118
|
+
const modelsToLoad = new Set<string>();
|
|
119
|
+
const texturesToLoad = new Set<string>();
|
|
120
|
+
|
|
121
|
+
const traverse = (node?: GameObjectType | null) => {
|
|
122
|
+
if (!node) return;
|
|
123
|
+
if (node.components?.model?.properties?.filename) {
|
|
124
|
+
modelsToLoad.add(node.components.model.properties.filename);
|
|
125
|
+
}
|
|
126
|
+
if (node.components?.material?.properties?.texture) {
|
|
127
|
+
texturesToLoad.add(node.components.material.properties.texture);
|
|
128
|
+
}
|
|
129
|
+
node.children?.forEach(traverse);
|
|
130
|
+
};
|
|
131
|
+
traverse(data.root);
|
|
132
|
+
|
|
133
|
+
for (const filename of modelsToLoad) {
|
|
134
|
+
if (!loadedModels[filename] && !loadingRefs.current.has(filename)) {
|
|
135
|
+
loadingRefs.current.add(filename);
|
|
136
|
+
const result = await loadModel(filename, basePath);
|
|
137
|
+
if (result.success && result.model) {
|
|
138
|
+
setLoadedModels(prev => ({ ...prev, [filename]: result.model }));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const textureLoader = new TextureLoader();
|
|
144
|
+
for (const filename of texturesToLoad) {
|
|
145
|
+
if (!loadedTextures[filename] && !loadingRefs.current.has(filename)) {
|
|
146
|
+
loadingRefs.current.add(filename);
|
|
147
|
+
const texturePath = basePath ? `${basePath}/${filename}` : filename;
|
|
148
|
+
textureLoader.load(texturePath, (texture) => {
|
|
149
|
+
texture.colorSpace = SRGBColorSpace;
|
|
150
|
+
setLoadedTextures(prev => ({ ...prev, [filename]: texture }));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
loadAssets();
|
|
156
|
+
}, [data, loadedModels, loadedTextures]);
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
return <group ref={ref}>
|
|
161
|
+
<GameInstanceProvider models={loadedModels} onSelect={editMode ? onSelect : undefined} registerRef={registerRef}>
|
|
162
|
+
<GameObjectRenderer
|
|
163
|
+
gameObject={data.root}
|
|
164
|
+
selectedId={selectedId}
|
|
165
|
+
onSelect={editMode ? onSelect : undefined}
|
|
166
|
+
registerRef={registerRef}
|
|
167
|
+
loadedModels={loadedModels}
|
|
168
|
+
loadedTextures={loadedTextures}
|
|
169
|
+
editMode={editMode}
|
|
170
|
+
parentMatrix={new Matrix4()} // 👈 identity = world root
|
|
171
|
+
/>
|
|
172
|
+
</GameInstanceProvider>
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
{editMode && <>
|
|
176
|
+
<MapControls makeDefault />
|
|
177
|
+
|
|
178
|
+
{selectedId && selectedObject && (
|
|
179
|
+
<TransformControls
|
|
180
|
+
object={selectedObject}
|
|
181
|
+
mode={transformMode}
|
|
182
|
+
space="local"
|
|
183
|
+
onObjectChange={onTransformChange}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
</>}
|
|
187
|
+
</group>;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
interface GameObjectRendererProps {
|
|
191
|
+
gameObject?: GameObjectType | null;
|
|
192
|
+
selectedId?: string | null;
|
|
193
|
+
onSelect?: (id: string) => void;
|
|
194
|
+
registerRef: (id: string, obj: Object3D | null) => void;
|
|
195
|
+
loadedModels: Record<string, Object3D>;
|
|
196
|
+
loadedTextures: Record<string, Texture>;
|
|
197
|
+
editMode?: boolean;
|
|
198
|
+
parentMatrix?: Matrix4; // 👈 new
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
function GameObjectRenderer({
|
|
203
|
+
gameObject,
|
|
204
|
+
selectedId,
|
|
205
|
+
onSelect,
|
|
206
|
+
registerRef,
|
|
207
|
+
loadedModels,
|
|
208
|
+
loadedTextures,
|
|
209
|
+
editMode,
|
|
210
|
+
parentMatrix = new Matrix4(),
|
|
211
|
+
}: GameObjectRendererProps) {
|
|
212
|
+
|
|
213
|
+
// Early return if gameObject is null or undefined
|
|
214
|
+
if (!gameObject) return null;
|
|
215
|
+
|
|
216
|
+
// Build a small context object to avoid long param lists
|
|
217
|
+
const ctx = { gameObject, selectedId, onSelect, registerRef, loadedModels, loadedTextures, editMode };
|
|
218
|
+
|
|
219
|
+
// --- 1. Transform (local + world) ---
|
|
220
|
+
const transformProps = getNodeTransformProps(gameObject);
|
|
221
|
+
const localMatrix = new Matrix4().compose(
|
|
222
|
+
new Vector3(...transformProps.position),
|
|
223
|
+
new Quaternion().setFromEuler(new Euler(...transformProps.rotation)),
|
|
224
|
+
new Vector3(...transformProps.scale)
|
|
225
|
+
);
|
|
226
|
+
const worldMatrix = parentMatrix.clone().multiply(localMatrix);
|
|
227
|
+
|
|
228
|
+
// preserve click/drag detection from previous implementation
|
|
229
|
+
const clickValid = useRef(false);
|
|
230
|
+
const handlePointerDown = (e: ThreeEvent<PointerEvent>) => {
|
|
231
|
+
e.stopPropagation();
|
|
232
|
+
clickValid.current = true;
|
|
233
|
+
};
|
|
234
|
+
const handlePointerMove = () => {
|
|
235
|
+
if (clickValid.current) clickValid.current = false;
|
|
236
|
+
};
|
|
237
|
+
const handlePointerUp = (e: ThreeEvent<PointerEvent>) => {
|
|
238
|
+
if (clickValid.current) {
|
|
239
|
+
e.stopPropagation();
|
|
240
|
+
onSelect?.(gameObject.id);
|
|
241
|
+
}
|
|
242
|
+
clickValid.current = false;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if (!gameObject.enabled || !gameObject.visible) return null;
|
|
246
|
+
|
|
247
|
+
// --- 2. If instanced, short-circuit to a tiny clean branch ---
|
|
248
|
+
const isInstanced = !!gameObject.components?.model?.properties?.instanced;
|
|
249
|
+
if (isInstanced) {
|
|
250
|
+
return renderInstancedNode(gameObject, worldMatrix, ctx);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- 3. Core content decided by component registry ---
|
|
254
|
+
const core = renderCoreNode(gameObject, ctx, parentMatrix);
|
|
255
|
+
|
|
256
|
+
// --- 5. Render children (always relative transforms) ---
|
|
257
|
+
const children = (gameObject.children ?? []).map((child) => (
|
|
258
|
+
<GameObjectRenderer
|
|
259
|
+
key={child.id}
|
|
260
|
+
gameObject={child}
|
|
261
|
+
selectedId={selectedId}
|
|
262
|
+
onSelect={onSelect}
|
|
263
|
+
registerRef={registerRef}
|
|
264
|
+
loadedModels={loadedModels}
|
|
265
|
+
loadedTextures={loadedTextures}
|
|
266
|
+
editMode={editMode}
|
|
267
|
+
parentMatrix={worldMatrix}
|
|
268
|
+
/>
|
|
269
|
+
));
|
|
270
|
+
|
|
271
|
+
// --- 4. Wrap with physics if needed ---
|
|
272
|
+
// Combine core and children so they both get wrapped by physics (if present)
|
|
273
|
+
const content = (
|
|
274
|
+
<>
|
|
275
|
+
{core}
|
|
276
|
+
{children}
|
|
277
|
+
</>
|
|
278
|
+
);
|
|
279
|
+
const physicsWrapped = wrapPhysicsIfNeeded(gameObject, content, ctx);
|
|
280
|
+
|
|
281
|
+
// --- 6. Final group wrapper ---
|
|
282
|
+
return (
|
|
283
|
+
<group
|
|
284
|
+
ref={(el) => registerRef(gameObject.id, el)}
|
|
285
|
+
position={transformProps.position}
|
|
286
|
+
rotation={transformProps.rotation}
|
|
287
|
+
scale={transformProps.scale}
|
|
288
|
+
onPointerDown={handlePointerDown}
|
|
289
|
+
onPointerMove={handlePointerMove}
|
|
290
|
+
onPointerUp={handlePointerUp}
|
|
291
|
+
>
|
|
292
|
+
{physicsWrapped}
|
|
293
|
+
</group>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Helper: render an instanced GameInstance (terminal node)
|
|
298
|
+
function renderInstancedNode(gameObject: GameObjectType, worldMatrix: Matrix4, ctx: any) {
|
|
299
|
+
const physics = gameObject.components?.physics;
|
|
300
|
+
const wp = new Vector3();
|
|
301
|
+
const wq = new Quaternion();
|
|
302
|
+
const ws = new Vector3();
|
|
303
|
+
worldMatrix.decompose(wp, wq, ws);
|
|
304
|
+
const we = new Euler().setFromQuaternion(wq);
|
|
305
|
+
const modelUrl = gameObject.components?.model?.properties?.filename;
|
|
306
|
+
return (
|
|
307
|
+
<GameInstance
|
|
308
|
+
id={gameObject.id}
|
|
309
|
+
modelUrl={modelUrl}
|
|
310
|
+
position={[wp.x, wp.y, wp.z]}
|
|
311
|
+
rotation={[we.x, we.y, we.z]}
|
|
312
|
+
scale={[ws.x, ws.y, ws.z]}
|
|
313
|
+
physics={ctx.editMode ? undefined : (physics?.properties as any)}
|
|
314
|
+
/>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Helper: render main model/geometry content for a non-instanced node
|
|
319
|
+
function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matrix4 | undefined) {
|
|
320
|
+
const geometry = gameObject.components?.geometry;
|
|
321
|
+
const material = gameObject.components?.material;
|
|
322
|
+
const modelComp = gameObject.components?.model;
|
|
323
|
+
|
|
324
|
+
const geometryDef = geometry ? getComponent('Geometry') : undefined;
|
|
325
|
+
const materialDef = material ? getComponent('Material') : undefined;
|
|
326
|
+
|
|
327
|
+
const isModelAvailable = !!(modelComp && modelComp.properties && modelComp.properties.filename && ctx.loadedModels[modelComp.properties.filename]);
|
|
328
|
+
|
|
329
|
+
// Generic component views (exclude geometry/material/model)
|
|
330
|
+
const contextProps = {
|
|
331
|
+
loadedModels: ctx.loadedModels,
|
|
332
|
+
loadedTextures: ctx.loadedTextures,
|
|
333
|
+
isSelected: ctx.selectedId === gameObject.id,
|
|
334
|
+
editMode: ctx.editMode,
|
|
335
|
+
parentMatrix,
|
|
336
|
+
registerRef: ctx.registerRef,
|
|
337
|
+
};
|
|
338
|
+
const allComponentViews = gameObject.components
|
|
339
|
+
? Object.entries(gameObject.components)
|
|
340
|
+
.filter(([key]) => key !== 'geometry' && key !== 'material' && key !== 'model')
|
|
341
|
+
.map(([key, comp]) => {
|
|
342
|
+
const def = getComponent(key);
|
|
343
|
+
if (!def || !def.View || !comp) return null;
|
|
344
|
+
return <def.View key={key} properties={comp.properties} {...contextProps} />;
|
|
345
|
+
})
|
|
346
|
+
: null;
|
|
347
|
+
|
|
348
|
+
// If we have a model (non-instanced) render it as a primitive with material override
|
|
349
|
+
if (isModelAvailable) {
|
|
350
|
+
const modelObj = ctx.loadedModels[modelComp.properties.filename].clone();
|
|
351
|
+
return (
|
|
352
|
+
<primitive object={modelObj}>
|
|
353
|
+
{material && materialDef && materialDef.View && (
|
|
354
|
+
<materialDef.View
|
|
355
|
+
key="material"
|
|
356
|
+
properties={material.properties}
|
|
357
|
+
loadedTextures={ctx.loadedTextures}
|
|
358
|
+
isSelected={ctx.selectedId === gameObject.id}
|
|
359
|
+
editMode={ctx.editMode}
|
|
360
|
+
parentMatrix={parentMatrix}
|
|
361
|
+
registerRef={ctx.registerRef}
|
|
362
|
+
/>
|
|
363
|
+
)}
|
|
364
|
+
{allComponentViews}
|
|
365
|
+
</primitive>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Otherwise, if geometry present, render a mesh
|
|
370
|
+
if (geometry && geometryDef && geometryDef.View) {
|
|
371
|
+
return (
|
|
372
|
+
<mesh>
|
|
373
|
+
<geometryDef.View key="geometry" properties={geometry.properties} {...contextProps} />
|
|
374
|
+
{material && materialDef && materialDef.View && (
|
|
375
|
+
<materialDef.View
|
|
376
|
+
key="material"
|
|
377
|
+
properties={material.properties}
|
|
378
|
+
loadedTextures={ctx.loadedTextures}
|
|
379
|
+
isSelected={ctx.selectedId === gameObject.id}
|
|
380
|
+
editMode={ctx.editMode}
|
|
381
|
+
parentMatrix={parentMatrix}
|
|
382
|
+
registerRef={ctx.registerRef}
|
|
383
|
+
/>
|
|
384
|
+
)}
|
|
385
|
+
{allComponentViews}
|
|
386
|
+
</mesh>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Default: render other component views (no geometry/model)
|
|
391
|
+
return <>{allComponentViews}</>;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Helper: wrap core content with physics component when necessary
|
|
395
|
+
function wrapPhysicsIfNeeded(gameObject: GameObjectType, content: React.ReactNode, ctx: any) {
|
|
396
|
+
const physics = gameObject.components?.physics;
|
|
397
|
+
if (!physics) return content;
|
|
398
|
+
const physicsDef = getComponent('Physics');
|
|
399
|
+
if (!physicsDef || !physicsDef.View) return content;
|
|
400
|
+
return (
|
|
401
|
+
<physicsDef.View
|
|
402
|
+
properties={{ ...physics.properties, id: gameObject.id }}
|
|
403
|
+
registerRef={ctx.registerRef}
|
|
404
|
+
editMode={ctx.editMode}
|
|
405
|
+
>
|
|
406
|
+
{content}
|
|
407
|
+
</physicsDef.View>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
export default PrefabRoot;
|
|
416
|
+
|
|
417
|
+
function getNodeTransformProps(node?: GameObjectType | null) {
|
|
418
|
+
const t = node?.components?.transform?.properties;
|
|
419
|
+
return {
|
|
420
|
+
position: t?.position ?? [0, 0, 0],
|
|
421
|
+
rotation: t?.rotation ?? [0, 0, 0],
|
|
422
|
+
scale: t?.scale ?? [1, 1, 1],
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function computeParentWorldMatrix(root: GameObjectType, targetId: string): Matrix4 {
|
|
427
|
+
const identity = new Matrix4();
|
|
428
|
+
|
|
429
|
+
function traverse(node: GameObjectType, parentWorld: Matrix4): Matrix4 | null {
|
|
430
|
+
if (node.id === targetId) {
|
|
431
|
+
// parentWorld is what we want
|
|
432
|
+
return parentWorld.clone();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const { position, rotation, scale } = getNodeTransformProps(node);
|
|
436
|
+
|
|
437
|
+
const localPos = new Vector3(...position);
|
|
438
|
+
const localRot = new Euler(...rotation);
|
|
439
|
+
const localScale = new Vector3(...scale);
|
|
440
|
+
|
|
441
|
+
const localMat = new Matrix4().compose(
|
|
442
|
+
localPos,
|
|
443
|
+
new Quaternion().setFromEuler(localRot),
|
|
444
|
+
localScale
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
const worldMat = parentWorld.clone().multiply(localMat);
|
|
448
|
+
|
|
449
|
+
if (node.children) {
|
|
450
|
+
for (const child of node.children) {
|
|
451
|
+
const res = traverse(child, worldMat);
|
|
452
|
+
if (res) return res;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return traverse(root, identity) ?? identity;
|
|
460
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { FC } from "react";
|
|
2
|
+
|
|
3
|
+
export interface Component {
|
|
4
|
+
name: string;
|
|
5
|
+
Editor: FC<{ component: any; onUpdate: (newComp: any) => void; basePath?: string }>;
|
|
6
|
+
defaultProperties: any;
|
|
7
|
+
// Allow View to accept extra props for special cases (like material)
|
|
8
|
+
View?: FC<any>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const REGISTRY: Record<string, Component> = {};
|
|
12
|
+
|
|
13
|
+
export function registerComponent(component: Component) {
|
|
14
|
+
if (REGISTRY[component.name]) {
|
|
15
|
+
throw new Error(`Component with name ${component.name} already registered.`);
|
|
16
|
+
}
|
|
17
|
+
REGISTRY[component.name] = component;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getComponent(name: string): Component | undefined {
|
|
21
|
+
return REGISTRY[name];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getAllComponents(): Record<string, Component> {
|
|
25
|
+
return { ...REGISTRY };
|
|
26
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Component } from "./ComponentRegistry";
|
|
2
|
+
|
|
3
|
+
function GeometryComponentEditor({ component, onUpdate }: { component: any; onUpdate: (newComp: any) => void }) {
|
|
4
|
+
return <div>
|
|
5
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Type</label>
|
|
6
|
+
<select
|
|
7
|
+
className="w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50"
|
|
8
|
+
value={component.properties.geometryType}
|
|
9
|
+
onChange={e => onUpdate({ geometryType: e.target.value })}
|
|
10
|
+
>
|
|
11
|
+
<option value="box">Box</option>
|
|
12
|
+
<option value="sphere">Sphere</option>
|
|
13
|
+
<option value="plane">Plane</option>
|
|
14
|
+
</select>
|
|
15
|
+
</div>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// View for Geometry component
|
|
19
|
+
function GeometryComponentView({ properties, children }: { properties: any, children?: React.ReactNode }) {
|
|
20
|
+
const { geometryType, args = [] } = properties;
|
|
21
|
+
// Only return the geometry node, do not wrap in mesh or group
|
|
22
|
+
switch (geometryType) {
|
|
23
|
+
case "box":
|
|
24
|
+
return <boxGeometry args={args as [number, number, number]} />;
|
|
25
|
+
case "sphere":
|
|
26
|
+
return <sphereGeometry args={args as [number, number?, number?]} />;
|
|
27
|
+
case "plane":
|
|
28
|
+
return <planeGeometry args={args as [number, number]} />;
|
|
29
|
+
default:
|
|
30
|
+
return <boxGeometry args={[1, 1, 1]} />;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const GeometryComponent: Component = {
|
|
35
|
+
name: 'Geometry',
|
|
36
|
+
Editor: GeometryComponentEditor,
|
|
37
|
+
View: GeometryComponentView,
|
|
38
|
+
defaultProperties: {
|
|
39
|
+
geometryType: 'box'
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default GeometryComponent;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { TextureListViewer } from '../../assetviewer/page';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Component } from './ComponentRegistry';
|
|
4
|
+
|
|
5
|
+
function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { component: any; onUpdate: (newComp: any) => void; basePath?: string }) {
|
|
6
|
+
const [textureFiles, setTextureFiles] = useState<string[]>([]);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const base = basePath ? `${basePath}/` : '';
|
|
10
|
+
fetch(`/${base}textures/manifest.json`)
|
|
11
|
+
.then(r => r.json())
|
|
12
|
+
.then(data => setTextureFiles(Array.isArray(data) ? data : data.files || []))
|
|
13
|
+
.catch(console.error);
|
|
14
|
+
}, [basePath]);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-col">
|
|
18
|
+
<div className="mb-1">
|
|
19
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Color</label>
|
|
20
|
+
<div className="flex gap-0.5">
|
|
21
|
+
<input
|
|
22
|
+
type="color"
|
|
23
|
+
className="h-5 w-5 bg-transparent border-none cursor-pointer"
|
|
24
|
+
value={component.properties.color}
|
|
25
|
+
onChange={e => onUpdate({ 'color': e.target.value })}
|
|
26
|
+
/>
|
|
27
|
+
<input
|
|
28
|
+
type="text"
|
|
29
|
+
className="flex-1 bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50"
|
|
30
|
+
value={component.properties.color}
|
|
31
|
+
onChange={e => onUpdate({ 'color': e.target.value })}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="flex items-center gap-1 mb-1">
|
|
36
|
+
<input
|
|
37
|
+
type="checkbox"
|
|
38
|
+
className="w-3 h-3"
|
|
39
|
+
checked={component.properties.wireframe || false}
|
|
40
|
+
onChange={e => onUpdate({ 'wireframe': e.target.checked })}
|
|
41
|
+
/>
|
|
42
|
+
<label className="text-[9px] text-cyan-400/60">Wireframe</label>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div>
|
|
46
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Texture</label>
|
|
47
|
+
<div className="max-h-32 overflow-y-auto">
|
|
48
|
+
<TextureListViewer
|
|
49
|
+
files={textureFiles}
|
|
50
|
+
selected={component.properties.texture || undefined}
|
|
51
|
+
onSelect={(file) => onUpdate({ 'texture': file })}
|
|
52
|
+
basePath={basePath}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{component.properties.texture && (
|
|
58
|
+
<div className="border-t border-cyan-500/20 pt-1 mt-1">
|
|
59
|
+
<div className="flex items-center gap-1 mb-1">
|
|
60
|
+
<input
|
|
61
|
+
type="checkbox"
|
|
62
|
+
className="w-3 h-3"
|
|
63
|
+
checked={component.properties.repeat || false}
|
|
64
|
+
onChange={e => onUpdate({ 'repeat': e.target.checked })}
|
|
65
|
+
/>
|
|
66
|
+
<label className="text-[9px] text-cyan-400/60">Repeat Texture</label>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{component.properties.repeat && (
|
|
70
|
+
<div>
|
|
71
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Repeat (X, Y)</label>
|
|
72
|
+
<div className="flex gap-0.5">
|
|
73
|
+
<input
|
|
74
|
+
type="number"
|
|
75
|
+
className="w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50"
|
|
76
|
+
value={component.properties.repeatCount?.[0] ?? 1}
|
|
77
|
+
onChange={e => {
|
|
78
|
+
const y = component.properties.repeatCount?.[1] ?? 1;
|
|
79
|
+
onUpdate({ 'repeatCount': [parseFloat(e.target.value), y] });
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
<input
|
|
83
|
+
type="number"
|
|
84
|
+
className="w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50"
|
|
85
|
+
value={component.properties.repeatCount?.[1] ?? 1}
|
|
86
|
+
onChange={e => {
|
|
87
|
+
const x = component.properties.repeatCount?.[0] ?? 1;
|
|
88
|
+
onUpdate({ 'repeatCount': [x, parseFloat(e.target.value)] });
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
import { useMemo } from 'react';
|
|
102
|
+
import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace, Texture } from 'three';
|
|
103
|
+
|
|
104
|
+
// View for Material component
|
|
105
|
+
function MaterialComponentView({ properties, loadedTextures, isSelected }: { properties: any, loadedTextures?: Record<string, Texture>, isSelected?: boolean }) {
|
|
106
|
+
const textureName = properties?.texture;
|
|
107
|
+
const repeat = properties?.repeat;
|
|
108
|
+
const repeatCount = properties?.repeatCount;
|
|
109
|
+
const texture = textureName && loadedTextures ? loadedTextures[textureName] : undefined;
|
|
110
|
+
|
|
111
|
+
const finalTexture = useMemo(() => {
|
|
112
|
+
if (!texture) return undefined;
|
|
113
|
+
const t = texture.clone();
|
|
114
|
+
if (repeat) {
|
|
115
|
+
t.wrapS = t.wrapT = RepeatWrapping;
|
|
116
|
+
if (repeatCount) t.repeat.set(repeatCount[0], repeatCount[1]);
|
|
117
|
+
} else {
|
|
118
|
+
t.wrapS = t.wrapT = ClampToEdgeWrapping;
|
|
119
|
+
t.repeat.set(1, 1);
|
|
120
|
+
}
|
|
121
|
+
t.colorSpace = SRGBColorSpace;
|
|
122
|
+
t.needsUpdate = true;
|
|
123
|
+
return t;
|
|
124
|
+
}, [texture, repeat, repeatCount?.[0], repeatCount?.[1]]);
|
|
125
|
+
|
|
126
|
+
if (!properties) {
|
|
127
|
+
return <meshStandardMaterial color="red" wireframe />;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { color, wireframe = false } = properties;
|
|
131
|
+
const displayColor = isSelected ? "cyan" : color;
|
|
132
|
+
|
|
133
|
+
return <meshStandardMaterial
|
|
134
|
+
key={finalTexture?.uuid ?? 'no-texture'}
|
|
135
|
+
color={displayColor}
|
|
136
|
+
wireframe={wireframe}
|
|
137
|
+
map={finalTexture}
|
|
138
|
+
transparent={!!finalTexture}
|
|
139
|
+
side={DoubleSide}
|
|
140
|
+
/>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const MaterialComponent: Component = {
|
|
144
|
+
name: 'Material',
|
|
145
|
+
Editor: MaterialComponentEditor,
|
|
146
|
+
View: MaterialComponentView,
|
|
147
|
+
defaultProperties: {
|
|
148
|
+
color: '#ffffff',
|
|
149
|
+
wireframe: false
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export default MaterialComponent;
|