react-three-game 0.0.59 → 0.0.61

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 (67) hide show
  1. package/dist/tools/dragdrop/DragDropLoader.d.ts +8 -8
  2. package/dist/tools/dragdrop/DragDropLoader.js +33 -15
  3. package/dist/tools/dragdrop/index.d.ts +3 -3
  4. package/dist/tools/dragdrop/index.js +1 -1
  5. package/dist/tools/dragdrop/modelLoader.d.ts +10 -1
  6. package/dist/tools/dragdrop/modelLoader.js +39 -0
  7. package/dist/tools/prefabeditor/PrefabEditor.js +17 -26
  8. package/dist/tools/prefabeditor/PrefabRoot.d.ts +1 -1
  9. package/dist/tools/prefabeditor/PrefabRoot.js +2 -8
  10. package/package.json +9 -3
  11. package/.gitattributes +0 -2
  12. package/.github/copilot-instructions.md +0 -83
  13. package/.github/workflows/nextjs.yml +0 -99
  14. package/.gitmodules +0 -3
  15. package/assets/architecture.png +0 -0
  16. package/assets/editor.gif +0 -0
  17. package/assets/favicon.ico +0 -0
  18. package/assets/react-three-game-logo.png +0 -0
  19. package/dist/tools/dragdrop/page.d.ts +0 -1
  20. package/dist/tools/dragdrop/page.js +0 -11
  21. package/dist/tools/prefabeditor/EntityEvents.d.ts +0 -54
  22. package/dist/tools/prefabeditor/EntityEvents.js +0 -85
  23. package/dist/tools/prefabeditor/page.d.ts +0 -1
  24. package/dist/tools/prefabeditor/page.js +0 -5
  25. package/react-three-game-skill/.gitattributes +0 -2
  26. package/react-three-game-skill/README.md +0 -7
  27. package/react-three-game-skill/react-three-game/SKILL.md +0 -514
  28. package/react-three-game-skill/react-three-game/rules/ADVANCED_PHYSICS.md +0 -472
  29. package/react-three-game-skill/react-three-game/rules/LIGHTING.md +0 -6
  30. package/src/helpers/SoundManager.ts +0 -130
  31. package/src/helpers/index.ts +0 -91
  32. package/src/index.ts +0 -59
  33. package/src/shared/ContactShadow.tsx +0 -74
  34. package/src/shared/GameCanvas.tsx +0 -52
  35. package/src/tools/assetviewer/page.tsx +0 -425
  36. package/src/tools/dragdrop/DragDropLoader.tsx +0 -136
  37. package/src/tools/dragdrop/index.ts +0 -4
  38. package/src/tools/dragdrop/modelLoader.ts +0 -145
  39. package/src/tools/dragdrop/page.tsx +0 -45
  40. package/src/tools/prefabeditor/Dropdown.tsx +0 -112
  41. package/src/tools/prefabeditor/EditorContext.tsx +0 -25
  42. package/src/tools/prefabeditor/EditorTree.tsx +0 -452
  43. package/src/tools/prefabeditor/EditorTreeMenus.tsx +0 -307
  44. package/src/tools/prefabeditor/EditorUI.tsx +0 -204
  45. package/src/tools/prefabeditor/EventSystem.tsx +0 -36
  46. package/src/tools/prefabeditor/GameEvents.ts +0 -191
  47. package/src/tools/prefabeditor/InstanceProvider.tsx +0 -466
  48. package/src/tools/prefabeditor/PrefabEditor.tsx +0 -262
  49. package/src/tools/prefabeditor/PrefabRoot.tsx +0 -773
  50. package/src/tools/prefabeditor/components/AmbientLightComponent.tsx +0 -34
  51. package/src/tools/prefabeditor/components/CameraComponent.tsx +0 -117
  52. package/src/tools/prefabeditor/components/ComponentRegistry.ts +0 -40
  53. package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +0 -210
  54. package/src/tools/prefabeditor/components/EnvironmentComponent.tsx +0 -47
  55. package/src/tools/prefabeditor/components/GeometryComponent.tsx +0 -133
  56. package/src/tools/prefabeditor/components/Input.tsx +0 -820
  57. package/src/tools/prefabeditor/components/MaterialComponent.tsx +0 -431
  58. package/src/tools/prefabeditor/components/ModelComponent.tsx +0 -176
  59. package/src/tools/prefabeditor/components/PhysicsComponent.tsx +0 -188
  60. package/src/tools/prefabeditor/components/SpotLightComponent.tsx +0 -109
  61. package/src/tools/prefabeditor/components/TextComponent.tsx +0 -137
  62. package/src/tools/prefabeditor/components/TransformComponent.tsx +0 -173
  63. package/src/tools/prefabeditor/components/index.ts +0 -26
  64. package/src/tools/prefabeditor/page.tsx +0 -10
  65. package/src/tools/prefabeditor/styles.ts +0 -235
  66. package/src/tools/prefabeditor/types.ts +0 -20
  67. package/src/tools/prefabeditor/utils.ts +0 -312
@@ -1,431 +0,0 @@
1
- import { SingleTextureViewer, TextureListViewer } from '../../assetviewer/page';
2
- import { extend } from '@react-three/fiber';
3
- import type { ThreeElement } from '@react-three/fiber';
4
- import { useEffect, useLayoutEffect, useRef, useState } from 'react';
5
- import { createPortal } from 'react-dom';
6
- import { Component } from './ComponentRegistry';
7
- import { FieldRenderer, FieldDefinition, Input } from './Input';
8
- import { colors } from '../styles';
9
- import { useMemo } from 'react';
10
- import { MeshBasicNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu';
11
- import {
12
- RepeatWrapping,
13
- ClampToEdgeWrapping,
14
- SRGBColorSpace,
15
- LinearSRGBColorSpace,
16
- Texture,
17
- Vector2,
18
- NearestFilter,
19
- LinearFilter,
20
- NearestMipmapNearestFilter,
21
- NearestMipmapLinearFilter,
22
- LinearMipmapNearestFilter,
23
- LinearMipmapLinearFilter,
24
- MinificationTextureFilter,
25
- MagnificationTextureFilter,
26
- MeshBasicMaterialProperties,
27
- MeshStandardMaterialProperties,
28
- FrontSide,
29
- BackSide,
30
- DoubleSide,
31
- } from 'three';
32
-
33
- declare module '@react-three/fiber' {
34
- interface ThreeElements {
35
- meshBasicNodeMaterial: ThreeElement<typeof MeshBasicNodeMaterial>;
36
- meshStandardNodeMaterial: ThreeElement<typeof MeshStandardNodeMaterial>;
37
- }
38
- }
39
-
40
- export interface MaterialProps extends Omit<MeshStandardMaterialProperties & MeshBasicMaterialProperties, 'args' | 'normalScale'> {
41
- materialType?: 'standard' | 'basic';
42
- transmission?: number;
43
- thickness?: number;
44
- ior?: number;
45
- texture?: string;
46
- repeat?: boolean;
47
- repeatCount?: [number, number];
48
- generateMipmaps?: boolean;
49
- minFilter?: string;
50
- magFilter?: string;
51
- normalMapTexture?: string;
52
- normalScale?: [number, number];
53
- }
54
-
55
- const PICKER_POPUP_WIDTH = 260;
56
- const PICKER_POPUP_HEIGHT = 360;
57
- extend({
58
- MeshBasicNodeMaterial,
59
- MeshStandardNodeMaterial,
60
- });
61
-
62
- function TexturePicker({
63
- value,
64
- onChange,
65
- basePath
66
- }: {
67
- value: string | undefined;
68
- onChange: (v: string) => void;
69
- basePath: string;
70
- }) {
71
- const [textureFiles, setTextureFiles] = useState<string[]>([]);
72
- const [showPicker, setShowPicker] = useState(false);
73
- const [popupStyle, setPopupStyle] = useState<React.CSSProperties | null>(null);
74
- const triggerRef = useRef<HTMLButtonElement>(null);
75
-
76
- useEffect(() => {
77
- fetch(`${basePath}/textures/manifest.json`)
78
- .then(r => r.json())
79
- .then(data => setTextureFiles(Array.isArray(data) ? data : data.files || []))
80
- .catch(console.error);
81
- }, [basePath]);
82
-
83
- useLayoutEffect(() => {
84
- if (!showPicker || !triggerRef.current || typeof window === 'undefined') return;
85
-
86
- const updatePosition = () => {
87
- const rect = triggerRef.current?.getBoundingClientRect();
88
- if (!rect) return;
89
-
90
- const preferredLeft = rect.left - PICKER_POPUP_WIDTH - 8;
91
- const fallbackLeft = rect.right + 8;
92
- const fitsLeft = preferredLeft >= 8;
93
- const left = fitsLeft ? preferredLeft : Math.min(fallbackLeft, window.innerWidth - PICKER_POPUP_WIDTH - 8);
94
- const top = Math.min(Math.max(8, rect.top), window.innerHeight - PICKER_POPUP_HEIGHT - 8);
95
-
96
- setPopupStyle({
97
- position: 'fixed',
98
- left,
99
- top,
100
- background: colors.bg,
101
- padding: 12,
102
- border: `1px solid ${colors.border}`,
103
- borderRadius: 6,
104
- width: PICKER_POPUP_WIDTH,
105
- height: PICKER_POPUP_HEIGHT,
106
- overflow: 'hidden',
107
- zIndex: 1000,
108
- boxShadow: '0 4px 16px rgba(0,0,0,0.6)',
109
- });
110
- };
111
-
112
- updatePosition();
113
- window.addEventListener('resize', updatePosition);
114
- window.addEventListener('scroll', updatePosition, true);
115
-
116
- return () => {
117
- window.removeEventListener('resize', updatePosition);
118
- window.removeEventListener('scroll', updatePosition, true);
119
- };
120
- }, [showPicker]);
121
-
122
- // Only show 3D preview for server-hosted textures (starting with / or http)
123
- const canPreview = value && (value.startsWith('/') || value.startsWith('http'));
124
-
125
- return (
126
- <div style={{ maxHeight: 128, overflow: 'visible', position: 'relative', display: 'flex', alignItems: 'center' }}>
127
- {canPreview
128
- ? <SingleTextureViewer file={value} basePath={basePath} />
129
- : value
130
- ? <span style={{ fontSize: 10, opacity: 0.6, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{value}</span>
131
- : null
132
- }
133
- <button
134
- ref={triggerRef}
135
- onClick={() => setShowPicker(!showPicker)}
136
- style={{ padding: '4px 8px', backgroundColor: colors.bgLight, color: 'inherit', fontSize: 10, cursor: 'pointer', border: `1px solid ${colors.border}`, borderRadius: 3, marginTop: 4 }}
137
- >
138
- {showPicker ? 'Cancel' : 'Change'}
139
- </button>
140
- <button
141
- onClick={() => {
142
- onChange(undefined as any);
143
- }}
144
- style={{ padding: '4px 8px', backgroundColor: colors.bgLight, color: 'inherit', fontSize: 10, cursor: 'pointer', border: `1px solid ${colors.border}`, borderRadius: 3, marginTop: 4, marginLeft: 4 }}
145
- >
146
- Clear
147
- </button>
148
- {showPicker && popupStyle && typeof document !== 'undefined' && createPortal(
149
- <div style={popupStyle} onMouseLeave={() => setShowPicker(false)}>
150
- <TextureListViewer
151
- files={textureFiles}
152
- selected={value || undefined}
153
- onSelect={(file) => {
154
- onChange(file);
155
- setShowPicker(false);
156
- }}
157
- basePath={basePath}
158
- />
159
- </div>,
160
- document.body
161
- )}
162
- </div>
163
- );
164
- }
165
-
166
- function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { component: any; onUpdate: (newComp: any) => void; basePath?: string }) {
167
- const materialType = component.properties.materialType ?? 'standard';
168
- const hasTexture = !!component.properties.texture;
169
- const hasRepeat = component.properties.repeat;
170
- const isStandardMaterial = materialType === 'standard';
171
-
172
- const fields: FieldDefinition[] = [
173
- {
174
- name: 'materialType',
175
- type: 'select',
176
- label: 'Material Type',
177
- options: [
178
- { value: 'standard', label: 'Standard' },
179
- { value: 'basic', label: 'Basic' },
180
- ],
181
- },
182
- { name: 'color', type: 'color', label: 'Color' },
183
- { name: 'toneMapped', type: 'boolean', label: 'Tone Mapped' },
184
- { name: 'wireframe', type: 'boolean', label: 'Wireframe' },
185
- { name: 'transparent', type: 'boolean', label: 'Transparent' },
186
- { name: 'opacity', type: 'number', label: 'Opacity', min: 0, max: 1, step: 0.01 },
187
- ...(isStandardMaterial ? [
188
- { name: 'metalness', type: 'number', label: 'Metalness', min: 0, max: 1, step: 0.01 },
189
- { name: 'roughness', type: 'number', label: 'Roughness', min: 0, max: 1, step: 0.01 },
190
- { name: 'transmission', type: 'number', label: 'Transmission', min: 0, max: 1, step: 0.01 },
191
- { name: 'thickness', type: 'number', label: 'Thickness', min: 0, step: 0.1 },
192
- { name: 'ior', type: 'number', label: 'IOR (Index of Refraction)', min: 1, max: 2.333, step: 0.01 },
193
- ] as FieldDefinition[] : []),
194
- {
195
- name: 'side',
196
- type: 'select',
197
- label: 'Side',
198
- options: [
199
- { value: 'FrontSide', label: 'Front' },
200
- { value: 'BackSide', label: 'Back' },
201
- { value: 'DoubleSide', label: 'Double' },
202
- ],
203
- } as FieldDefinition,
204
- {
205
- name: 'texture',
206
- type: 'custom',
207
- label: 'Texture File',
208
- render: ({ value, onChange }) => (
209
- <TexturePicker value={value} onChange={onChange} basePath={basePath} />
210
- ),
211
- },
212
- // Conditional texture settings
213
- ...(hasTexture ? [
214
- { name: 'repeat', type: 'boolean', label: 'Repeat Texture' } as FieldDefinition,
215
- ...(hasRepeat ? [{
216
- name: 'repeatCount',
217
- type: 'custom',
218
- label: 'Repeat (X, Y)',
219
- render: ({ value, onChange }: { value: [number, number] | undefined; onChange: (v: [number, number]) => void }) => (
220
- <div style={{ display: 'flex', gap: 2 }}>
221
- <Input
222
- label="X"
223
- value={value?.[0] ?? 1}
224
- onChange={v => onChange([v, value?.[1] ?? 1])}
225
- min={0.01}
226
- max={100}
227
- step={0.1}
228
- />
229
- <Input
230
- label="Y"
231
- value={value?.[1] ?? 1}
232
- onChange={v => onChange([value?.[0] ?? 1, v])}
233
- min={0.01}
234
- max={100}
235
- step={0.1}
236
- />
237
- </div>
238
- ),
239
- } as FieldDefinition] : []),
240
- {
241
- name: 'normalMapTexture',
242
- type: 'custom',
243
- label: 'Normal Map',
244
- render: ({ value, onChange }) => (
245
- <TexturePicker value={value} onChange={onChange} basePath={basePath} />
246
- ),
247
- } as FieldDefinition,
248
- ...(component.properties.normalMapTexture ? [{
249
- name: 'normalScale',
250
- type: 'custom',
251
- label: 'Normal Scale (X, Y)',
252
- render: ({ value, onChange }: { value: [number, number] | undefined; onChange: (v: [number, number]) => void }) => (
253
- <div style={{ display: 'flex', gap: 2 }}>
254
- <Input
255
- label="X"
256
- value={value?.[0] ?? 1}
257
- onChange={v => onChange([v, value?.[1] ?? 1])}
258
- min={0}
259
- max={5}
260
- step={0.01}
261
- />
262
- <Input
263
- label="Y"
264
- value={value?.[1] ?? 1}
265
- onChange={v => onChange([value?.[0] ?? 1, v])}
266
- min={0}
267
- max={5}
268
- step={0.01}
269
- />
270
- </div>
271
- ),
272
- } as FieldDefinition] : []),
273
- { name: 'generateMipmaps', type: 'boolean', label: 'Generate Mipmaps' } as FieldDefinition,
274
- {
275
- name: 'minFilter',
276
- type: 'select',
277
- label: 'Min Filter',
278
- options: [
279
- { value: 'NearestFilter', label: 'Nearest' },
280
- { value: 'NearestMipmapNearestFilter', label: 'Nearest Mipmap Nearest' },
281
- { value: 'NearestMipmapLinearFilter', label: 'Nearest Mipmap Linear' },
282
- { value: 'LinearFilter', label: 'Linear' },
283
- { value: 'LinearMipmapNearestFilter', label: 'Linear Mipmap Nearest' },
284
- { value: 'LinearMipmapLinearFilter', label: 'Linear Mipmap Linear (Default)' },
285
- ],
286
- } as FieldDefinition,
287
- {
288
- name: 'magFilter',
289
- type: 'select',
290
- label: 'Mag Filter',
291
- options: [
292
- { value: 'NearestFilter', label: 'Nearest' },
293
- { value: 'LinearFilter', label: 'Linear (Default)' },
294
- ],
295
- } as FieldDefinition,
296
- ] : []),
297
- ];
298
-
299
- return (
300
- <FieldRenderer
301
- fields={fields}
302
- values={component.properties}
303
- onChange={onUpdate}
304
- />
305
- );
306
- }
307
-
308
- // View for Material component
309
- function MaterialComponentView({ properties, loadedTextures }: { properties: MaterialProps, loadedTextures?: Record<string, Texture> }) {
310
- const materialType = properties?.materialType ?? 'standard';
311
- const textureName = properties?.texture;
312
- const repeat = properties?.repeat;
313
- const repeatCount = properties?.repeatCount;
314
- const generateMipmaps = properties?.generateMipmaps !== false;
315
- const minFilter = properties?.minFilter || 'LinearMipmapLinearFilter';
316
- const magFilter = properties?.magFilter || 'LinearFilter';
317
- const texture = textureName && loadedTextures ? loadedTextures[textureName] : undefined;
318
-
319
- const normalMapTextureName = properties?.normalMapTexture;
320
- const normalScaleProp = properties?.normalScale;
321
- const normalMapTexture = normalMapTextureName && loadedTextures ? loadedTextures[normalMapTextureName] : undefined;
322
- const materialSource: MaterialProps = properties ?? {};
323
-
324
- // Destructure all material props and separate custom texture handling props
325
- const {
326
- texture: _texture,
327
- repeat: _repeat,
328
- repeatCount: _repeatCount,
329
- generateMipmaps: _generateMipmaps,
330
- minFilter: _minFilter,
331
- magFilter: _magFilter,
332
- map: _map,
333
- materialType: _materialType,
334
- normalMapTexture: _normalMapTexture,
335
- normalScale: _normalScale,
336
- normalMap: _normalMap,
337
- side: sideProp,
338
- ...materialProps
339
- } = materialSource;
340
-
341
- const sideMap: Record<string, any> = { FrontSide, BackSide, DoubleSide };
342
- const resolvedSide = sideProp ? (sideMap[sideProp as unknown as string] ?? FrontSide) : FrontSide;
343
-
344
- const minFilterMap: Record<string, MinificationTextureFilter> = {
345
- NearestFilter,
346
- LinearFilter,
347
- NearestMipmapNearestFilter,
348
- NearestMipmapLinearFilter,
349
- LinearMipmapNearestFilter,
350
- LinearMipmapLinearFilter
351
- };
352
-
353
- const magFilterMap: Record<string, MagnificationTextureFilter> = {
354
- NearestFilter,
355
- LinearFilter
356
- };
357
-
358
- const finalTexture = useMemo(() => {
359
- if (!texture) return undefined;
360
- const t = texture.clone();
361
- if (repeat) {
362
- t.wrapS = t.wrapT = RepeatWrapping;
363
- if (repeatCount) t.repeat.set(repeatCount[0], repeatCount[1]);
364
- } else {
365
- t.wrapS = t.wrapT = ClampToEdgeWrapping;
366
- t.repeat.set(1, 1);
367
- }
368
- t.colorSpace = SRGBColorSpace;
369
- t.generateMipmaps = generateMipmaps;
370
- t.minFilter = minFilterMap[minFilter] ?? LinearMipmapLinearFilter;
371
- t.magFilter = magFilterMap[magFilter] ?? LinearFilter;
372
- t.needsUpdate = true;
373
- return t;
374
- }, [texture, repeat, repeatCount?.[0], repeatCount?.[1], generateMipmaps, minFilter, magFilter]);
375
-
376
- const finalNormalMap = useMemo(() => {
377
- if (!normalMapTexture) return undefined;
378
- const t = normalMapTexture.clone();
379
- t.colorSpace = LinearSRGBColorSpace;
380
- t.needsUpdate = true;
381
- return t;
382
- }, [normalMapTexture]);
383
-
384
- const normalScaleVec = useMemo(() => {
385
- if (!finalNormalMap) return undefined;
386
- return new Vector2(normalScaleProp?.[0] ?? 1, normalScaleProp?.[1] ?? 1);
387
- }, [finalNormalMap, normalScaleProp?.[0], normalScaleProp?.[1]]);
388
-
389
- if (!properties) {
390
- return <meshStandardNodeMaterial color="red" wireframe />;
391
- }
392
-
393
- const materialKey = `${finalTexture?.uuid ?? 'no-texture'}:${materialProps.transparent ? 'transparent' : 'opaque'}`;
394
- const sharedProps = {
395
- map: finalTexture,
396
- side: resolvedSide,
397
- ...materialProps,
398
- };
399
-
400
- if (materialType === 'basic') {
401
- return <meshBasicNodeMaterial key={materialKey} {...sharedProps} />;
402
- }
403
-
404
- return (
405
- <meshStandardNodeMaterial
406
- key={materialKey}
407
- {...sharedProps}
408
- normalMap={finalNormalMap}
409
- normalScale={normalScaleVec}
410
- />
411
- );
412
- }
413
-
414
- const MaterialComponent: Component = {
415
- name: 'Material',
416
- Editor: MaterialComponentEditor,
417
- View: MaterialComponentView,
418
- nonComposable: true,
419
- defaultProperties: {
420
- materialType: 'standard',
421
- color: '#ffffff',
422
- toneMapped: true,
423
- wireframe: false,
424
- transparent: false,
425
- opacity: 1,
426
- metalness: 0,
427
- roughness: 1
428
- }
429
- };
430
-
431
- export default MaterialComponent;
@@ -1,176 +0,0 @@
1
- import { ModelListViewer, SingleModelViewer } from '../../assetviewer/page';
2
- import { useEffect, useLayoutEffect, useState, useMemo, useRef } from 'react';
3
- import { createPortal } from 'react-dom';
4
- import { Component } from './ComponentRegistry';
5
- import { FieldRenderer, FieldDefinition } from './Input';
6
- import { GameObject } from '../types';
7
-
8
- const PICKER_POPUP_WIDTH = 260;
9
- const PICKER_POPUP_HEIGHT = 360;
10
-
11
- function ModelPicker({
12
- value,
13
- onChange,
14
- basePath,
15
- nodeId
16
- }: {
17
- value: string | undefined;
18
- onChange: (v: string) => void;
19
- basePath: string;
20
- nodeId?: string;
21
- }) {
22
- const [modelFiles, setModelFiles] = useState<string[]>([]);
23
- const [showPicker, setShowPicker] = useState(false);
24
- const [popupStyle, setPopupStyle] = useState<React.CSSProperties | null>(null);
25
- const triggerRef = useRef<HTMLButtonElement>(null);
26
-
27
- useEffect(() => {
28
- fetch(`${basePath}/models/manifest.json`)
29
- .then(r => r.json())
30
- .then(data => setModelFiles(Array.isArray(data) ? data : data.files || []))
31
- .catch(console.error);
32
- }, [basePath]);
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
-
73
- const handleModelSelect = (file: string) => {
74
- const filename = file.startsWith('/') ? file.slice(1) : file;
75
- onChange(filename);
76
- setShowPicker(false);
77
- };
78
-
79
- return (
80
- <div style={{ maxHeight: 128, overflow: 'visible', position: 'relative', display: 'flex', alignItems: 'center' }}>
81
- <SingleModelViewer file={value ? `/${value}` : undefined} basePath={basePath} />
82
- <button
83
- ref={triggerRef}
84
- onClick={() => setShowPicker(!showPicker)}
85
- style={{ padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }}
86
- >
87
- {showPicker ? 'Cancel' : 'Change'}
88
- </button>
89
- <button
90
- onClick={() => {
91
- onChange(undefined as any);
92
- }}
93
- 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 }}
94
- >
95
- Clear
96
- </button>
97
- {showPicker && popupStyle && typeof document !== 'undefined' && createPortal(
98
- <div style={popupStyle} onMouseLeave={() => setShowPicker(false)}>
99
- <ModelListViewer
100
- key={nodeId}
101
- files={modelFiles}
102
- selected={value ? `/${value}` : undefined}
103
- onSelect={handleModelSelect}
104
- basePath={basePath}
105
- />
106
- </div>,
107
- document.body
108
- )}
109
- </div>
110
- );
111
- }
112
-
113
- function ModelComponentEditor({ component, node, onUpdate, basePath = "" }: { component: any; node?: GameObject; onUpdate: (newComp: any) => void; basePath?: string }) {
114
- const fields: FieldDefinition[] = [
115
- {
116
- name: 'filename',
117
- type: 'custom',
118
- label: 'Model File',
119
- render: ({ value, onChange }) => (
120
- <ModelPicker
121
- value={value}
122
- onChange={onChange}
123
- basePath={basePath}
124
- nodeId={node?.id}
125
- />
126
- ),
127
- },
128
- { name: 'instanced', type: 'boolean', label: 'Instanced' },
129
- ];
130
-
131
- return (
132
- <FieldRenderer
133
- fields={fields}
134
- values={component.properties}
135
- onChange={onUpdate}
136
- />
137
- );
138
- }
139
-
140
- // View for Model component
141
- function ModelComponentView({ properties, loadedModels, children }: { properties: any, loadedModels?: Record<string, any>, children?: React.ReactNode }) {
142
- // Instanced models are handled elsewhere (GameInstance), so only render non-instanced here
143
- if (!properties.filename || properties.instanced) return <>{children}</>;
144
-
145
- const sourceModel = loadedModels?.[properties.filename];
146
-
147
- // Clone model once and set up shadows - memoized to avoid cloning on every render
148
- const clonedModel = useMemo(() => {
149
- if (!sourceModel) return null;
150
- const clone = sourceModel.clone();
151
- clone.traverse((obj: any) => {
152
- if (obj.isMesh) {
153
- obj.castShadow = true;
154
- obj.receiveShadow = true;
155
- }
156
- });
157
- return clone;
158
- }, [sourceModel]);
159
-
160
- if (!clonedModel) return <>{children}</>;
161
-
162
- return <primitive object={clonedModel}>{children}</primitive>;
163
- }
164
-
165
- const ModelComponent: Component = {
166
- name: 'Model',
167
- Editor: ModelComponentEditor,
168
- View: ModelComponentView,
169
- nonComposable: true,
170
- defaultProperties: {
171
- filename: '',
172
- instanced: false
173
- }
174
- };
175
-
176
- export default ModelComponent;