react-three-game 0.0.60 → 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 (58) hide show
  1. package/package.json +9 -3
  2. package/.gitattributes +0 -2
  3. package/.github/copilot-instructions.md +0 -83
  4. package/.github/workflows/nextjs.yml +0 -99
  5. package/.gitmodules +0 -3
  6. package/assets/architecture.png +0 -0
  7. package/assets/editor.gif +0 -0
  8. package/assets/favicon.ico +0 -0
  9. package/assets/react-three-game-logo.png +0 -0
  10. package/dist/tools/dragdrop/page.d.ts +0 -1
  11. package/dist/tools/dragdrop/page.js +0 -11
  12. package/dist/tools/prefabeditor/EntityEvents.d.ts +0 -54
  13. package/dist/tools/prefabeditor/EntityEvents.js +0 -85
  14. package/dist/tools/prefabeditor/page.d.ts +0 -1
  15. package/dist/tools/prefabeditor/page.js +0 -5
  16. package/react-three-game-skill/.gitattributes +0 -2
  17. package/react-three-game-skill/README.md +0 -7
  18. package/react-three-game-skill/react-three-game/SKILL.md +0 -514
  19. package/react-three-game-skill/react-three-game/rules/ADVANCED_PHYSICS.md +0 -472
  20. package/react-three-game-skill/react-three-game/rules/LIGHTING.md +0 -6
  21. package/src/helpers/SoundManager.ts +0 -130
  22. package/src/helpers/index.ts +0 -91
  23. package/src/index.ts +0 -59
  24. package/src/shared/ContactShadow.tsx +0 -74
  25. package/src/shared/GameCanvas.tsx +0 -52
  26. package/src/tools/assetviewer/page.tsx +0 -425
  27. package/src/tools/dragdrop/DragDropLoader.tsx +0 -159
  28. package/src/tools/dragdrop/index.ts +0 -4
  29. package/src/tools/dragdrop/modelLoader.ts +0 -204
  30. package/src/tools/dragdrop/page.tsx +0 -45
  31. package/src/tools/prefabeditor/Dropdown.tsx +0 -112
  32. package/src/tools/prefabeditor/EditorContext.tsx +0 -25
  33. package/src/tools/prefabeditor/EditorTree.tsx +0 -452
  34. package/src/tools/prefabeditor/EditorTreeMenus.tsx +0 -307
  35. package/src/tools/prefabeditor/EditorUI.tsx +0 -204
  36. package/src/tools/prefabeditor/EventSystem.tsx +0 -36
  37. package/src/tools/prefabeditor/GameEvents.ts +0 -191
  38. package/src/tools/prefabeditor/InstanceProvider.tsx +0 -466
  39. package/src/tools/prefabeditor/PrefabEditor.tsx +0 -256
  40. package/src/tools/prefabeditor/PrefabRoot.tsx +0 -767
  41. package/src/tools/prefabeditor/components/AmbientLightComponent.tsx +0 -34
  42. package/src/tools/prefabeditor/components/CameraComponent.tsx +0 -117
  43. package/src/tools/prefabeditor/components/ComponentRegistry.ts +0 -40
  44. package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +0 -210
  45. package/src/tools/prefabeditor/components/EnvironmentComponent.tsx +0 -47
  46. package/src/tools/prefabeditor/components/GeometryComponent.tsx +0 -133
  47. package/src/tools/prefabeditor/components/Input.tsx +0 -820
  48. package/src/tools/prefabeditor/components/MaterialComponent.tsx +0 -431
  49. package/src/tools/prefabeditor/components/ModelComponent.tsx +0 -176
  50. package/src/tools/prefabeditor/components/PhysicsComponent.tsx +0 -188
  51. package/src/tools/prefabeditor/components/SpotLightComponent.tsx +0 -109
  52. package/src/tools/prefabeditor/components/TextComponent.tsx +0 -137
  53. package/src/tools/prefabeditor/components/TransformComponent.tsx +0 -173
  54. package/src/tools/prefabeditor/components/index.ts +0 -26
  55. package/src/tools/prefabeditor/page.tsx +0 -10
  56. package/src/tools/prefabeditor/styles.ts +0 -235
  57. package/src/tools/prefabeditor/types.ts +0 -20
  58. 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;