react-three-game 0.0.57 → 0.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.github/copilot-instructions.md +1 -1
  2. package/README.md +59 -35
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/tools/assetviewer/page.js +1 -1
  6. package/dist/tools/dragdrop/DragDropLoader.d.ts +19 -6
  7. package/dist/tools/dragdrop/DragDropLoader.js +77 -40
  8. package/dist/tools/dragdrop/index.d.ts +4 -0
  9. package/dist/tools/dragdrop/index.js +2 -0
  10. package/dist/tools/dragdrop/modelLoader.d.ts +5 -6
  11. package/dist/tools/dragdrop/modelLoader.js +62 -49
  12. package/dist/tools/dragdrop/page.js +3 -3
  13. package/dist/tools/prefabeditor/EditorTree.js +24 -48
  14. package/dist/tools/prefabeditor/EditorTreeMenus.d.ts +33 -0
  15. package/dist/tools/prefabeditor/EditorTreeMenus.js +136 -0
  16. package/dist/tools/prefabeditor/PrefabEditor.js +1 -1
  17. package/dist/tools/prefabeditor/PrefabRoot.js +5 -3
  18. package/dist/tools/prefabeditor/components/CameraComponent.js +32 -12
  19. package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +49 -23
  20. package/dist/tools/prefabeditor/components/MaterialComponent.d.ts +8 -0
  21. package/dist/tools/prefabeditor/components/MaterialComponent.js +11 -5
  22. package/dist/tools/prefabeditor/components/SpotLightComponent.js +34 -13
  23. package/package.json +2 -2
  24. package/react-three-game-skill/react-three-game/SKILL.md +63 -5
  25. package/react-three-game-skill/react-three-game/rules/ADVANCED_PHYSICS.md +7 -5
  26. package/src/index.ts +1 -1
  27. package/src/tools/assetviewer/page.tsx +1 -1
  28. package/src/tools/dragdrop/DragDropLoader.tsx +118 -55
  29. package/src/tools/dragdrop/index.ts +4 -0
  30. package/src/tools/dragdrop/modelLoader.ts +95 -50
  31. package/src/tools/dragdrop/page.tsx +7 -4
  32. package/src/tools/prefabeditor/EditorTree.tsx +56 -125
  33. package/src/tools/prefabeditor/EditorTreeMenus.tsx +307 -0
  34. package/src/tools/prefabeditor/PrefabEditor.tsx +1 -1
  35. package/src/tools/prefabeditor/PrefabRoot.tsx +6 -3
  36. package/src/tools/prefabeditor/components/CameraComponent.tsx +51 -14
  37. package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +59 -28
  38. package/src/tools/prefabeditor/components/MaterialComponent.tsx +18 -9
  39. package/src/tools/prefabeditor/components/SpotLightComponent.tsx +49 -18
@@ -1,14 +1,16 @@
1
- import { GLTFLoader, FBXLoader, DRACOLoader } from "three/examples/jsm/Addons.js";
1
+ import type { Object3D } from "three";
2
+ import { DRACOLoader, FBXLoader, GLTFLoader } from "three/examples/jsm/Addons.js";
3
+
4
+ export type LoadedModel = Object3D;
2
5
 
3
6
  export type ModelLoadResult = {
4
7
  success: boolean;
5
- model?: any;
6
- error?: any;
8
+ model?: LoadedModel;
9
+ error?: unknown;
7
10
  };
8
11
 
9
12
  export type ProgressCallback = (filename: string, loaded: number, total: number) => void;
10
13
 
11
- // Singleton loader instances
12
14
  const dracoLoader = new DRACOLoader();
13
15
  dracoLoader.setDecoderPath("https://www.gstatic.com/draco/v1/decoders/");
14
16
 
@@ -17,37 +19,76 @@ gltfLoader.setDRACOLoader(dracoLoader);
17
19
 
18
20
  const fbxLoader = new FBXLoader();
19
21
 
20
- /**
21
- * Parse a model from a File object (e.g. from drag-drop or file picker).
22
- * Returns the parsed Three.js Object3D scene.
23
- */
22
+ type ModelFileKind = "gltf" | "fbx";
23
+
24
+ function normalizeModelPath(name: string) {
25
+ return name.split(/[?#]/, 1)[0].toLowerCase();
26
+ }
27
+
28
+ function getModelFileKind(name: string): ModelFileKind | null {
29
+ const normalizedName = normalizeModelPath(name);
30
+
31
+ if (normalizedName.endsWith(".glb") || normalizedName.endsWith(".gltf")) {
32
+ return "gltf";
33
+ }
34
+
35
+ if (normalizedName.endsWith(".fbx")) {
36
+ return "fbx";
37
+ }
38
+
39
+ return null;
40
+ }
41
+
42
+ export function canParseModelFile(file: File | string) {
43
+ const filename = typeof file === "string" ? file : file.name;
44
+ return getModelFileKind(filename) !== null;
45
+ }
46
+
47
+ function parseModelBuffer(arrayBuffer: ArrayBuffer, sourceName: string): Promise<ModelLoadResult> {
48
+ const modelFileKind = getModelFileKind(sourceName);
49
+
50
+ if (modelFileKind === "gltf") {
51
+ return new Promise(resolve => {
52
+ gltfLoader.parse(
53
+ arrayBuffer,
54
+ "",
55
+ gltf => {
56
+ resolve({ success: true, model: gltf.scene });
57
+ },
58
+ error => {
59
+ resolve({ success: false, error });
60
+ },
61
+ );
62
+ });
63
+ }
64
+
65
+ if (modelFileKind === "fbx") {
66
+ try {
67
+ const model = fbxLoader.parse(arrayBuffer, "");
68
+ return Promise.resolve({ success: true, model });
69
+ } catch (error) {
70
+ return Promise.resolve({ success: false, error });
71
+ }
72
+ }
73
+
74
+ return Promise.resolve({ success: false, error: new Error(`Unsupported file format: ${sourceName}`) });
75
+ }
76
+
24
77
  export function parseModelFromFile(file: File): Promise<ModelLoadResult> {
25
- return new Promise((resolve) => {
78
+ return new Promise(resolve => {
26
79
  const reader = new FileReader();
27
- reader.onload = (event) => {
80
+
81
+ reader.onload = event => {
28
82
  const arrayBuffer = event.target?.result as ArrayBuffer;
83
+
29
84
  if (!arrayBuffer) {
30
- resolve({ success: false, error: new Error('Failed to read file') });
85
+ resolve({ success: false, error: new Error("Failed to read file") });
31
86
  return;
32
87
  }
33
- const name = file.name.toLowerCase();
34
- if (name.endsWith('.glb') || name.endsWith('.gltf')) {
35
- gltfLoader.parse(arrayBuffer, '', (gltf) => {
36
- resolve({ success: true, model: gltf.scene });
37
- }, (error) => {
38
- resolve({ success: false, error });
39
- });
40
- } else if (name.endsWith('.fbx')) {
41
- try {
42
- const model = fbxLoader.parse(arrayBuffer, '');
43
- resolve({ success: true, model });
44
- } catch (error) {
45
- resolve({ success: false, error });
46
- }
47
- } else {
48
- resolve({ success: false, error: new Error(`Unsupported file format: ${file.name}`) });
49
- }
88
+
89
+ void parseModelBuffer(arrayBuffer, file.name).then(resolve);
50
90
  };
91
+
51
92
  reader.onerror = () => resolve({ success: false, error: reader.error });
52
93
  reader.readAsArrayBuffer(file);
53
94
  });
@@ -55,45 +96,49 @@ export function parseModelFromFile(file: File): Promise<ModelLoadResult> {
55
96
 
56
97
  export async function loadModel(
57
98
  filename: string,
58
- onProgress?: ProgressCallback
99
+ onProgress?: ProgressCallback,
59
100
  ): Promise<ModelLoadResult> {
60
101
  try {
61
- // Use filename directly (should already include leading /)
62
102
  const fullPath = filename;
103
+ const modelFileKind = getModelFileKind(filename);
63
104
 
64
- if (filename.endsWith('.glb') || filename.endsWith('.gltf')) {
65
- return new Promise((resolve) => {
105
+ if (modelFileKind === "gltf") {
106
+ return new Promise(resolve => {
66
107
  gltfLoader.load(
67
108
  fullPath,
68
- (gltf) => resolve({ success: true, model: gltf.scene }),
69
- (progressEvent) => {
70
- if (onProgress) {
71
- // Use loaded as total if total is not available
72
- const total = progressEvent.total || progressEvent.loaded;
73
- onProgress(filename, progressEvent.loaded, total);
109
+ gltf => resolve({ success: true, model: gltf.scene }),
110
+ progressEvent => {
111
+ if (!onProgress) {
112
+ return;
74
113
  }
114
+
115
+ const total = progressEvent.total || progressEvent.loaded;
116
+ onProgress(filename, progressEvent.loaded, total);
75
117
  },
76
- (error) => resolve({ success: false, error })
118
+ error => resolve({ success: false, error }),
77
119
  );
78
120
  });
79
- } else if (filename.endsWith('.fbx')) {
80
- return new Promise((resolve) => {
121
+ }
122
+
123
+ if (modelFileKind === "fbx") {
124
+ return new Promise(resolve => {
81
125
  fbxLoader.load(
82
126
  fullPath,
83
- (model) => resolve({ success: true, model }),
84
- (progressEvent) => {
85
- if (onProgress) {
86
- // Use loaded as total if total is not available
87
- const total = progressEvent.total || progressEvent.loaded;
88
- onProgress(filename, progressEvent.loaded, total);
127
+ model => resolve({ success: true, model }),
128
+ progressEvent => {
129
+ if (!onProgress) {
130
+ return;
89
131
  }
132
+
133
+ const total = progressEvent.total || progressEvent.loaded;
134
+ onProgress(filename, progressEvent.loaded, total);
90
135
  },
91
- (error) => resolve({ success: false, error })
136
+ error => resolve({ success: false, error }),
92
137
  );
93
138
  });
94
- } else {
95
- return { success: false, error: new Error(`Unsupported file format: ${filename}`) };
96
139
  }
140
+
141
+ return { success: false, error: new Error(`Unsupported file format: ${filename}`) };
97
142
  } catch (error) {
98
143
  return { success: false, error };
99
144
  }
@@ -3,15 +3,18 @@
3
3
  import { Physics, RigidBody } from "@react-three/rapier";
4
4
  import { OrbitControls } from "@react-three/drei";
5
5
  import { useState } from "react";
6
- import { DragDropLoader } from "./DragDropLoader";
6
+ import { DragDropLoader } from "./index";
7
7
  import GameCanvas from "../../shared/GameCanvas";
8
8
 
9
9
  export default function Home() {
10
10
  const [models, setModels] = useState<any[]>([]);
11
11
 
12
12
  return (
13
- <>
14
- <DragDropLoader onModelLoaded={model => setModels(prev => [...prev, model])} />
13
+ <DragDropLoader
14
+ onModelLoaded={model => setModels(prev => [...prev, model])}
15
+ className="w-full items-center justify-items-center min-h-screen"
16
+ style={{ height: "100vh" }}
17
+ >
15
18
  <div className="w-full items-center justify-items-center min-h-screen" style={{ height: "100vh" }}>
16
19
  <GameCanvas>
17
20
  <Physics>
@@ -37,6 +40,6 @@ export default function Home() {
37
40
  </Physics>
38
41
  </GameCanvas>
39
42
  </div>
40
- </>
43
+ </DragDropLoader>
41
44
  );
42
45
  }
@@ -1,10 +1,11 @@
1
1
  import { Dispatch, SetStateAction, useState, MouseEvent } from 'react';
2
2
  import { Prefab, GameObject } from "./types";
3
3
  import { getComponent } from './components/ComponentRegistry';
4
- import { base, colors, tree, menu } from './styles';
5
- import { findNode, findParent, deleteNode, cloneNode, updateNodeById, loadJson, saveJson, regenerateIds } from './utils';
4
+ import { base, colors, tree } from './styles';
5
+ import { findNode, findParent, deleteNode, cloneNode, updateNodeById } from './utils';
6
6
  import { useEditorContext } from './EditorContext';
7
7
  import { Dropdown } from './Dropdown';
8
+ import { FileMenu, MenuTriggerButton, TreeContextMenu, TreeContextMenuState, TreeNodeMenu } from './EditorTreeMenus';
8
9
 
9
10
  type DropPosition = 'before' | 'inside';
10
11
 
@@ -100,6 +101,7 @@ export default function EditorTree({
100
101
  const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
101
102
  const [collapsed, setCollapsed] = useState(false);
102
103
  const [searchQuery, setSearchQuery] = useState('');
104
+ const [contextMenu, setContextMenu] = useState<TreeContextMenuState>(null);
103
105
 
104
106
  if (!prefabData || !setPrefabData) return null;
105
107
 
@@ -165,6 +167,30 @@ export default function EditorTree({
165
167
  }));
166
168
  };
167
169
 
170
+ const closeContextMenu = () => setContextMenu(null);
171
+
172
+ const openContextMenu = (nodeId: string, x: number, y: number) => {
173
+ setSelectedId(nodeId);
174
+ setContextMenu({ nodeId, x, y });
175
+ };
176
+
177
+ const handleFocus = (nodeId: string) => {
178
+ setSelectedId(nodeId);
179
+ onFocusNode?.(nodeId);
180
+ };
181
+
182
+ const renderTreeNodeMenu = (nodeId: string, isRoot: boolean, onClose: () => void) => (
183
+ <TreeNodeMenu
184
+ isRoot={isRoot}
185
+ nodeId={nodeId}
186
+ onAddChild={handleAddChild}
187
+ onFocus={handleFocus}
188
+ onDuplicate={isRoot ? undefined : handleDuplicate}
189
+ onDelete={isRoot ? undefined : handleDelete}
190
+ onClose={onClose}
191
+ />
192
+ );
193
+
168
194
  const handleDragStart = (e: React.DragEvent, id: string) => {
169
195
  if (id === prefabData.root.id) return e.preventDefault();
170
196
  e.dataTransfer.effectAllowed = "move";
@@ -240,6 +266,11 @@ export default function EditorTree({
240
266
  }}
241
267
  draggable={!isRoot}
242
268
  onClick={(e) => { e.stopPropagation(); setSelectedId(node.id); }}
269
+ onContextMenu={(e) => {
270
+ e.preventDefault();
271
+ e.stopPropagation();
272
+ openContextMenu(node.id, e.clientX, e.clientY);
273
+ }}
243
274
  onDragStart={(e) => handleDragStart(e, node.id)}
244
275
  onDragEnd={() => { setDraggedId(null); setDropTarget(null); }}
245
276
  onDragOver={(e) => handleDragOver(e, node.id, isRoot)}
@@ -269,8 +300,10 @@ export default function EditorTree({
269
300
  <Dropdown
270
301
  placement="bottom-end"
271
302
  trigger={({ ref, toggle }) => (
272
- <button
273
- ref={ref}
303
+ <MenuTriggerButton
304
+ buttonRef={ref}
305
+ onToggle={toggle}
306
+ title="Node Actions"
274
307
  style={{
275
308
  background: 'none',
276
309
  border: 'none',
@@ -280,32 +313,12 @@ export default function EditorTree({
280
313
  opacity: 0.7,
281
314
  color: 'inherit',
282
315
  }}
283
- onClick={(e) => {
284
- e.stopPropagation();
285
- toggle();
286
- }}
287
- title="Node Actions"
288
316
  >
289
317
 
290
- </button>
318
+ </MenuTriggerButton>
291
319
  )}
292
320
  >
293
- {(close) => (
294
- <div style={{ ...menu.container, position: 'static' }} onClick={(e) => e.stopPropagation()}>
295
- <button style={menu.item} onClick={() => { handleAddChild(node.id); close(); }}>
296
- Add Child
297
- </button>
298
- <button style={menu.item} onClick={() => { setSelectedId(node.id); onFocusNode?.(node.id); close(); }}>
299
- Focus Camera
300
- </button>
301
- <button style={menu.item} onClick={() => { handleDuplicate(node.id); close(); }}>
302
- Duplicate
303
- </button>
304
- <button style={{ ...menu.item, ...menu.danger }} onClick={() => { handleDelete(node.id); close(); }}>
305
- Delete
306
- </button>
307
- </div>
308
- )}
321
+ {(close) => renderTreeNodeMenu(node.id, false, close)}
309
322
  </Dropdown>
310
323
  <button
311
324
  style={{
@@ -331,8 +344,10 @@ export default function EditorTree({
331
344
  <Dropdown
332
345
  placement="bottom-end"
333
346
  trigger={({ ref, toggle }) => (
334
- <button
335
- ref={ref}
347
+ <MenuTriggerButton
348
+ buttonRef={ref}
349
+ onToggle={toggle}
350
+ title="Scene Actions"
336
351
  style={{
337
352
  background: 'none',
338
353
  border: 'none',
@@ -342,26 +357,12 @@ export default function EditorTree({
342
357
  opacity: 0.7,
343
358
  color: 'inherit',
344
359
  }}
345
- onClick={(e) => {
346
- e.stopPropagation();
347
- toggle();
348
- }}
349
- title="Scene Actions"
350
360
  >
351
361
 
352
- </button>
362
+ </MenuTriggerButton>
353
363
  )}
354
364
  >
355
- {(close) => (
356
- <div style={{ ...menu.container, position: 'static' }} onClick={(e) => e.stopPropagation()}>
357
- <button style={menu.item} onClick={() => { handleAddChild(node.id); close(); }}>
358
- Add Child
359
- </button>
360
- <button style={menu.item} onClick={() => { setSelectedId(node.id); onFocusNode?.(node.id); close(); }}>
361
- Focus Camera
362
- </button>
363
- </div>
364
- )}
365
+ {(close) => renderTreeNodeMenu(node.id, true, close)}
365
366
  </Dropdown>
366
367
  )}
367
368
  </div>
@@ -399,14 +400,14 @@ export default function EditorTree({
399
400
  <Dropdown
400
401
  placement="bottom-end"
401
402
  trigger={({ ref, toggle }) => (
402
- <button
403
- ref={ref}
403
+ <MenuTriggerButton
404
+ buttonRef={ref}
405
+ onToggle={toggle}
406
+ title="Menu"
404
407
  style={{ ...base.btn, padding: '2px 6px', fontSize: 10 }}
405
- onClick={(e) => { e.stopPropagation(); toggle(); }}
406
- title="File"
407
408
  >
408
409
 
409
- </button>
410
+ </MenuTriggerButton>
410
411
  )}
411
412
  >
412
413
  {(close) => (
@@ -439,83 +440,13 @@ export default function EditorTree({
439
440
  </>
440
441
  )}
441
442
  </div>
443
+ <TreeContextMenu
444
+ contextMenu={contextMenu}
445
+ onClose={closeContextMenu}
446
+ >
447
+ {(nodeId, close) => renderTreeNodeMenu(nodeId, nodeId === prefabData.root.id, close)}
448
+ </TreeContextMenu>
442
449
 
443
450
  </>
444
451
  );
445
452
  }
446
-
447
- function FileMenu({
448
- prefabData,
449
- setPrefabData,
450
- onClose
451
- }: {
452
- prefabData: Prefab;
453
- setPrefabData: Dispatch<SetStateAction<Prefab>>;
454
- onClose: () => void;
455
- }) {
456
- const { onScreenshot, onExportGLB } = useEditorContext();
457
-
458
- const handleLoad = async () => {
459
- const loadedPrefab = await loadJson();
460
- if (!loadedPrefab) return;
461
- setPrefabData(loadedPrefab);
462
- onClose();
463
- };
464
-
465
- const handleSave = () => {
466
- saveJson(prefabData, "prefab");
467
- onClose();
468
- };
469
-
470
- const handleLoadIntoScene = async () => {
471
- const loadedPrefab = await loadJson();
472
- if (!loadedPrefab) return;
473
-
474
- setPrefabData(prev => ({
475
- ...prev,
476
- root: updateNodeById(prev.root, prev.root.id, root => ({
477
- ...root,
478
- children: [...(root.children ?? []), regenerateIds(loadedPrefab.root)]
479
- }))
480
- }));
481
- onClose();
482
- };
483
-
484
- return (
485
- <div
486
- style={{ ...menu.container, position: 'static' }}
487
- onClick={(e) => e.stopPropagation()}
488
- >
489
- <button
490
- style={menu.item}
491
- onClick={handleLoad}
492
- >
493
- 📥 Load Prefab JSON
494
- </button>
495
- <button
496
- style={menu.item}
497
- onClick={handleSave}
498
- >
499
- 💾 Save Prefab JSON
500
- </button>
501
- <button
502
- style={menu.item}
503
- onClick={handleLoadIntoScene}
504
- >
505
- 📂 Load into Scene
506
- </button>
507
- <button
508
- style={menu.item}
509
- onClick={() => { onScreenshot?.(); onClose(); }}
510
- >
511
- 📸 Screenshot
512
- </button>
513
- <button
514
- style={menu.item}
515
- onClick={() => { onExportGLB?.(); onClose(); }}
516
- >
517
- 📦 Export GLB
518
- </button>
519
- </div>
520
- );
521
- }