react-three-game 0.0.17 → 0.0.19
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/.github/copilot-instructions.md +54 -183
- package/README.md +69 -214
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -0
- package/dist/tools/prefabeditor/EditorTree.d.ts +2 -4
- package/dist/tools/prefabeditor/EditorTree.js +20 -194
- package/dist/tools/prefabeditor/EditorUI.js +43 -224
- package/dist/tools/prefabeditor/InstanceProvider.d.ts +4 -4
- package/dist/tools/prefabeditor/InstanceProvider.js +21 -13
- package/dist/tools/prefabeditor/PrefabEditor.js +33 -99
- package/dist/tools/prefabeditor/PrefabRoot.d.ts +0 -1
- package/dist/tools/prefabeditor/PrefabRoot.js +33 -50
- package/dist/tools/prefabeditor/components/DirectionalLightComponent.d.ts +3 -0
- package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +102 -0
- package/dist/tools/prefabeditor/components/ModelComponent.js +12 -4
- package/dist/tools/prefabeditor/components/SpotLightComponent.js +10 -5
- package/dist/tools/prefabeditor/components/index.js +2 -0
- package/dist/tools/prefabeditor/hooks/useModelLoader.d.ts +10 -0
- package/dist/tools/prefabeditor/hooks/useModelLoader.js +40 -0
- package/dist/tools/prefabeditor/styles.d.ts +1809 -0
- package/dist/tools/prefabeditor/styles.js +168 -0
- package/dist/tools/prefabeditor/types.d.ts +3 -14
- package/dist/tools/prefabeditor/types.js +0 -1
- package/dist/tools/prefabeditor/utils.d.ts +19 -0
- package/dist/tools/prefabeditor/utils.js +72 -0
- package/package.json +3 -3
- package/src/index.ts +5 -1
- package/src/tools/prefabeditor/EditorTree.tsx +38 -270
- package/src/tools/prefabeditor/EditorUI.tsx +105 -322
- package/src/tools/prefabeditor/InstanceProvider.tsx +43 -32
- package/src/tools/prefabeditor/PrefabEditor.tsx +40 -151
- package/src/tools/prefabeditor/PrefabRoot.tsx +41 -73
- package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +317 -0
- package/src/tools/prefabeditor/components/ModelComponent.tsx +14 -4
- package/src/tools/prefabeditor/components/SpotLightComponent.tsx +27 -7
- package/src/tools/prefabeditor/components/index.ts +2 -0
- package/src/tools/prefabeditor/styles.ts +195 -0
- package/src/tools/prefabeditor/types.ts +4 -12
- package/src/tools/prefabeditor/utils.ts +80 -0
|
@@ -1,31 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { MapControls, TransformControls } from "@react-three/drei";
|
|
4
|
-
import { useState, useRef, useEffect, forwardRef,
|
|
5
|
-
import { Vector3, Euler, Quaternion,
|
|
4
|
+
import { useState, useRef, useEffect, forwardRef, useCallback } from "react";
|
|
5
|
+
import { Vector3, Euler, Quaternion, Group, Object3D, SRGBColorSpace, Texture, TextureLoader, Matrix4 } from "three";
|
|
6
6
|
import { Prefab, GameObject as GameObjectType } from "./types";
|
|
7
|
-
import { getComponent } from "./components/ComponentRegistry";
|
|
7
|
+
import { getComponent, registerComponent } from "./components/ComponentRegistry";
|
|
8
8
|
import { ThreeEvent } from "@react-three/fiber";
|
|
9
9
|
import { loadModel } from "../dragdrop/modelLoader";
|
|
10
10
|
import { GameInstance, GameInstanceProvider } from "./InstanceProvider";
|
|
11
|
-
|
|
12
|
-
// register all components
|
|
13
|
-
import { registerComponent } from './components/ComponentRegistry';
|
|
11
|
+
import { updateNode } from "./utils";
|
|
14
12
|
import components from './components/';
|
|
15
|
-
components.forEach(registerComponent);
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
}
|
|
14
|
+
// Register all components
|
|
15
|
+
components.forEach(registerComponent);
|
|
29
16
|
|
|
30
17
|
export const PrefabRoot = forwardRef<Group, {
|
|
31
18
|
editMode?: boolean;
|
|
@@ -34,29 +21,21 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
34
21
|
selectedId?: string | null;
|
|
35
22
|
onSelect?: (id: string | null) => void;
|
|
36
23
|
transformMode?: "translate" | "rotate" | "scale";
|
|
37
|
-
setTransformMode?: (mode: "translate" | "rotate" | "scale") => void;
|
|
38
24
|
basePath?: string;
|
|
39
|
-
}>(({ editMode, data, onPrefabChange, selectedId, onSelect, transformMode,
|
|
25
|
+
}>(({ editMode, data, onPrefabChange, selectedId, onSelect, transformMode, basePath = "" }, ref) => {
|
|
40
26
|
const [loadedModels, setLoadedModels] = useState<Record<string, Object3D>>({});
|
|
41
27
|
const [loadedTextures, setLoadedTextures] = useState<Record<string, Texture>>({});
|
|
42
|
-
// const [prefabRoot, setPrefabRoot] = useState<Prefab>(data); // Removed local state
|
|
43
28
|
const loadingRefs = useRef<Set<string>>(new Set());
|
|
44
29
|
const objectRefs = useRef<Record<string, Object3D | null>>({});
|
|
45
30
|
const [selectedObject, setSelectedObject] = useState<Object3D | null>(null);
|
|
46
31
|
|
|
47
32
|
const registerRef = useCallback((id: string, obj: Object3D | null) => {
|
|
48
33
|
objectRefs.current[id] = obj;
|
|
49
|
-
if (id === selectedId)
|
|
50
|
-
setSelectedObject(obj);
|
|
51
|
-
}
|
|
34
|
+
if (id === selectedId) setSelectedObject(obj);
|
|
52
35
|
}, [selectedId]);
|
|
53
36
|
|
|
54
37
|
useEffect(() => {
|
|
55
|
-
|
|
56
|
-
setSelectedObject(objectRefs.current[selectedId] || null);
|
|
57
|
-
} else {
|
|
58
|
-
setSelectedObject(null);
|
|
59
|
-
}
|
|
38
|
+
setSelectedObject(selectedId ? objectRefs.current[selectedId] || null : null);
|
|
60
39
|
}, [selectedId]);
|
|
61
40
|
|
|
62
41
|
const onTransformChange = () => {
|
|
@@ -82,10 +61,10 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
82
61
|
const le = new Euler().setFromQuaternion(lq);
|
|
83
62
|
|
|
84
63
|
// 4. Write back LOCAL transform into the prefab node
|
|
85
|
-
const newRoot =
|
|
64
|
+
const newRoot = updateNode(data.root, selectedId, (node) => ({
|
|
86
65
|
...node,
|
|
87
66
|
components: {
|
|
88
|
-
...node
|
|
67
|
+
...node.components,
|
|
89
68
|
transform: {
|
|
90
69
|
type: "Transform",
|
|
91
70
|
properties: {
|
|
@@ -203,11 +182,12 @@ function GameObjectRenderer({
|
|
|
203
182
|
|
|
204
183
|
// Early return if gameObject is null or undefined
|
|
205
184
|
if (!gameObject) return null;
|
|
185
|
+
if (gameObject.disabled === true || gameObject.hidden === true) return null;
|
|
206
186
|
|
|
207
|
-
// Build
|
|
187
|
+
// Build context object for passing to helper functions
|
|
208
188
|
const ctx = { gameObject, selectedId, onSelect, registerRef, loadedModels, loadedTextures, editMode };
|
|
209
189
|
|
|
210
|
-
// --- 1.
|
|
190
|
+
// --- 1. Compute transforms (local + world) ---
|
|
211
191
|
const transformProps = getNodeTransformProps(gameObject);
|
|
212
192
|
const localMatrix = new Matrix4().compose(
|
|
213
193
|
new Vector3(...transformProps.position),
|
|
@@ -216,7 +196,7 @@ function GameObjectRenderer({
|
|
|
216
196
|
);
|
|
217
197
|
const worldMatrix = parentMatrix.clone().multiply(localMatrix);
|
|
218
198
|
|
|
219
|
-
//
|
|
199
|
+
// --- 2. Handle selection interaction (edit mode only) ---
|
|
220
200
|
const clickValid = useRef(false);
|
|
221
201
|
const handlePointerDown = (e: ThreeEvent<PointerEvent>) => {
|
|
222
202
|
e.stopPropagation();
|
|
@@ -233,18 +213,19 @@ function GameObjectRenderer({
|
|
|
233
213
|
clickValid.current = false;
|
|
234
214
|
};
|
|
235
215
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
// --- 2. If instanced, short-circuit to a tiny clean branch ---
|
|
216
|
+
// --- 3. If instanced model, short-circuit to GameInstance (terminal node) ---
|
|
239
217
|
const isInstanced = !!gameObject.components?.model?.properties?.instanced;
|
|
240
218
|
if (isInstanced) {
|
|
241
219
|
return renderInstancedNode(gameObject, worldMatrix, ctx);
|
|
242
220
|
}
|
|
243
221
|
|
|
244
|
-
// ---
|
|
222
|
+
// --- 4. Render core content using component system ---
|
|
245
223
|
const core = renderCoreNode(gameObject, ctx, parentMatrix);
|
|
246
224
|
|
|
247
|
-
// --- 5.
|
|
225
|
+
// --- 5. Wrap with physics if needed (except in edit mode) ---
|
|
226
|
+
const physicsWrapped = wrapPhysicsIfNeeded(gameObject, core, ctx);
|
|
227
|
+
|
|
228
|
+
// --- 6. Render children recursively (always relative transforms) ---
|
|
248
229
|
const children = (gameObject.children ?? []).map((child) => (
|
|
249
230
|
<GameObjectRenderer
|
|
250
231
|
key={child.id}
|
|
@@ -259,10 +240,7 @@ function GameObjectRenderer({
|
|
|
259
240
|
/>
|
|
260
241
|
));
|
|
261
242
|
|
|
262
|
-
// ---
|
|
263
|
-
const physicsWrapped = wrapPhysicsIfNeeded(gameObject, core, ctx);
|
|
264
|
-
|
|
265
|
-
// --- 6. Final group wrapper ---
|
|
243
|
+
// --- 7. Final group wrapper with local transform ---
|
|
266
244
|
return (
|
|
267
245
|
<group
|
|
268
246
|
ref={(el) => registerRef(gameObject.id, el)}
|
|
@@ -300,18 +278,17 @@ function renderInstancedNode(gameObject: GameObjectType, worldMatrix: Matrix4, c
|
|
|
300
278
|
);
|
|
301
279
|
}
|
|
302
280
|
|
|
303
|
-
// Helper: render main
|
|
281
|
+
// Helper: render main content for a non-instanced node using the component system
|
|
304
282
|
function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matrix4 | undefined) {
|
|
305
283
|
const geometry = gameObject.components?.geometry;
|
|
306
284
|
const material = gameObject.components?.material;
|
|
307
|
-
const
|
|
285
|
+
const model = gameObject.components?.model;
|
|
308
286
|
|
|
309
287
|
const geometryDef = geometry ? getComponent('Geometry') : undefined;
|
|
310
288
|
const materialDef = material ? getComponent('Material') : undefined;
|
|
289
|
+
const modelDef = model ? getComponent('Model') : undefined;
|
|
311
290
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
// Generic component views (exclude geometry/material/model/transform/physics)
|
|
291
|
+
// Context props for all component Views
|
|
315
292
|
const contextProps = {
|
|
316
293
|
loadedModels: ctx.loadedModels,
|
|
317
294
|
loadedTextures: ctx.loadedTextures,
|
|
@@ -321,20 +298,19 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
|
|
|
321
298
|
registerRef: ctx.registerRef,
|
|
322
299
|
};
|
|
323
300
|
|
|
324
|
-
//
|
|
301
|
+
// Collect wrapper and leaf components (excluding transform/physics which are handled separately)
|
|
325
302
|
const wrapperComponents: Array<{ key: string; View: any; properties: any }> = [];
|
|
326
303
|
const leafComponents: React.ReactNode[] = [];
|
|
327
304
|
|
|
328
305
|
if (gameObject.components) {
|
|
329
306
|
Object.entries(gameObject.components)
|
|
330
|
-
.filter(([key]) =>
|
|
307
|
+
.filter(([key]) => !['geometry', 'material', 'model', 'transform', 'physics'].includes(key))
|
|
331
308
|
.forEach(([key, comp]) => {
|
|
332
309
|
if (!comp || !comp.type) return;
|
|
333
310
|
const def = getComponent(comp.type);
|
|
334
311
|
if (!def || !def.View) return;
|
|
335
312
|
|
|
336
|
-
//
|
|
337
|
-
// Components that wrap content should accept children prop
|
|
313
|
+
// Components that accept children are wrappers, others are leaves
|
|
338
314
|
const viewString = def.View.toString();
|
|
339
315
|
if (viewString.includes('children')) {
|
|
340
316
|
wrapperComponents.push({ key, View: def.View, properties: comp.properties });
|
|
@@ -344,49 +320,41 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
|
|
|
344
320
|
});
|
|
345
321
|
}
|
|
346
322
|
|
|
347
|
-
// Build
|
|
323
|
+
// Build core content based on what components exist
|
|
348
324
|
let coreContent: React.ReactNode;
|
|
349
325
|
|
|
350
|
-
//
|
|
351
|
-
if (
|
|
352
|
-
|
|
326
|
+
// Priority: Model > Geometry + Material > Empty
|
|
327
|
+
if (model && modelDef && modelDef.View) {
|
|
328
|
+
// Model component wraps its children (including material override)
|
|
353
329
|
coreContent = (
|
|
354
|
-
<
|
|
330
|
+
<modelDef.View properties={model.properties} {...contextProps}>
|
|
355
331
|
{material && materialDef && materialDef.View && (
|
|
356
332
|
<materialDef.View
|
|
357
333
|
key="material"
|
|
358
334
|
properties={material.properties}
|
|
359
|
-
|
|
360
|
-
isSelected={ctx.selectedId === gameObject.id}
|
|
361
|
-
editMode={ctx.editMode}
|
|
362
|
-
parentMatrix={parentMatrix}
|
|
363
|
-
registerRef={ctx.registerRef}
|
|
335
|
+
{...contextProps}
|
|
364
336
|
/>
|
|
365
337
|
)}
|
|
366
338
|
{leafComponents}
|
|
367
|
-
</
|
|
339
|
+
</modelDef.View>
|
|
368
340
|
);
|
|
369
341
|
} else if (geometry && geometryDef && geometryDef.View) {
|
|
370
|
-
//
|
|
342
|
+
// Geometry + Material = mesh
|
|
371
343
|
coreContent = (
|
|
372
|
-
<mesh>
|
|
373
|
-
<geometryDef.View
|
|
344
|
+
<mesh castShadow receiveShadow>
|
|
345
|
+
<geometryDef.View properties={geometry.properties} {...contextProps} />
|
|
374
346
|
{material && materialDef && materialDef.View && (
|
|
375
347
|
<materialDef.View
|
|
376
348
|
key="material"
|
|
377
349
|
properties={material.properties}
|
|
378
|
-
|
|
379
|
-
isSelected={ctx.selectedId === gameObject.id}
|
|
380
|
-
editMode={ctx.editMode}
|
|
381
|
-
parentMatrix={parentMatrix}
|
|
382
|
-
registerRef={ctx.registerRef}
|
|
350
|
+
{...contextProps}
|
|
383
351
|
/>
|
|
384
352
|
)}
|
|
385
353
|
{leafComponents}
|
|
386
354
|
</mesh>
|
|
387
355
|
);
|
|
388
356
|
} else {
|
|
389
|
-
// No
|
|
357
|
+
// No visual component - just render leaves
|
|
390
358
|
coreContent = <>{leafComponents}</>;
|
|
391
359
|
}
|
|
392
360
|
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { Component } from "./ComponentRegistry";
|
|
2
|
+
import { useRef, useEffect } from "react";
|
|
3
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
4
|
+
import { CameraHelper, DirectionalLight, Object3D, Vector3 } from "three";
|
|
5
|
+
|
|
6
|
+
function DirectionalLightComponentEditor({ component, onUpdate }: { component: any; onUpdate: (newComp: any) => void }) {
|
|
7
|
+
const props = {
|
|
8
|
+
color: component.properties.color ?? '#ffffff',
|
|
9
|
+
intensity: component.properties.intensity ?? 1.0,
|
|
10
|
+
castShadow: component.properties.castShadow ?? true,
|
|
11
|
+
shadowMapSize: component.properties.shadowMapSize ?? 1024,
|
|
12
|
+
shadowCameraNear: component.properties.shadowCameraNear ?? 0.1,
|
|
13
|
+
shadowCameraFar: component.properties.shadowCameraFar ?? 100,
|
|
14
|
+
shadowCameraTop: component.properties.shadowCameraTop ?? 30,
|
|
15
|
+
shadowCameraBottom: component.properties.shadowCameraBottom ?? -30,
|
|
16
|
+
shadowCameraLeft: component.properties.shadowCameraLeft ?? -30,
|
|
17
|
+
shadowCameraRight: component.properties.shadowCameraRight ?? 30,
|
|
18
|
+
targetOffset: component.properties.targetOffset ?? [0, -5, 0]
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
return <div className="flex flex-col gap-2">
|
|
22
|
+
<div>
|
|
23
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Color</label>
|
|
24
|
+
<div className="flex gap-0.5">
|
|
25
|
+
<input
|
|
26
|
+
type="color"
|
|
27
|
+
className="h-5 w-5 bg-transparent border-none cursor-pointer"
|
|
28
|
+
value={props.color}
|
|
29
|
+
onChange={e => onUpdate({ ...component.properties, 'color': e.target.value })}
|
|
30
|
+
/>
|
|
31
|
+
<input
|
|
32
|
+
type="text"
|
|
33
|
+
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"
|
|
34
|
+
value={props.color}
|
|
35
|
+
onChange={e => onUpdate({ ...component.properties, 'color': e.target.value })}
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div>
|
|
40
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Intensity</label>
|
|
41
|
+
<input
|
|
42
|
+
type="number"
|
|
43
|
+
step="0.1"
|
|
44
|
+
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"
|
|
45
|
+
value={props.intensity}
|
|
46
|
+
onChange={e => onUpdate({ ...component.properties, 'intensity': parseFloat(e.target.value) })}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
<div>
|
|
50
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Cast Shadow</label>
|
|
51
|
+
<input
|
|
52
|
+
type="checkbox"
|
|
53
|
+
className="h-4 w-4 bg-black/40 border border-cyan-500/30 cursor-pointer"
|
|
54
|
+
checked={props.castShadow}
|
|
55
|
+
onChange={e => onUpdate({ ...component.properties, 'castShadow': e.target.checked })}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
<div>
|
|
59
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Shadow Map Size</label>
|
|
60
|
+
<input
|
|
61
|
+
type="number"
|
|
62
|
+
step="256"
|
|
63
|
+
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"
|
|
64
|
+
value={props.shadowMapSize}
|
|
65
|
+
onChange={e => onUpdate({ ...component.properties, 'shadowMapSize': parseFloat(e.target.value) })}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
<div className="border-t border-cyan-500/20 pt-2 mt-2">
|
|
69
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1">Shadow Camera</label>
|
|
70
|
+
<div className="grid grid-cols-2 gap-1">
|
|
71
|
+
<div>
|
|
72
|
+
<label className="block text-[8px] text-cyan-400/50 mb-0.5">Near</label>
|
|
73
|
+
<input
|
|
74
|
+
type="number"
|
|
75
|
+
step="0.1"
|
|
76
|
+
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"
|
|
77
|
+
value={props.shadowCameraNear}
|
|
78
|
+
onChange={e => onUpdate({ ...component.properties, 'shadowCameraNear': parseFloat(e.target.value) })}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
<div>
|
|
82
|
+
<label className="block text-[8px] text-cyan-400/50 mb-0.5">Far</label>
|
|
83
|
+
<input
|
|
84
|
+
type="number"
|
|
85
|
+
step="1"
|
|
86
|
+
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"
|
|
87
|
+
value={props.shadowCameraFar}
|
|
88
|
+
onChange={e => onUpdate({ ...component.properties, 'shadowCameraFar': parseFloat(e.target.value) })}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
<div>
|
|
92
|
+
<label className="block text-[8px] text-cyan-400/50 mb-0.5">Top</label>
|
|
93
|
+
<input
|
|
94
|
+
type="number"
|
|
95
|
+
step="1"
|
|
96
|
+
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"
|
|
97
|
+
value={props.shadowCameraTop}
|
|
98
|
+
onChange={e => onUpdate({ ...component.properties, 'shadowCameraTop': parseFloat(e.target.value) })}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
<div>
|
|
102
|
+
<label className="block text-[8px] text-cyan-400/50 mb-0.5">Bottom</label>
|
|
103
|
+
<input
|
|
104
|
+
type="number"
|
|
105
|
+
step="1"
|
|
106
|
+
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"
|
|
107
|
+
value={props.shadowCameraBottom}
|
|
108
|
+
onChange={e => onUpdate({ ...component.properties, 'shadowCameraBottom': parseFloat(e.target.value) })}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
<div>
|
|
112
|
+
<label className="block text-[8px] text-cyan-400/50 mb-0.5">Left</label>
|
|
113
|
+
<input
|
|
114
|
+
type="number"
|
|
115
|
+
step="1"
|
|
116
|
+
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"
|
|
117
|
+
value={props.shadowCameraLeft}
|
|
118
|
+
onChange={e => onUpdate({ ...component.properties, 'shadowCameraLeft': parseFloat(e.target.value) })}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
<div>
|
|
122
|
+
<label className="block text-[8px] text-cyan-400/50 mb-0.5">Right</label>
|
|
123
|
+
<input
|
|
124
|
+
type="number"
|
|
125
|
+
step="1"
|
|
126
|
+
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"
|
|
127
|
+
value={props.shadowCameraRight}
|
|
128
|
+
onChange={e => onUpdate({ ...component.properties, 'shadowCameraRight': parseFloat(e.target.value) })}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="border-t border-cyan-500/20 pt-2 mt-2">
|
|
134
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1">Target Offset</label>
|
|
135
|
+
<div className="grid grid-cols-3 gap-1">
|
|
136
|
+
<div>
|
|
137
|
+
<label className="block text-[8px] text-cyan-400/50 mb-0.5">X</label>
|
|
138
|
+
<input
|
|
139
|
+
type="number"
|
|
140
|
+
step="0.5"
|
|
141
|
+
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"
|
|
142
|
+
value={props.targetOffset[0]}
|
|
143
|
+
onChange={e => onUpdate({
|
|
144
|
+
...component.properties,
|
|
145
|
+
'targetOffset': [parseFloat(e.target.value), props.targetOffset[1], props.targetOffset[2]]
|
|
146
|
+
})}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
<div>
|
|
150
|
+
<label className="block text-[8px] text-cyan-400/50 mb-0.5">Y</label>
|
|
151
|
+
<input
|
|
152
|
+
type="number"
|
|
153
|
+
step="0.5"
|
|
154
|
+
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"
|
|
155
|
+
value={props.targetOffset[1]}
|
|
156
|
+
onChange={e => onUpdate({
|
|
157
|
+
...component.properties,
|
|
158
|
+
'targetOffset': [props.targetOffset[0], parseFloat(e.target.value), props.targetOffset[2]]
|
|
159
|
+
})}
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
<div>
|
|
163
|
+
<label className="block text-[8px] text-cyan-400/50 mb-0.5">Z</label>
|
|
164
|
+
<input
|
|
165
|
+
type="number"
|
|
166
|
+
step="0.5"
|
|
167
|
+
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"
|
|
168
|
+
value={props.targetOffset[2]}
|
|
169
|
+
onChange={e => onUpdate({
|
|
170
|
+
...component.properties,
|
|
171
|
+
'targetOffset': [props.targetOffset[0], props.targetOffset[1], parseFloat(e.target.value)]
|
|
172
|
+
})}
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function DirectionalLightView({ properties, editMode }: { properties: any; editMode?: boolean }) {
|
|
181
|
+
const color = properties.color ?? '#ffffff';
|
|
182
|
+
const intensity = properties.intensity ?? 1.0;
|
|
183
|
+
const castShadow = properties.castShadow ?? true;
|
|
184
|
+
const shadowMapSize = properties.shadowMapSize ?? 1024;
|
|
185
|
+
const shadowCameraNear = properties.shadowCameraNear ?? 0.1;
|
|
186
|
+
const shadowCameraFar = properties.shadowCameraFar ?? 100;
|
|
187
|
+
const shadowCameraTop = properties.shadowCameraTop ?? 30;
|
|
188
|
+
const shadowCameraBottom = properties.shadowCameraBottom ?? -30;
|
|
189
|
+
const shadowCameraLeft = properties.shadowCameraLeft ?? -30;
|
|
190
|
+
const shadowCameraRight = properties.shadowCameraRight ?? 30;
|
|
191
|
+
const targetOffset = properties.targetOffset ?? [0, -5, 0];
|
|
192
|
+
|
|
193
|
+
const { scene } = useThree();
|
|
194
|
+
const directionalLightRef = useRef<DirectionalLight>(null);
|
|
195
|
+
const targetRef = useRef<Object3D>(new Object3D());
|
|
196
|
+
const cameraHelperRef = useRef<CameraHelper | null>(null);
|
|
197
|
+
|
|
198
|
+
// Add target to scene once
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
const target = targetRef.current;
|
|
201
|
+
scene.add(target);
|
|
202
|
+
return () => {
|
|
203
|
+
scene.remove(target);
|
|
204
|
+
};
|
|
205
|
+
}, [scene]);
|
|
206
|
+
|
|
207
|
+
// Set up light target reference once
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (directionalLightRef.current) {
|
|
210
|
+
directionalLightRef.current.target = targetRef.current;
|
|
211
|
+
}
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
// Update target position and mark shadow for update when light moves or offset changes
|
|
215
|
+
useFrame(() => {
|
|
216
|
+
if (!directionalLightRef.current) return;
|
|
217
|
+
|
|
218
|
+
const lightWorldPos = new Vector3();
|
|
219
|
+
directionalLightRef.current.getWorldPosition(lightWorldPos);
|
|
220
|
+
|
|
221
|
+
const newTargetPos = new Vector3(
|
|
222
|
+
lightWorldPos.x + targetOffset[0],
|
|
223
|
+
lightWorldPos.y + targetOffset[1],
|
|
224
|
+
lightWorldPos.z + targetOffset[2]
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// Only update if position actually changed
|
|
228
|
+
if (!targetRef.current.position.equals(newTargetPos)) {
|
|
229
|
+
targetRef.current.position.copy(newTargetPos);
|
|
230
|
+
if (directionalLightRef.current.shadow) {
|
|
231
|
+
directionalLightRef.current.shadow.needsUpdate = true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Update camera helper in edit mode
|
|
236
|
+
if (editMode && cameraHelperRef.current) {
|
|
237
|
+
cameraHelperRef.current.update();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Create/destroy camera helper for edit mode
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (editMode && directionalLightRef.current?.shadow.camera) {
|
|
244
|
+
const helper = new CameraHelper(directionalLightRef.current.shadow.camera);
|
|
245
|
+
cameraHelperRef.current = helper;
|
|
246
|
+
scene.add(helper);
|
|
247
|
+
|
|
248
|
+
return () => {
|
|
249
|
+
if (cameraHelperRef.current) {
|
|
250
|
+
scene.remove(cameraHelperRef.current);
|
|
251
|
+
cameraHelperRef.current.dispose();
|
|
252
|
+
cameraHelperRef.current = null;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}, [editMode, scene]);
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<>
|
|
260
|
+
<directionalLight
|
|
261
|
+
ref={directionalLightRef}
|
|
262
|
+
color={color}
|
|
263
|
+
intensity={intensity}
|
|
264
|
+
castShadow={castShadow}
|
|
265
|
+
shadow-mapSize={[shadowMapSize, shadowMapSize]}
|
|
266
|
+
shadow-bias={-0.001}
|
|
267
|
+
shadow-normalBias={0.02}
|
|
268
|
+
>
|
|
269
|
+
<orthographicCamera
|
|
270
|
+
attach="shadow-camera"
|
|
271
|
+
near={shadowCameraNear}
|
|
272
|
+
far={shadowCameraFar}
|
|
273
|
+
top={shadowCameraTop}
|
|
274
|
+
bottom={shadowCameraBottom}
|
|
275
|
+
left={shadowCameraLeft}
|
|
276
|
+
right={shadowCameraRight}
|
|
277
|
+
/>
|
|
278
|
+
</directionalLight>
|
|
279
|
+
{editMode && (
|
|
280
|
+
<>
|
|
281
|
+
{/* Light source indicator */}
|
|
282
|
+
<mesh>
|
|
283
|
+
<sphereGeometry args={[0.3, 8, 6]} />
|
|
284
|
+
<meshBasicMaterial color={color} wireframe />
|
|
285
|
+
</mesh>
|
|
286
|
+
{/* Target indicator */}
|
|
287
|
+
<mesh position={targetOffset as [number, number, number]}>
|
|
288
|
+
<sphereGeometry args={[0.2, 8, 6]} />
|
|
289
|
+
<meshBasicMaterial color={color} wireframe opacity={0.5} transparent />
|
|
290
|
+
</mesh>
|
|
291
|
+
{/* Direction line */}
|
|
292
|
+
<line>
|
|
293
|
+
<bufferGeometry
|
|
294
|
+
onUpdate={(geo) => {
|
|
295
|
+
const points = [
|
|
296
|
+
new Vector3(0, 0, 0),
|
|
297
|
+
new Vector3(targetOffset[0], targetOffset[1], targetOffset[2])
|
|
298
|
+
];
|
|
299
|
+
geo.setFromPoints(points);
|
|
300
|
+
}}
|
|
301
|
+
/>
|
|
302
|
+
<lineBasicMaterial color={color} opacity={0.6} transparent />
|
|
303
|
+
</line>
|
|
304
|
+
</>
|
|
305
|
+
)}
|
|
306
|
+
</>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const DirectionalLightComponent: Component = {
|
|
311
|
+
name: 'DirectionalLight',
|
|
312
|
+
Editor: DirectionalLightComponentEditor,
|
|
313
|
+
View: DirectionalLightView,
|
|
314
|
+
defaultProperties: {}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
export default DirectionalLightComponent;
|
|
@@ -47,12 +47,22 @@ function ModelComponentEditor({ component, onUpdate, basePath = "" }: { componen
|
|
|
47
47
|
// View for Model component
|
|
48
48
|
function ModelComponentView({ properties, loadedModels, children }: { properties: any, loadedModels?: Record<string, any>, children?: React.ReactNode }) {
|
|
49
49
|
// Instanced models are handled elsewhere (GameInstance), so only render non-instanced here
|
|
50
|
-
if (!properties.filename || properties.instanced) return children
|
|
50
|
+
if (!properties.filename || properties.instanced) return <>{children}</>;
|
|
51
|
+
|
|
51
52
|
if (loadedModels && loadedModels[properties.filename]) {
|
|
52
|
-
|
|
53
|
+
const clonedModel = loadedModels[properties.filename].clone();
|
|
54
|
+
// Enable shadows on all meshes in the model
|
|
55
|
+
clonedModel.traverse((obj: any) => {
|
|
56
|
+
if (obj.isMesh) {
|
|
57
|
+
obj.castShadow = true;
|
|
58
|
+
obj.receiveShadow = true;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return <primitive object={clonedModel}>{children}</primitive>;
|
|
53
62
|
}
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
|
|
64
|
+
// Model not loaded yet - render children only
|
|
65
|
+
return <>{children}</>;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
const ModelComponent: Component = {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
|
|
2
1
|
import { Component } from "./ComponentRegistry";
|
|
3
|
-
import { useRef } from "react";
|
|
2
|
+
import { useRef, useEffect } from "react";
|
|
4
3
|
|
|
5
4
|
function SpotLightComponentEditor({ component, onUpdate }: { component: any; onUpdate: (newComp: any) => void }) {
|
|
6
|
-
// Provide default values to prevent NaN
|
|
7
5
|
const props = {
|
|
8
6
|
color: component.properties.color ?? '#ffffff',
|
|
9
7
|
intensity: component.properties.intensity ?? 1.0,
|
|
@@ -88,10 +86,7 @@ function SpotLightComponentEditor({ component, onUpdate }: { component: any; onU
|
|
|
88
86
|
</div>;
|
|
89
87
|
}
|
|
90
88
|
|
|
91
|
-
|
|
92
|
-
// The view component for SpotLight
|
|
93
|
-
function SpotLightView({ properties }: { properties: any }) {
|
|
94
|
-
// Provide defaults in case properties are missing
|
|
89
|
+
function SpotLightView({ properties, editMode }: { properties: any; editMode?: boolean }) {
|
|
95
90
|
const color = properties.color ?? '#ffffff';
|
|
96
91
|
const intensity = properties.intensity ?? 1.0;
|
|
97
92
|
const angle = properties.angle ?? Math.PI / 6;
|
|
@@ -99,16 +94,41 @@ function SpotLightView({ properties }: { properties: any }) {
|
|
|
99
94
|
const distance = properties.distance ?? 100;
|
|
100
95
|
const castShadow = properties.castShadow ?? true;
|
|
101
96
|
|
|
97
|
+
const spotLightRef = useRef<any>(null);
|
|
98
|
+
const targetRef = useRef<any>(null);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (spotLightRef.current && targetRef.current) {
|
|
102
|
+
spotLightRef.current.target = targetRef.current;
|
|
103
|
+
}
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
102
106
|
return (
|
|
103
107
|
<>
|
|
104
108
|
<spotLight
|
|
109
|
+
ref={spotLightRef}
|
|
105
110
|
color={color}
|
|
106
111
|
intensity={intensity}
|
|
107
112
|
angle={angle}
|
|
108
113
|
penumbra={penumbra}
|
|
109
114
|
distance={distance}
|
|
110
115
|
castShadow={castShadow}
|
|
116
|
+
shadow-bias={-0.0001}
|
|
117
|
+
shadow-normalBias={0.02}
|
|
111
118
|
/>
|
|
119
|
+
<object3D ref={targetRef} position={[0, -5, 0]} />
|
|
120
|
+
{editMode && (
|
|
121
|
+
<>
|
|
122
|
+
<mesh>
|
|
123
|
+
<sphereGeometry args={[0.2, 8, 6]} />
|
|
124
|
+
<meshBasicMaterial color={color} wireframe />
|
|
125
|
+
</mesh>
|
|
126
|
+
<mesh position={[0, -5, 0]}>
|
|
127
|
+
<sphereGeometry args={[0.15, 8, 6]} />
|
|
128
|
+
<meshBasicMaterial color={color} wireframe opacity={0.5} transparent />
|
|
129
|
+
</mesh>
|
|
130
|
+
</>
|
|
131
|
+
)}
|
|
112
132
|
</>
|
|
113
133
|
);
|
|
114
134
|
}
|