react-three-game 0.0.60 → 0.0.62

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