react-three-game 0.0.55 → 0.0.57

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 (68) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/shared/ContactShadow.d.ts +8 -0
  4. package/dist/shared/ContactShadow.js +32 -0
  5. package/dist/shared/GameCanvas.js +1 -3
  6. package/dist/tools/assetviewer/page.js +36 -15
  7. package/dist/tools/dragdrop/DragDropLoader.js +17 -40
  8. package/dist/tools/dragdrop/modelLoader.d.ts +5 -0
  9. package/dist/tools/dragdrop/modelLoader.js +39 -0
  10. package/dist/tools/prefabeditor/Dropdown.d.ts +15 -0
  11. package/dist/tools/prefabeditor/Dropdown.js +82 -0
  12. package/dist/tools/prefabeditor/EditorContext.d.ts +5 -0
  13. package/dist/tools/prefabeditor/EditorTree.js +139 -70
  14. package/dist/tools/prefabeditor/EditorUI.js +5 -10
  15. package/dist/tools/prefabeditor/PrefabEditor.d.ts +1 -0
  16. package/dist/tools/prefabeditor/PrefabEditor.js +70 -3
  17. package/dist/tools/prefabeditor/PrefabRoot.d.ts +3 -0
  18. package/dist/tools/prefabeditor/PrefabRoot.js +136 -35
  19. package/dist/tools/prefabeditor/components/AmbientLightComponent.js +3 -7
  20. package/dist/tools/prefabeditor/components/CameraComponent.d.ts +3 -0
  21. package/dist/tools/prefabeditor/components/CameraComponent.js +25 -0
  22. package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +2 -2
  23. package/dist/tools/prefabeditor/components/EnvironmentComponent.d.ts +3 -0
  24. package/dist/tools/prefabeditor/components/EnvironmentComponent.js +15 -0
  25. package/dist/tools/prefabeditor/components/GeometryComponent.js +46 -46
  26. package/dist/tools/prefabeditor/components/Input.d.ts +51 -1
  27. package/dist/tools/prefabeditor/components/Input.js +100 -47
  28. package/dist/tools/prefabeditor/components/MaterialComponent.d.ts +8 -2
  29. package/dist/tools/prefabeditor/components/MaterialComponent.js +129 -14
  30. package/dist/tools/prefabeditor/components/ModelComponent.js +44 -3
  31. package/dist/tools/prefabeditor/components/PhysicsComponent.js +16 -81
  32. package/dist/tools/prefabeditor/components/SpotLightComponent.js +6 -11
  33. package/dist/tools/prefabeditor/components/TextComponent.js +7 -53
  34. package/dist/tools/prefabeditor/components/TransformComponent.js +31 -19
  35. package/dist/tools/prefabeditor/components/index.js +5 -1
  36. package/dist/tools/prefabeditor/styles.d.ts +17 -4
  37. package/dist/tools/prefabeditor/styles.js +69 -32
  38. package/dist/tools/prefabeditor/utils.d.ts +8 -3
  39. package/dist/tools/prefabeditor/utils.js +92 -6
  40. package/package.json +1 -1
  41. package/react-three-game-skill/react-three-game/rules/LIGHTING.md +6 -0
  42. package/src/index.ts +7 -0
  43. package/src/shared/ContactShadow.tsx +74 -0
  44. package/src/shared/GameCanvas.tsx +0 -3
  45. package/src/tools/assetviewer/page.tsx +78 -46
  46. package/src/tools/dragdrop/DragDropLoader.tsx +7 -39
  47. package/src/tools/dragdrop/modelLoader.ts +36 -0
  48. package/src/tools/prefabeditor/Dropdown.tsx +112 -0
  49. package/src/tools/prefabeditor/EditorContext.tsx +5 -0
  50. package/src/tools/prefabeditor/EditorTree.tsx +237 -115
  51. package/src/tools/prefabeditor/EditorUI.tsx +6 -11
  52. package/src/tools/prefabeditor/PrefabEditor.tsx +77 -5
  53. package/src/tools/prefabeditor/PrefabRoot.tsx +228 -59
  54. package/src/tools/prefabeditor/components/AmbientLightComponent.tsx +5 -11
  55. package/src/tools/prefabeditor/components/CameraComponent.tsx +80 -0
  56. package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +2 -2
  57. package/src/tools/prefabeditor/components/EnvironmentComponent.tsx +47 -0
  58. package/src/tools/prefabeditor/components/GeometryComponent.tsx +69 -63
  59. package/src/tools/prefabeditor/components/Input.tsx +247 -53
  60. package/src/tools/prefabeditor/components/MaterialComponent.tsx +191 -20
  61. package/src/tools/prefabeditor/components/ModelComponent.tsx +52 -5
  62. package/src/tools/prefabeditor/components/PhysicsComponent.tsx +44 -85
  63. package/src/tools/prefabeditor/components/SpotLightComponent.tsx +14 -16
  64. package/src/tools/prefabeditor/components/TextComponent.tsx +58 -57
  65. package/src/tools/prefabeditor/components/TransformComponent.tsx +78 -20
  66. package/src/tools/prefabeditor/components/index.ts +5 -1
  67. package/src/tools/prefabeditor/styles.ts +71 -32
  68. package/src/tools/prefabeditor/utils.ts +96 -5
@@ -1,13 +1,17 @@
1
1
  import { SingleTextureViewer, TextureListViewer } from '../../assetviewer/page';
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { createPortal } from 'react-dom';
3
4
  import { Component } from './ComponentRegistry';
4
5
  import { FieldRenderer, FieldDefinition, Input } from './Input';
6
+ import { colors } from '../styles';
5
7
  import { useMemo } from 'react';
6
8
  import {
7
9
  RepeatWrapping,
8
10
  ClampToEdgeWrapping,
9
11
  SRGBColorSpace,
12
+ LinearSRGBColorSpace,
10
13
  Texture,
14
+ Vector2,
11
15
  NearestFilter,
12
16
  LinearFilter,
13
17
  NearestMipmapNearestFilter,
@@ -16,18 +20,31 @@ import {
16
20
  LinearMipmapLinearFilter,
17
21
  MinificationTextureFilter,
18
22
  MagnificationTextureFilter,
19
- MeshStandardMaterialProperties
23
+ MeshBasicMaterialProperties,
24
+ MeshStandardMaterialProperties,
25
+ FrontSide,
26
+ BackSide,
27
+ DoubleSide,
20
28
  } from 'three';
21
29
 
22
- export interface MaterialProps extends Omit<MeshStandardMaterialProperties, 'args'> {
30
+ export interface MaterialProps extends Omit<MeshStandardMaterialProperties & MeshBasicMaterialProperties, 'args' | 'normalScale'> {
31
+ materialType?: 'standard' | 'basic';
32
+ transmission?: number;
33
+ thickness?: number;
34
+ ior?: number;
23
35
  texture?: string;
24
36
  repeat?: boolean;
25
37
  repeatCount?: [number, number];
26
38
  generateMipmaps?: boolean;
27
39
  minFilter?: string;
28
40
  magFilter?: string;
41
+ normalMapTexture?: string;
42
+ normalScale?: [number, number];
29
43
  }
30
44
 
45
+ const PICKER_POPUP_WIDTH = 260;
46
+ const PICKER_POPUP_HEIGHT = 360;
47
+
31
48
  function TexturePicker({
32
49
  value,
33
50
  onChange,
@@ -39,6 +56,8 @@ function TexturePicker({
39
56
  }) {
40
57
  const [textureFiles, setTextureFiles] = useState<string[]>([]);
41
58
  const [showPicker, setShowPicker] = useState(false);
59
+ const [popupStyle, setPopupStyle] = useState<React.CSSProperties | null>(null);
60
+ const triggerRef = useRef<HTMLButtonElement>(null);
42
61
 
43
62
  useEffect(() => {
44
63
  fetch(`${basePath}/textures/manifest.json`)
@@ -47,12 +66,60 @@ function TexturePicker({
47
66
  .catch(console.error);
48
67
  }, [basePath]);
49
68
 
69
+ useLayoutEffect(() => {
70
+ if (!showPicker || !triggerRef.current || typeof window === 'undefined') return;
71
+
72
+ const updatePosition = () => {
73
+ const rect = triggerRef.current?.getBoundingClientRect();
74
+ if (!rect) return;
75
+
76
+ const preferredLeft = rect.left - PICKER_POPUP_WIDTH - 8;
77
+ const fallbackLeft = rect.right + 8;
78
+ const fitsLeft = preferredLeft >= 8;
79
+ const left = fitsLeft ? preferredLeft : Math.min(fallbackLeft, window.innerWidth - PICKER_POPUP_WIDTH - 8);
80
+ const top = Math.min(Math.max(8, rect.top), window.innerHeight - PICKER_POPUP_HEIGHT - 8);
81
+
82
+ setPopupStyle({
83
+ position: 'fixed',
84
+ left,
85
+ top,
86
+ background: colors.bg,
87
+ padding: 12,
88
+ border: `1px solid ${colors.border}`,
89
+ borderRadius: 6,
90
+ width: PICKER_POPUP_WIDTH,
91
+ height: PICKER_POPUP_HEIGHT,
92
+ overflow: 'hidden',
93
+ zIndex: 1000,
94
+ boxShadow: '0 4px 16px rgba(0,0,0,0.6)',
95
+ });
96
+ };
97
+
98
+ updatePosition();
99
+ window.addEventListener('resize', updatePosition);
100
+ window.addEventListener('scroll', updatePosition, true);
101
+
102
+ return () => {
103
+ window.removeEventListener('resize', updatePosition);
104
+ window.removeEventListener('scroll', updatePosition, true);
105
+ };
106
+ }, [showPicker]);
107
+
108
+ // Only show 3D preview for server-hosted textures (starting with / or http)
109
+ const canPreview = value && (value.startsWith('/') || value.startsWith('http'));
110
+
50
111
  return (
51
- <div style={{ maxHeight: 128, overflowY: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }}>
52
- <SingleTextureViewer file={value || undefined} basePath={basePath} />
112
+ <div style={{ maxHeight: 128, overflow: 'visible', position: 'relative', display: 'flex', alignItems: 'center' }}>
113
+ {canPreview
114
+ ? <SingleTextureViewer file={value} basePath={basePath} />
115
+ : value
116
+ ? <span style={{ fontSize: 10, opacity: 0.6, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{value}</span>
117
+ : null
118
+ }
53
119
  <button
120
+ ref={triggerRef}
54
121
  onClick={() => setShowPicker(!showPicker)}
55
- style={{ padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }}
122
+ style={{ padding: '4px 8px', backgroundColor: colors.bgLight, color: 'inherit', fontSize: 10, cursor: 'pointer', border: `1px solid ${colors.border}`, borderRadius: 3, marginTop: 4 }}
56
123
  >
57
124
  {showPicker ? 'Cancel' : 'Change'}
58
125
  </button>
@@ -60,12 +127,12 @@ function TexturePicker({
60
127
  onClick={() => {
61
128
  onChange(undefined as any);
62
129
  }}
63
- style={{ padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4, marginLeft: 4 }}
130
+ style={{ padding: '4px 8px', backgroundColor: colors.bgLight, color: 'inherit', fontSize: 10, cursor: 'pointer', border: `1px solid ${colors.border}`, borderRadius: 3, marginTop: 4, marginLeft: 4 }}
64
131
  >
65
132
  Clear
66
133
  </button>
67
- {showPicker && (
68
- <div style={{ position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }}>
134
+ {showPicker && popupStyle && typeof document !== 'undefined' && createPortal(
135
+ <div style={popupStyle} onMouseLeave={() => setShowPicker(false)}>
69
136
  <TextureListViewer
70
137
  files={textureFiles}
71
138
  selected={value || undefined}
@@ -75,26 +142,51 @@ function TexturePicker({
75
142
  }}
76
143
  basePath={basePath}
77
144
  />
78
- </div>
145
+ </div>,
146
+ document.body
79
147
  )}
80
148
  </div>
81
149
  );
82
150
  }
83
151
 
84
152
  function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { component: any; onUpdate: (newComp: any) => void; basePath?: string }) {
153
+ const materialType = component.properties.materialType ?? 'standard';
85
154
  const hasTexture = !!component.properties.texture;
86
155
  const hasRepeat = component.properties.repeat;
156
+ const isStandardMaterial = materialType === 'standard';
87
157
 
88
158
  const fields: FieldDefinition[] = [
159
+ {
160
+ name: 'materialType',
161
+ type: 'select',
162
+ label: 'Material Type',
163
+ options: [
164
+ { value: 'standard', label: 'Standard' },
165
+ { value: 'basic', label: 'Basic' },
166
+ ],
167
+ },
89
168
  { name: 'color', type: 'color', label: 'Color' },
169
+ { name: 'toneMapped', type: 'boolean', label: 'Tone Mapped' },
90
170
  { name: 'wireframe', type: 'boolean', label: 'Wireframe' },
91
171
  { name: 'transparent', type: 'boolean', label: 'Transparent' },
92
172
  { name: 'opacity', type: 'number', label: 'Opacity', min: 0, max: 1, step: 0.01 },
93
- { name: 'metalness', type: 'number', label: 'Metalness', min: 0, max: 1, step: 0.01 },
94
- { name: 'roughness', type: 'number', label: 'Roughness', min: 0, max: 1, step: 0.01 },
95
- { name: 'transmission', type: 'number', label: 'Transmission', min: 0, max: 1, step: 0.01 },
96
- { name: 'thickness', type: 'number', label: 'Thickness', min: 0, step: 0.1 },
97
- { name: 'ior', type: 'number', label: 'IOR (Index of Refraction)', min: 1, max: 2.333, step: 0.01 },
173
+ ...(isStandardMaterial ? [
174
+ { name: 'metalness', type: 'number', label: 'Metalness', min: 0, max: 1, step: 0.01 },
175
+ { name: 'roughness', type: 'number', label: 'Roughness', min: 0, max: 1, step: 0.01 },
176
+ { name: 'transmission', type: 'number', label: 'Transmission', min: 0, max: 1, step: 0.01 },
177
+ { name: 'thickness', type: 'number', label: 'Thickness', min: 0, step: 0.1 },
178
+ { name: 'ior', type: 'number', label: 'IOR (Index of Refraction)', min: 1, max: 2.333, step: 0.01 },
179
+ ] as FieldDefinition[] : []),
180
+ {
181
+ name: 'side',
182
+ type: 'select',
183
+ label: 'Side',
184
+ options: [
185
+ { value: 'FrontSide', label: 'Front' },
186
+ { value: 'BackSide', label: 'Back' },
187
+ { value: 'DoubleSide', label: 'Double' },
188
+ ],
189
+ } as FieldDefinition,
98
190
  {
99
191
  name: 'texture',
100
192
  type: 'custom',
@@ -131,6 +223,39 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { compo
131
223
  </div>
132
224
  ),
133
225
  } as FieldDefinition] : []),
226
+ {
227
+ name: 'normalMapTexture',
228
+ type: 'custom',
229
+ label: 'Normal Map',
230
+ render: ({ value, onChange }) => (
231
+ <TexturePicker value={value} onChange={onChange} basePath={basePath} />
232
+ ),
233
+ } as FieldDefinition,
234
+ ...(component.properties.normalMapTexture ? [{
235
+ name: 'normalScale',
236
+ type: 'custom',
237
+ label: 'Normal Scale (X, Y)',
238
+ render: ({ value, onChange }: { value: [number, number] | undefined; onChange: (v: [number, number]) => void }) => (
239
+ <div style={{ display: 'flex', gap: 2 }}>
240
+ <Input
241
+ label="X"
242
+ value={value?.[0] ?? 1}
243
+ onChange={v => onChange([v, value?.[1] ?? 1])}
244
+ min={0}
245
+ max={5}
246
+ step={0.01}
247
+ />
248
+ <Input
249
+ label="Y"
250
+ value={value?.[1] ?? 1}
251
+ onChange={v => onChange([value?.[0] ?? 1, v])}
252
+ min={0}
253
+ max={5}
254
+ step={0.01}
255
+ />
256
+ </div>
257
+ ),
258
+ } as FieldDefinition] : []),
134
259
  { name: 'generateMipmaps', type: 'boolean', label: 'Generate Mipmaps' } as FieldDefinition,
135
260
  {
136
261
  name: 'minFilter',
@@ -168,6 +293,7 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { compo
168
293
 
169
294
  // View for Material component
170
295
  function MaterialComponentView({ properties, loadedTextures }: { properties: MaterialProps, loadedTextures?: Record<string, Texture> }) {
296
+ const materialType = properties?.materialType ?? 'standard';
171
297
  const textureName = properties?.texture;
172
298
  const repeat = properties?.repeat;
173
299
  const repeatCount = properties?.repeatCount;
@@ -176,6 +302,11 @@ function MaterialComponentView({ properties, loadedTextures }: { properties: Mat
176
302
  const magFilter = properties?.magFilter || 'LinearFilter';
177
303
  const texture = textureName && loadedTextures ? loadedTextures[textureName] : undefined;
178
304
 
305
+ const normalMapTextureName = properties?.normalMapTexture;
306
+ const normalScaleProp = properties?.normalScale;
307
+ const normalMapTexture = normalMapTextureName && loadedTextures ? loadedTextures[normalMapTextureName] : undefined;
308
+ const materialSource: MaterialProps = properties ?? {};
309
+
179
310
  // Destructure all material props and separate custom texture handling props
180
311
  const {
181
312
  texture: _texture,
@@ -184,9 +315,22 @@ function MaterialComponentView({ properties, loadedTextures }: { properties: Mat
184
315
  generateMipmaps: _generateMipmaps,
185
316
  minFilter: _minFilter,
186
317
  magFilter: _magFilter,
187
- map: _map, // Filter out map since we set it explicitly
318
+ map: _map,
319
+ materialType: _materialType,
320
+ normalMapTexture: _normalMapTexture,
321
+ normalScale: _normalScale,
322
+ normalMap: _normalMap,
323
+ side: sideProp,
324
+ metalness: _metalness,
325
+ roughness: _roughness,
326
+ transmission: _transmission,
327
+ thickness: _thickness,
328
+ ior: _ior,
188
329
  ...materialProps
189
- } = properties || {};
330
+ } = materialSource;
331
+
332
+ const sideMap: Record<string, any> = { FrontSide, BackSide, DoubleSide };
333
+ const resolvedSide = sideProp ? (sideMap[sideProp as unknown as string] ?? FrontSide) : FrontSide;
190
334
 
191
335
  const minFilterMap: Record<string, MinificationTextureFilter> = {
192
336
  NearestFilter,
@@ -220,15 +364,40 @@ function MaterialComponentView({ properties, loadedTextures }: { properties: Mat
220
364
  return t;
221
365
  }, [texture, repeat, repeatCount?.[0], repeatCount?.[1], generateMipmaps, minFilter, magFilter]);
222
366
 
367
+ const finalNormalMap = useMemo(() => {
368
+ if (!normalMapTexture) return undefined;
369
+ const t = normalMapTexture.clone();
370
+ t.colorSpace = LinearSRGBColorSpace;
371
+ t.needsUpdate = true;
372
+ return t;
373
+ }, [normalMapTexture]);
374
+
375
+ const normalScaleVec = useMemo(() => {
376
+ if (!finalNormalMap) return undefined;
377
+ return new Vector2(normalScaleProp?.[0] ?? 1, normalScaleProp?.[1] ?? 1);
378
+ }, [finalNormalMap, normalScaleProp?.[0], normalScaleProp?.[1]]);
379
+
223
380
  if (!properties) {
224
381
  return <meshStandardMaterial color="red" wireframe />;
225
382
  }
226
383
 
384
+ const materialKey = finalTexture?.uuid ?? 'no-texture';
385
+ const sharedProps = {
386
+ map: finalTexture,
387
+ side: resolvedSide,
388
+ ...materialProps,
389
+ };
390
+
391
+ if (materialType === 'basic') {
392
+ return <meshBasicMaterial key={materialKey} {...sharedProps} />;
393
+ }
394
+
227
395
  return (
228
396
  <meshStandardMaterial
229
- key={finalTexture?.uuid ?? 'no-texture'}
230
- map={finalTexture}
231
- {...materialProps}
397
+ key={materialKey}
398
+ {...sharedProps}
399
+ normalMap={finalNormalMap}
400
+ normalScale={normalScaleVec}
232
401
  />
233
402
  );
234
403
  }
@@ -239,7 +408,9 @@ const MaterialComponent: Component = {
239
408
  View: MaterialComponentView,
240
409
  nonComposable: true,
241
410
  defaultProperties: {
411
+ materialType: 'standard',
242
412
  color: '#ffffff',
413
+ toneMapped: true,
243
414
  wireframe: false,
244
415
  transparent: false,
245
416
  opacity: 1,
@@ -1,9 +1,13 @@
1
1
  import { ModelListViewer, SingleModelViewer } from '../../assetviewer/page';
2
- import { useEffect, useState, useMemo } from 'react';
2
+ import { useEffect, useLayoutEffect, useState, useMemo, useRef } from 'react';
3
+ import { createPortal } from 'react-dom';
3
4
  import { Component } from './ComponentRegistry';
4
5
  import { FieldRenderer, FieldDefinition } from './Input';
5
6
  import { GameObject } from '../types';
6
7
 
8
+ const PICKER_POPUP_WIDTH = 260;
9
+ const PICKER_POPUP_HEIGHT = 360;
10
+
7
11
  function ModelPicker({
8
12
  value,
9
13
  onChange,
@@ -17,6 +21,8 @@ function ModelPicker({
17
21
  }) {
18
22
  const [modelFiles, setModelFiles] = useState<string[]>([]);
19
23
  const [showPicker, setShowPicker] = useState(false);
24
+ const [popupStyle, setPopupStyle] = useState<React.CSSProperties | null>(null);
25
+ const triggerRef = useRef<HTMLButtonElement>(null);
20
26
 
21
27
  useEffect(() => {
22
28
  fetch(`${basePath}/models/manifest.json`)
@@ -25,6 +31,45 @@ function ModelPicker({
25
31
  .catch(console.error);
26
32
  }, [basePath]);
27
33
 
34
+ useLayoutEffect(() => {
35
+ if (!showPicker || !triggerRef.current || typeof window === 'undefined') return;
36
+
37
+ const updatePosition = () => {
38
+ const rect = triggerRef.current?.getBoundingClientRect();
39
+ if (!rect) return;
40
+
41
+ const preferredLeft = rect.left - PICKER_POPUP_WIDTH - 8;
42
+ const fallbackLeft = rect.right + 8;
43
+ const fitsLeft = preferredLeft >= 8;
44
+ const left = fitsLeft ? preferredLeft : Math.min(fallbackLeft, window.innerWidth - PICKER_POPUP_WIDTH - 8);
45
+ const top = Math.min(Math.max(8, rect.top), window.innerHeight - PICKER_POPUP_HEIGHT - 8);
46
+
47
+ setPopupStyle({
48
+ position: 'fixed',
49
+ left,
50
+ top,
51
+ background: 'rgba(0,0,0,0.9)',
52
+ padding: 12,
53
+ border: '1px solid rgba(34, 211, 238, 0.3)',
54
+ borderRadius: 6,
55
+ width: PICKER_POPUP_WIDTH,
56
+ height: PICKER_POPUP_HEIGHT,
57
+ overflow: 'hidden',
58
+ zIndex: 1000,
59
+ boxShadow: '0 4px 16px rgba(0,0,0,0.6)',
60
+ });
61
+ };
62
+
63
+ updatePosition();
64
+ window.addEventListener('resize', updatePosition);
65
+ window.addEventListener('scroll', updatePosition, true);
66
+
67
+ return () => {
68
+ window.removeEventListener('resize', updatePosition);
69
+ window.removeEventListener('scroll', updatePosition, true);
70
+ };
71
+ }, [showPicker]);
72
+
28
73
  const handleModelSelect = (file: string) => {
29
74
  const filename = file.startsWith('/') ? file.slice(1) : file;
30
75
  onChange(filename);
@@ -32,9 +77,10 @@ function ModelPicker({
32
77
  };
33
78
 
34
79
  return (
35
- <div style={{ maxHeight: 128, overflowY: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }}>
80
+ <div style={{ maxHeight: 128, overflow: 'visible', position: 'relative', display: 'flex', alignItems: 'center' }}>
36
81
  <SingleModelViewer file={value ? `/${value}` : undefined} basePath={basePath} />
37
82
  <button
83
+ ref={triggerRef}
38
84
  onClick={() => setShowPicker(!showPicker)}
39
85
  style={{ padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }}
40
86
  >
@@ -48,8 +94,8 @@ function ModelPicker({
48
94
  >
49
95
  Clear
50
96
  </button>
51
- {showPicker && (
52
- <div style={{ position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }}>
97
+ {showPicker && popupStyle && typeof document !== 'undefined' && createPortal(
98
+ <div style={popupStyle} onMouseLeave={() => setShowPicker(false)}>
53
99
  <ModelListViewer
54
100
  key={nodeId}
55
101
  files={modelFiles}
@@ -57,7 +103,8 @@ function ModelPicker({
57
103
  onSelect={handleModelSelect}
58
104
  basePath={basePath}
59
105
  />
60
- </div>
106
+ </div>,
107
+ document.body
61
108
  )}
62
109
  </div>
63
110
  );
@@ -3,7 +3,7 @@ import type { RigidBodyOptions, CollisionPayload, IntersectionEnterPayload, Inte
3
3
  import type { ReactNode } from 'react';
4
4
  import { useRef, useEffect, useCallback } from 'react';
5
5
  import { Component } from "./ComponentRegistry";
6
- import { FieldRenderer, FieldDefinition } from "./Input";
6
+ import { BooleanField, FieldGroup, NumberField, SelectField } from "./Input";
7
7
  import { ComponentData } from "../types";
8
8
  import { gameEvents, getEntityIdFromRigidBody } from "../GameEvents";
9
9
 
@@ -11,92 +11,51 @@ export type PhysicsProps = RigidBodyOptions & {
11
11
  activeCollisionTypes?: 'all' | undefined;
12
12
  };
13
13
 
14
- const physicsFields: FieldDefinition[] = [
15
- {
16
- name: 'type',
17
- type: 'select',
18
- label: 'Type',
19
- options: [
20
- { value: 'dynamic', label: 'Dynamic' },
21
- { value: 'fixed', label: 'Fixed' },
22
- { value: 'kinematicPosition', label: 'Kinematic Position' },
23
- { value: 'kinematicVelocity', label: 'Kinematic Velocity' },
24
- ],
25
- },
26
- {
27
- name: 'colliders',
28
- type: 'select',
29
- label: 'Collider',
30
- options: [
31
- { value: 'hull', label: 'Hull (convex)' },
32
- { value: 'trimesh', label: 'Trimesh (exact)' },
33
- { value: 'cuboid', label: 'Cuboid (box)' },
34
- { value: 'ball', label: 'Ball (sphere)' },
35
- ],
36
- },
37
- {
38
- name: 'mass',
39
- type: 'number',
40
- label: 'Mass',
41
- },
42
- {
43
- name: 'restitution',
44
- type: 'number',
45
- label: 'Restitution (Bounciness)',
46
- min: 0,
47
- max: 1,
48
- step: 0.1,
49
- },
50
- {
51
- name: 'friction',
52
- type: 'number',
53
- label: 'Friction',
54
- min: 0,
55
- step: 0.1,
56
- },
57
- {
58
- name: 'linearDamping',
59
- type: 'number',
60
- label: 'Linear Damping',
61
- min: 0,
62
- step: 0.1,
63
- },
64
- {
65
- name: 'angularDamping',
66
- type: 'number',
67
- label: 'Angular Damping',
68
- min: 0,
69
- step: 0.1,
70
- },
71
- {
72
- name: 'gravityScale',
73
- type: 'number',
74
- label: 'Gravity Scale',
75
- step: 0.1,
76
- },
77
- {
78
- name: 'sensor',
79
- type: 'boolean',
80
- label: 'Sensor (Trigger Only)',
81
- },
82
- {
83
- name: 'activeCollisionTypes',
84
- type: 'select',
85
- label: 'Collision Detection',
86
- options: [
87
- { value: '', label: 'Default (Dynamic only)' },
88
- { value: 'all', label: 'All (includes kinematic & fixed)' },
89
- ],
90
- },
91
- ];
92
-
93
14
  function PhysicsComponentEditor({ component, onUpdate }: { component: ComponentData; onUpdate: (newComp: any) => void }) {
94
15
  return (
95
- <FieldRenderer
96
- fields={physicsFields}
97
- values={component.properties}
98
- onChange={onUpdate}
99
- />
16
+ <FieldGroup>
17
+ <SelectField
18
+ name="type"
19
+ label="Type"
20
+ values={component.properties}
21
+ onChange={onUpdate}
22
+ options={[
23
+ { value: 'dynamic', label: 'Dynamic' },
24
+ { value: 'fixed', label: 'Fixed' },
25
+ { value: 'kinematicPosition', label: 'Kinematic Position' },
26
+ { value: 'kinematicVelocity', label: 'Kinematic Velocity' },
27
+ ]}
28
+ />
29
+ <SelectField
30
+ name="colliders"
31
+ label="Collider"
32
+ values={component.properties}
33
+ onChange={onUpdate}
34
+ options={[
35
+ { value: 'hull', label: 'Hull (convex)' },
36
+ { value: 'trimesh', label: 'Trimesh (exact)' },
37
+ { value: 'cuboid', label: 'Cuboid (box)' },
38
+ { value: 'ball', label: 'Ball (sphere)' },
39
+ ]}
40
+ />
41
+ <NumberField name="mass" label="Mass" values={component.properties} onChange={onUpdate} fallback={1} step={0.1} min={0} />
42
+ <NumberField name="restitution" label="Restitution (Bounciness)" values={component.properties} onChange={onUpdate} fallback={0} min={0} max={1} step={0.1} />
43
+ <NumberField name="friction" label="Friction" values={component.properties} onChange={onUpdate} fallback={0.5} min={0} step={0.1} />
44
+ <NumberField name="linearDamping" label="Linear Damping" values={component.properties} onChange={onUpdate} fallback={0} min={0} step={0.1} />
45
+ <NumberField name="angularDamping" label="Angular Damping" values={component.properties} onChange={onUpdate} fallback={0} min={0} step={0.1} />
46
+ <NumberField name="gravityScale" label="Gravity Scale" values={component.properties} onChange={onUpdate} fallback={1} step={0.1} />
47
+ <BooleanField name="sensor" label="Sensor (Trigger Only)" values={component.properties} onChange={onUpdate} fallback={false} />
48
+ <SelectField
49
+ name="activeCollisionTypes"
50
+ label="Collision Detection"
51
+ values={component.properties}
52
+ onChange={onUpdate}
53
+ options={[
54
+ { value: '', label: 'Default (Dynamic only)' },
55
+ { value: 'all', label: 'All (includes kinematic & fixed)' },
56
+ ]}
57
+ />
58
+ </FieldGroup>
100
59
  );
101
60
  }
102
61
 
@@ -1,27 +1,23 @@
1
1
  import { Component } from "./ComponentRegistry";
2
2
  import { useRef, useEffect } from "react";
3
- import { FieldRenderer, FieldDefinition } from "./Input";
4
-
5
- const spotLightFields: FieldDefinition[] = [
6
- { name: 'color', type: 'color', label: 'Color' },
7
- { name: 'intensity', type: 'number', label: 'Intensity', step: 0.1, min: 0 },
8
- { name: 'angle', type: 'number', label: 'Angle', step: 0.1, min: 0, max: Math.PI },
9
- { name: 'penumbra', type: 'number', label: 'Penumbra', step: 0.1, min: 0, max: 1 },
10
- { name: 'distance', type: 'number', label: 'Distance', step: 1, min: 0 },
11
- { name: 'castShadow', type: 'boolean', label: 'Cast Shadow' },
12
- ];
3
+ import { BooleanField, ColorField, FieldGroup, NumberField } from "./Input";
4
+ import { useHelper } from "@react-three/drei";
5
+ import { SpotLightHelper } from "three";
13
6
 
14
7
  function SpotLightComponentEditor({ component, onUpdate }: { component: any; onUpdate: (newComp: any) => void }) {
15
8
  return (
16
- <FieldRenderer
17
- fields={spotLightFields}
18
- values={component.properties}
19
- onChange={onUpdate}
20
- />
9
+ <FieldGroup>
10
+ <ColorField name="color" label="Color" values={component.properties} onChange={onUpdate} />
11
+ <NumberField name="intensity" label="Intensity" values={component.properties} onChange={onUpdate} min={0} step={0.1} fallback={1} />
12
+ <NumberField name="angle" label="Angle" values={component.properties} onChange={onUpdate} min={0} max={Math.PI} step={0.05} fallback={Math.PI / 6} />
13
+ <NumberField name="penumbra" label="Penumbra" values={component.properties} onChange={onUpdate} min={0} max={1} step={0.05} fallback={0.5} />
14
+ <NumberField name="distance" label="Distance" values={component.properties} onChange={onUpdate} min={0} step={1} fallback={100} />
15
+ <BooleanField name="castShadow" label="Cast Shadow" values={component.properties} onChange={onUpdate} fallback={true} />
16
+ </FieldGroup>
21
17
  );
22
18
  }
23
19
 
24
- function SpotLightView({ properties, editMode }: { properties: any; editMode?: boolean }) {
20
+ function SpotLightView({ properties, editMode, isSelected }: { properties: any; editMode?: boolean; isSelected?: boolean }) {
25
21
  const color = properties.color ?? '#ffffff';
26
22
  const intensity = properties.intensity ?? 1.0;
27
23
  const angle = properties.angle ?? Math.PI / 6;
@@ -32,6 +28,8 @@ function SpotLightView({ properties, editMode }: { properties: any; editMode?: b
32
28
  const spotLightRef = useRef<any>(null);
33
29
  const targetRef = useRef<any>(null);
34
30
 
31
+ useHelper(editMode && isSelected ? spotLightRef : null, SpotLightHelper, color);
32
+
35
33
  useEffect(() => {
36
34
  if (spotLightRef.current && targetRef.current) {
37
35
  spotLightRef.current.target = targetRef.current;