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.
Files changed (39) hide show
  1. package/.github/copilot-instructions.md +54 -183
  2. package/README.md +69 -214
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +3 -0
  5. package/dist/tools/prefabeditor/EditorTree.d.ts +2 -4
  6. package/dist/tools/prefabeditor/EditorTree.js +20 -194
  7. package/dist/tools/prefabeditor/EditorUI.js +43 -224
  8. package/dist/tools/prefabeditor/InstanceProvider.d.ts +4 -4
  9. package/dist/tools/prefabeditor/InstanceProvider.js +21 -13
  10. package/dist/tools/prefabeditor/PrefabEditor.js +33 -99
  11. package/dist/tools/prefabeditor/PrefabRoot.d.ts +0 -1
  12. package/dist/tools/prefabeditor/PrefabRoot.js +33 -50
  13. package/dist/tools/prefabeditor/components/DirectionalLightComponent.d.ts +3 -0
  14. package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +102 -0
  15. package/dist/tools/prefabeditor/components/ModelComponent.js +12 -4
  16. package/dist/tools/prefabeditor/components/SpotLightComponent.js +10 -5
  17. package/dist/tools/prefabeditor/components/index.js +2 -0
  18. package/dist/tools/prefabeditor/hooks/useModelLoader.d.ts +10 -0
  19. package/dist/tools/prefabeditor/hooks/useModelLoader.js +40 -0
  20. package/dist/tools/prefabeditor/styles.d.ts +1809 -0
  21. package/dist/tools/prefabeditor/styles.js +168 -0
  22. package/dist/tools/prefabeditor/types.d.ts +3 -14
  23. package/dist/tools/prefabeditor/types.js +0 -1
  24. package/dist/tools/prefabeditor/utils.d.ts +19 -0
  25. package/dist/tools/prefabeditor/utils.js +72 -0
  26. package/package.json +3 -3
  27. package/src/index.ts +5 -1
  28. package/src/tools/prefabeditor/EditorTree.tsx +38 -270
  29. package/src/tools/prefabeditor/EditorUI.tsx +105 -322
  30. package/src/tools/prefabeditor/InstanceProvider.tsx +43 -32
  31. package/src/tools/prefabeditor/PrefabEditor.tsx +40 -151
  32. package/src/tools/prefabeditor/PrefabRoot.tsx +41 -73
  33. package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +317 -0
  34. package/src/tools/prefabeditor/components/ModelComponent.tsx +14 -4
  35. package/src/tools/prefabeditor/components/SpotLightComponent.tsx +27 -7
  36. package/src/tools/prefabeditor/components/index.ts +2 -0
  37. package/src/tools/prefabeditor/styles.ts +195 -0
  38. package/src/tools/prefabeditor/types.ts +4 -12
  39. 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, useMemo, useCallback } from "react";
5
- import { Vector3, Euler, Quaternion, ClampToEdgeWrapping, DoubleSide, Group, Object3D, RepeatWrapping, SRGBColorSpace, Texture, TextureLoader, Matrix4 } from "three";
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
- 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
- }
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, setTransformMode, basePath = "" }, ref) => {
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
- if (selectedId) {
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 = updatePrefabNode(data.root, selectedId, (node) => ({
64
+ const newRoot = updateNode(data.root, selectedId, (node) => ({
86
65
  ...node,
87
66
  components: {
88
- ...node?.components,
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 a small context object to avoid long param lists
187
+ // Build context object for passing to helper functions
208
188
  const ctx = { gameObject, selectedId, onSelect, registerRef, loadedModels, loadedTextures, editMode };
209
189
 
210
- // --- 1. Transform (local + world) ---
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
- // preserve click/drag detection from previous implementation
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
- if (gameObject.disabled === true || gameObject.hidden === true) return null;
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
- // --- 3. Core content decided by component registry ---
222
+ // --- 4. Render core content using component system ---
245
223
  const core = renderCoreNode(gameObject, ctx, parentMatrix);
246
224
 
247
- // --- 5. Render children (always relative transforms) ---
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
- // --- 4. Wrap with physics if needed ---
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 model/geometry content for a non-instanced node
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 modelComp = gameObject.components?.model;
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
- const isModelAvailable = !!(modelComp && modelComp.properties && modelComp.properties.filename && ctx.loadedModels[modelComp.properties.filename]);
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
- // Separate wrapper components (that accept children) from leaf components
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]) => key !== 'geometry' && key !== 'material' && key !== 'model' && key !== 'transform' && key !== 'physics')
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
- // Check if the component View accepts children by checking function signature
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 the core content (model or mesh)
323
+ // Build core content based on what components exist
348
324
  let coreContent: React.ReactNode;
349
325
 
350
- // If we have a model (non-instanced) render it as a primitive with material override
351
- if (isModelAvailable) {
352
- const modelObj = ctx.loadedModels[modelComp.properties.filename].clone();
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
- <primitive object={modelObj}>
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
- loadedTextures={ctx.loadedTextures}
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
- </primitive>
339
+ </modelDef.View>
368
340
  );
369
341
  } else if (geometry && geometryDef && geometryDef.View) {
370
- // Otherwise, if geometry present, render a mesh
342
+ // Geometry + Material = mesh
371
343
  coreContent = (
372
- <mesh>
373
- <geometryDef.View key="geometry" properties={geometry.properties} {...contextProps} />
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
- loadedTextures={ctx.loadedTextures}
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 geometry or model, just render leaf components
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 || null;
50
+ if (!properties.filename || properties.instanced) return <>{children}</>;
51
+
51
52
  if (loadedModels && loadedModels[properties.filename]) {
52
- return <>{<primitive object={loadedModels[properties.filename].clone()} />}{children}</>;
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
- // Optionally, render a placeholder if model is not loaded
55
- return children || null;
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
  }