react-three-game 0.0.37 → 0.0.39
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.
- package/dist/helpers/SoundManager.d.ts +35 -0
- package/dist/helpers/SoundManager.js +93 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +6 -5
- package/dist/shared/GameCanvas.d.ts +6 -3
- package/dist/shared/GameCanvas.js +4 -4
- package/dist/tools/loading/GameWithLoader.d.ts +6 -0
- package/dist/tools/loading/GameWithLoader.js +8 -0
- package/dist/tools/loading/loading.d.ts +2 -0
- package/dist/tools/loading/loading.js +38 -0
- package/dist/tools/prefabeditor/EditorContext.d.ts +11 -0
- package/dist/tools/prefabeditor/EditorContext.js +9 -0
- package/dist/tools/prefabeditor/EditorTree.d.ts +1 -3
- package/dist/tools/prefabeditor/EditorTree.js +38 -3
- package/dist/tools/prefabeditor/EditorUI.d.ts +1 -5
- package/dist/tools/prefabeditor/EditorUI.js +15 -13
- package/dist/tools/prefabeditor/ExportHelper.d.ts +7 -0
- package/dist/tools/prefabeditor/ExportHelper.js +55 -0
- package/dist/tools/prefabeditor/PrefabEditor.d.ts +10 -2
- package/dist/tools/prefabeditor/PrefabEditor.js +60 -53
- package/dist/tools/prefabeditor/PrefabRoot.d.ts +4 -2
- package/dist/tools/prefabeditor/PrefabRoot.js +18 -41
- package/dist/tools/prefabeditor/components/ComponentRegistry.d.ts +3 -1
- package/dist/tools/prefabeditor/components/Input.d.ts +2 -1
- package/dist/tools/prefabeditor/components/Input.js +9 -3
- package/dist/tools/prefabeditor/components/MaterialComponent.js +23 -4
- package/dist/tools/prefabeditor/components/ModelComponent.js +2 -2
- package/dist/tools/prefabeditor/components/TransformComponent.js +11 -3
- package/dist/tools/prefabeditor/utils.d.ts +7 -1
- package/dist/tools/prefabeditor/utils.js +41 -0
- package/package.json +1 -1
- package/src/helpers/SoundManager.ts +130 -0
- package/src/index.ts +13 -12
- package/src/shared/GameCanvas.tsx +9 -3
- package/src/tools/prefabeditor/EditorContext.tsx +20 -0
- package/src/tools/prefabeditor/EditorTree.tsx +83 -22
- package/src/tools/prefabeditor/EditorUI.tsx +14 -14
- package/src/tools/prefabeditor/PrefabEditor.tsx +79 -50
- package/src/tools/prefabeditor/PrefabRoot.tsx +26 -64
- package/src/tools/prefabeditor/components/ComponentRegistry.ts +3 -1
- package/src/tools/prefabeditor/components/Input.tsx +11 -3
- package/src/tools/prefabeditor/components/MaterialComponent.tsx +77 -5
- package/src/tools/prefabeditor/components/ModelComponent.tsx +3 -1
- package/src/tools/prefabeditor/components/TransformComponent.tsx +25 -4
- package/src/tools/prefabeditor/utils.ts +43 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
|
|
2
|
+
class SoundManager {
|
|
3
|
+
private static _instance: SoundManager | null = null
|
|
4
|
+
|
|
5
|
+
public context: AudioContext
|
|
6
|
+
private buffers = new Map<string, AudioBuffer>()
|
|
7
|
+
|
|
8
|
+
private masterGain: GainNode
|
|
9
|
+
private sfxGain: GainNode
|
|
10
|
+
private musicGain: GainNode
|
|
11
|
+
|
|
12
|
+
private constructor() {
|
|
13
|
+
const AudioCtx =
|
|
14
|
+
window.AudioContext || (window as any).webkitAudioContext
|
|
15
|
+
|
|
16
|
+
this.context = new AudioCtx()
|
|
17
|
+
|
|
18
|
+
this.masterGain = this.context.createGain()
|
|
19
|
+
this.sfxGain = this.context.createGain()
|
|
20
|
+
this.musicGain = this.context.createGain()
|
|
21
|
+
|
|
22
|
+
this.sfxGain.connect(this.masterGain)
|
|
23
|
+
this.musicGain.connect(this.masterGain)
|
|
24
|
+
this.masterGain.connect(this.context.destination)
|
|
25
|
+
|
|
26
|
+
this.masterGain.gain.value = 1
|
|
27
|
+
this.sfxGain.gain.value = 1
|
|
28
|
+
this.musicGain.gain.value = 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Singleton accessor */
|
|
32
|
+
static get instance(): SoundManager {
|
|
33
|
+
if (typeof window === 'undefined') {
|
|
34
|
+
// Return a dummy instance for SSR
|
|
35
|
+
return new Proxy({} as SoundManager, {
|
|
36
|
+
get: () => () => {}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
if (!SoundManager._instance) {
|
|
40
|
+
SoundManager._instance = new SoundManager()
|
|
41
|
+
}
|
|
42
|
+
return SoundManager._instance
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Required once after user gesture (browser) */
|
|
46
|
+
resume() {
|
|
47
|
+
if (this.context.state !== "running") {
|
|
48
|
+
this.context.resume()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Preload a sound from URL */
|
|
53
|
+
async load(path: string, url: string) {
|
|
54
|
+
if (this.buffers.has(path)) return
|
|
55
|
+
|
|
56
|
+
const res = await fetch(url)
|
|
57
|
+
const arrayBuffer = await res.arrayBuffer()
|
|
58
|
+
const buffer = await this.context.decodeAudioData(arrayBuffer)
|
|
59
|
+
|
|
60
|
+
this.buffers.set(path, buffer)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Play from already-loaded buffer (fails silently if not loaded) */
|
|
64
|
+
playSync(
|
|
65
|
+
path: string,
|
|
66
|
+
{
|
|
67
|
+
volume = 1,
|
|
68
|
+
playbackRate = 1,
|
|
69
|
+
detune = 0,
|
|
70
|
+
pitch = 1,
|
|
71
|
+
}: {
|
|
72
|
+
volume?: number
|
|
73
|
+
playbackRate?: number
|
|
74
|
+
detune?: number
|
|
75
|
+
pitch?: number
|
|
76
|
+
} = {}
|
|
77
|
+
) {
|
|
78
|
+
this.resume()
|
|
79
|
+
|
|
80
|
+
const buffer = this.buffers.get(path)
|
|
81
|
+
if (!buffer) return
|
|
82
|
+
|
|
83
|
+
const src = this.context.createBufferSource()
|
|
84
|
+
const gain = this.context.createGain()
|
|
85
|
+
|
|
86
|
+
src.buffer = buffer
|
|
87
|
+
src.playbackRate.value = playbackRate * pitch
|
|
88
|
+
src.detune.value = detune
|
|
89
|
+
|
|
90
|
+
gain.gain.value = volume
|
|
91
|
+
|
|
92
|
+
src.connect(gain)
|
|
93
|
+
gain.connect(this.sfxGain)
|
|
94
|
+
|
|
95
|
+
src.start()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Load and play SFX - accepts file path directly */
|
|
99
|
+
async play(
|
|
100
|
+
path: string,
|
|
101
|
+
options?: {
|
|
102
|
+
volume?: number
|
|
103
|
+
playbackRate?: number
|
|
104
|
+
detune?: number
|
|
105
|
+
pitch?: number
|
|
106
|
+
}
|
|
107
|
+
) {
|
|
108
|
+
// Auto-load from path if not already loaded
|
|
109
|
+
if (!this.buffers.has(path)) {
|
|
110
|
+
await this.load(path, path)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.playSync(path, options)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Volume controls */
|
|
117
|
+
setMasterVolume(v: number) {
|
|
118
|
+
this.masterGain.gain.value = v
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setSfxVolume(v: number) {
|
|
122
|
+
this.sfxGain.gain.value = v
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
setMusicVolume(v: number) {
|
|
126
|
+
this.musicGain.gain.value = v
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const sound = SoundManager.instance
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
// Components
|
|
1
|
+
// Core Components
|
|
2
2
|
export { default as GameCanvas } from './shared/GameCanvas';
|
|
3
|
+
|
|
4
|
+
// Prefab Editor
|
|
3
5
|
export { default as PrefabEditor } from './tools/prefabeditor/PrefabEditor';
|
|
6
|
+
export type { PrefabEditorRef } from './tools/prefabeditor/PrefabEditor';
|
|
4
7
|
export { default as PrefabRoot } from './tools/prefabeditor/PrefabRoot';
|
|
8
|
+
export type { PrefabRootRef } from './tools/prefabeditor/PrefabRoot';
|
|
9
|
+
export { registerComponent } from './tools/prefabeditor/components/ComponentRegistry';
|
|
10
|
+
export type { Component } from './tools/prefabeditor/components/ComponentRegistry';
|
|
11
|
+
export type { Prefab, GameObject, ComponentData } from './tools/prefabeditor/types';
|
|
12
|
+
export * as editorStyles from './tools/prefabeditor/styles';
|
|
13
|
+
export * from './tools/prefabeditor/utils';
|
|
14
|
+
|
|
15
|
+
// Asset Tools
|
|
5
16
|
export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
|
|
6
17
|
export {
|
|
7
18
|
TextureListViewer,
|
|
@@ -9,17 +20,7 @@ export {
|
|
|
9
20
|
SoundListViewer,
|
|
10
21
|
SharedCanvas,
|
|
11
22
|
} from './tools/assetviewer/page';
|
|
12
|
-
|
|
13
|
-
// Component Registry
|
|
14
|
-
export { registerComponent } from './tools/prefabeditor/components/ComponentRegistry';
|
|
15
|
-
export type { Component } from './tools/prefabeditor/components/ComponentRegistry';
|
|
16
|
-
|
|
17
|
-
// Editor Styles & Utils
|
|
18
|
-
export * as editorStyles from './tools/prefabeditor/styles';
|
|
19
|
-
export * from './tools/prefabeditor/utils';
|
|
23
|
+
export { sound as soundManager } from './helpers/SoundManager';
|
|
20
24
|
|
|
21
25
|
// Helpers
|
|
22
26
|
export * from './helpers';
|
|
23
|
-
|
|
24
|
-
// Types
|
|
25
|
-
export type { Prefab, GameObject, ComponentData } from './tools/prefabeditor/types';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Canvas, extend } from "@react-three/fiber";
|
|
3
|
+
import { Canvas, extend, CanvasProps } from "@react-three/fiber";
|
|
4
4
|
import { WebGPURenderer, MeshBasicNodeMaterial, MeshStandardNodeMaterial, SpriteNodeMaterial, PCFShadowMap } from "three/webgpu";
|
|
5
5
|
import { Suspense, useState } from "react";
|
|
6
6
|
import { WebGPURendererParameters } from "three/src/renderers/webgpu/WebGPURenderer.Nodes.js";
|
|
@@ -15,8 +15,13 @@ extend({
|
|
|
15
15
|
SpriteNodeMaterial: SpriteNodeMaterial,
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
+
interface GameCanvasProps extends Omit<CanvasProps, 'children'> {
|
|
19
|
+
loader?: boolean;
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
glConfig?: WebGPURendererParameters;
|
|
22
|
+
}
|
|
18
23
|
|
|
19
|
-
export default function GameCanvas({ loader = false, children, ...props }:
|
|
24
|
+
export default function GameCanvas({ loader = false, children, glConfig, ...props }: GameCanvasProps) {
|
|
20
25
|
const [frameloop, setFrameloop] = useState<"never" | "always">("never");
|
|
21
26
|
|
|
22
27
|
return <>
|
|
@@ -30,7 +35,7 @@ export default function GameCanvas({ loader = false, children, ...props }: { loa
|
|
|
30
35
|
// @ts-expect-error futuristic
|
|
31
36
|
shadowMap: true,
|
|
32
37
|
antialias: true,
|
|
33
|
-
...
|
|
38
|
+
...glConfig,
|
|
34
39
|
});
|
|
35
40
|
await renderer.init().then(() => {
|
|
36
41
|
setFrameloop("always");
|
|
@@ -40,6 +45,7 @@ export default function GameCanvas({ loader = false, children, ...props }: { loa
|
|
|
40
45
|
camera={{
|
|
41
46
|
position: [0, 1, 5],
|
|
42
47
|
}}
|
|
48
|
+
{...props}
|
|
43
49
|
>
|
|
44
50
|
<Suspense>
|
|
45
51
|
{children}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
interface EditorContextType {
|
|
4
|
+
transformMode: "translate" | "rotate" | "scale";
|
|
5
|
+
setTransformMode: (mode: "translate" | "rotate" | "scale") => void;
|
|
6
|
+
snapResolution: number;
|
|
7
|
+
setSnapResolution: (resolution: number) => void;
|
|
8
|
+
onScreenshot?: () => void;
|
|
9
|
+
onExportGLB?: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const EditorContext = createContext<EditorContextType | null>(null);
|
|
13
|
+
|
|
14
|
+
export function useEditorContext() {
|
|
15
|
+
const context = useContext(EditorContext);
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error("useEditorContext must be used within EditorContext.Provider");
|
|
18
|
+
}
|
|
19
|
+
return context;
|
|
20
|
+
}
|
|
@@ -2,15 +2,14 @@ import { Dispatch, SetStateAction, useState, MouseEvent } from 'react';
|
|
|
2
2
|
import { Prefab, GameObject } from "./types";
|
|
3
3
|
import { getComponent } from './components/ComponentRegistry';
|
|
4
4
|
import { base, tree, menu } from './styles';
|
|
5
|
-
import { findNode, findParent, deleteNode, cloneNode, updateNodeById } from './utils';
|
|
5
|
+
import { findNode, findParent, deleteNode, cloneNode, updateNodeById, loadJson, saveJson, regenerateIds } from './utils';
|
|
6
|
+
import { useEditorContext } from './EditorContext';
|
|
6
7
|
|
|
7
8
|
export default function EditorTree({
|
|
8
9
|
prefabData,
|
|
9
10
|
setPrefabData,
|
|
10
11
|
selectedId,
|
|
11
12
|
setSelectedId,
|
|
12
|
-
onSave,
|
|
13
|
-
onLoad,
|
|
14
13
|
onUndo,
|
|
15
14
|
onRedo,
|
|
16
15
|
canUndo,
|
|
@@ -20,8 +19,6 @@ export default function EditorTree({
|
|
|
20
19
|
setPrefabData?: Dispatch<SetStateAction<Prefab>>;
|
|
21
20
|
selectedId: string | null;
|
|
22
21
|
setSelectedId: Dispatch<SetStateAction<string | null>>;
|
|
23
|
-
onSave?: () => void;
|
|
24
|
-
onLoad?: () => void;
|
|
25
22
|
onUndo?: () => void;
|
|
26
23
|
onRedo?: () => void;
|
|
27
24
|
canUndo?: boolean;
|
|
@@ -212,23 +209,11 @@ export default function EditorTree({
|
|
|
212
209
|
⋮
|
|
213
210
|
</button>
|
|
214
211
|
{fileMenuOpen && (
|
|
215
|
-
<
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
style={menu.item}
|
|
221
|
-
onClick={() => { onLoad?.(); setFileMenuOpen(false); }}
|
|
222
|
-
>
|
|
223
|
-
📥 Load
|
|
224
|
-
</button>
|
|
225
|
-
<button
|
|
226
|
-
style={menu.item}
|
|
227
|
-
onClick={() => { onSave?.(); setFileMenuOpen(false); }}
|
|
228
|
-
>
|
|
229
|
-
💾 Save
|
|
230
|
-
</button>
|
|
231
|
-
</div>
|
|
212
|
+
<FileMenu
|
|
213
|
+
prefabData={prefabData}
|
|
214
|
+
setPrefabData={setPrefabData}
|
|
215
|
+
onClose={() => setFileMenuOpen(false)}
|
|
216
|
+
/>
|
|
232
217
|
)}
|
|
233
218
|
</div>
|
|
234
219
|
</div>
|
|
@@ -261,3 +246,79 @@ export default function EditorTree({
|
|
|
261
246
|
</>
|
|
262
247
|
);
|
|
263
248
|
}
|
|
249
|
+
|
|
250
|
+
function FileMenu({
|
|
251
|
+
prefabData,
|
|
252
|
+
setPrefabData,
|
|
253
|
+
onClose
|
|
254
|
+
}: {
|
|
255
|
+
prefabData: Prefab;
|
|
256
|
+
setPrefabData: Dispatch<SetStateAction<Prefab>>;
|
|
257
|
+
onClose: () => void;
|
|
258
|
+
}) {
|
|
259
|
+
const { onScreenshot, onExportGLB } = useEditorContext();
|
|
260
|
+
|
|
261
|
+
const handleLoad = async () => {
|
|
262
|
+
const loadedPrefab = await loadJson();
|
|
263
|
+
if (!loadedPrefab) return;
|
|
264
|
+
setPrefabData(loadedPrefab);
|
|
265
|
+
onClose();
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const handleSave = () => {
|
|
269
|
+
saveJson(prefabData, "prefab");
|
|
270
|
+
onClose();
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const handleLoadIntoScene = async () => {
|
|
274
|
+
const loadedPrefab = await loadJson();
|
|
275
|
+
if (!loadedPrefab) return;
|
|
276
|
+
|
|
277
|
+
setPrefabData(prev => ({
|
|
278
|
+
...prev,
|
|
279
|
+
root: updateNodeById(prev.root, prev.root.id, root => ({
|
|
280
|
+
...root,
|
|
281
|
+
children: [...(root.children ?? []), regenerateIds(loadedPrefab.root)]
|
|
282
|
+
}))
|
|
283
|
+
}));
|
|
284
|
+
onClose();
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div
|
|
289
|
+
style={{ ...menu.container, top: 28, right: 0 }}
|
|
290
|
+
onClick={(e) => e.stopPropagation()}
|
|
291
|
+
>
|
|
292
|
+
<button
|
|
293
|
+
style={menu.item}
|
|
294
|
+
onClick={handleLoad}
|
|
295
|
+
>
|
|
296
|
+
📥 Load Prefab JSON
|
|
297
|
+
</button>
|
|
298
|
+
<button
|
|
299
|
+
style={menu.item}
|
|
300
|
+
onClick={handleSave}
|
|
301
|
+
>
|
|
302
|
+
💾 Save Prefab JSON
|
|
303
|
+
</button>
|
|
304
|
+
<button
|
|
305
|
+
style={menu.item}
|
|
306
|
+
onClick={handleLoadIntoScene}
|
|
307
|
+
>
|
|
308
|
+
📂 Load into Scene
|
|
309
|
+
</button>
|
|
310
|
+
<button
|
|
311
|
+
style={menu.item}
|
|
312
|
+
onClick={() => { onScreenshot?.(); onClose(); }}
|
|
313
|
+
>
|
|
314
|
+
📸 Screenshot
|
|
315
|
+
</button>
|
|
316
|
+
<button
|
|
317
|
+
style={menu.item}
|
|
318
|
+
onClick={() => { onExportGLB?.(); onClose(); }}
|
|
319
|
+
>
|
|
320
|
+
📦 Export GLB
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
@@ -4,17 +4,14 @@ import EditorTree from './EditorTree';
|
|
|
4
4
|
import { getAllComponents } from './components/ComponentRegistry';
|
|
5
5
|
import { base, inspector } from './styles';
|
|
6
6
|
import { findNode, updateNode, deleteNode } from './utils';
|
|
7
|
+
import { useEditorContext } from './EditorContext';
|
|
7
8
|
|
|
8
9
|
function EditorUI({
|
|
9
10
|
prefabData,
|
|
10
11
|
setPrefabData,
|
|
11
12
|
selectedId,
|
|
12
13
|
setSelectedId,
|
|
13
|
-
transformMode,
|
|
14
|
-
setTransformMode,
|
|
15
14
|
basePath,
|
|
16
|
-
onSave,
|
|
17
|
-
onLoad,
|
|
18
15
|
onUndo,
|
|
19
16
|
onRedo,
|
|
20
17
|
canUndo,
|
|
@@ -24,17 +21,14 @@ function EditorUI({
|
|
|
24
21
|
setPrefabData?: Dispatch<SetStateAction<Prefab>>;
|
|
25
22
|
selectedId: string | null;
|
|
26
23
|
setSelectedId: Dispatch<SetStateAction<string | null>>;
|
|
27
|
-
transformMode: "translate" | "rotate" | "scale";
|
|
28
|
-
setTransformMode: (m: "translate" | "rotate" | "scale") => void;
|
|
29
24
|
basePath?: string;
|
|
30
|
-
onSave?: () => void;
|
|
31
|
-
onLoad?: () => void;
|
|
32
25
|
onUndo?: () => void;
|
|
33
26
|
onRedo?: () => void;
|
|
34
27
|
canUndo?: boolean;
|
|
35
28
|
canRedo?: boolean;
|
|
36
29
|
}) {
|
|
37
30
|
const [collapsed, setCollapsed] = useState(false);
|
|
31
|
+
const { transformMode, setTransformMode } = useEditorContext();
|
|
38
32
|
|
|
39
33
|
const updateNodeHandler = (updater: (n: GameObjectType) => GameObjectType) => {
|
|
40
34
|
if (!prefabData || !setPrefabData || !selectedId) return;
|
|
@@ -81,8 +75,6 @@ function EditorUI({
|
|
|
81
75
|
setPrefabData={setPrefabData}
|
|
82
76
|
selectedId={selectedId}
|
|
83
77
|
setSelectedId={setSelectedId}
|
|
84
|
-
onSave={onSave}
|
|
85
|
-
onLoad={onLoad}
|
|
86
78
|
onUndo={onUndo}
|
|
87
79
|
onRedo={onRedo}
|
|
88
80
|
canUndo={canUndo}
|
|
@@ -119,12 +111,19 @@ function NodeInspector({
|
|
|
119
111
|
}, [Object.keys(node.components || {}).join(',')]);
|
|
120
112
|
|
|
121
113
|
return <div style={inspector.content} className="prefab-scroll">
|
|
122
|
-
{/* Node
|
|
114
|
+
{/* Node Name */}
|
|
123
115
|
<div style={base.section}>
|
|
124
|
-
<div style={
|
|
116
|
+
<div style={{ display: "flex", marginBottom: 8, alignItems: 'center', gap: 8 }}>
|
|
117
|
+
<div style={{ fontSize: 10, color: '#888', wordBreak: 'break-all', border: '1px solid rgba(255,255,255,0.1)', padding: '2px 4px', borderRadius: 4, flex: 1 }}>
|
|
118
|
+
{node.id}
|
|
119
|
+
</div>
|
|
120
|
+
<button style={{ ...base.btn, ...base.btnDanger }} title="Delete Node" onClick={deleteNode}>❌</button>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
125
123
|
<input
|
|
126
124
|
style={base.input}
|
|
127
125
|
value={node.name ?? ""}
|
|
126
|
+
placeholder='Node name'
|
|
128
127
|
onChange={e =>
|
|
129
128
|
updateNode(n => ({ ...n, name: e.target.value }))
|
|
130
129
|
}
|
|
@@ -135,7 +134,6 @@ function NodeInspector({
|
|
|
135
134
|
<div style={base.section}>
|
|
136
135
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
137
136
|
<div style={base.label}>Components</div>
|
|
138
|
-
<button style={{ ...base.btn, ...base.btnDanger }} onClick={deleteNode}>Delete Node</button>
|
|
139
137
|
</div>
|
|
140
138
|
|
|
141
139
|
{node.components && Object.entries(node.components).map(([key, comp]: [string, any]) => {
|
|
@@ -151,6 +149,7 @@ function NodeInspector({
|
|
|
151
149
|
<div style={{ fontSize: 11, fontWeight: 500 }}>{key}</div>
|
|
152
150
|
<button
|
|
153
151
|
style={{ ...base.btn, padding: '2px 6px' }}
|
|
152
|
+
title="Remove Component"
|
|
154
153
|
onClick={() => updateNode(n => {
|
|
155
154
|
const { [key]: _, ...rest } = n.components || {};
|
|
156
155
|
return { ...n, components: rest };
|
|
@@ -162,6 +161,7 @@ function NodeInspector({
|
|
|
162
161
|
{def.Editor && (
|
|
163
162
|
<def.Editor
|
|
164
163
|
component={comp}
|
|
164
|
+
node={node}
|
|
165
165
|
onUpdate={(newProps: any) => updateNode(n => ({
|
|
166
166
|
...n,
|
|
167
167
|
components: {
|
|
@@ -182,7 +182,6 @@ function NodeInspector({
|
|
|
182
182
|
{/* Add Component */}
|
|
183
183
|
{available.length > 0 && (
|
|
184
184
|
<div>
|
|
185
|
-
<div style={base.label}>Add Component</div>
|
|
186
185
|
<div style={base.row}>
|
|
187
186
|
<select
|
|
188
187
|
style={{ ...base.input, flex: 1 }}
|
|
@@ -207,6 +206,7 @@ function NodeInspector({
|
|
|
207
206
|
}));
|
|
208
207
|
}
|
|
209
208
|
}}
|
|
209
|
+
title="Add Component"
|
|
210
210
|
>
|
|
211
211
|
+
|
|
212
212
|
</button>
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import GameCanvas from "../../shared/GameCanvas";
|
|
4
|
-
import { useState, useRef, useEffect } from "react";
|
|
4
|
+
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";
|
|
5
5
|
import { Prefab } from "./types";
|
|
6
|
-
import PrefabRoot from "./PrefabRoot";
|
|
6
|
+
import PrefabRoot, { PrefabRootRef } from "./PrefabRoot";
|
|
7
7
|
import { Physics } from "@react-three/rapier";
|
|
8
8
|
import EditorUI from "./EditorUI";
|
|
9
9
|
import { base, toolbar } from "./styles";
|
|
10
|
+
import { EditorContext } from "./EditorContext";
|
|
11
|
+
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
|
|
12
|
+
import { Group } from "three";
|
|
13
|
+
|
|
14
|
+
export interface PrefabEditorRef {
|
|
15
|
+
screenshot: () => void;
|
|
16
|
+
exportGLB: () => void;
|
|
17
|
+
prefab: Prefab;
|
|
18
|
+
setPrefab: (prefab: Prefab) => void;
|
|
19
|
+
rootRef: React.RefObject<PrefabRootRef | null>;
|
|
20
|
+
}
|
|
10
21
|
|
|
11
22
|
const DEFAULT_PREFAB: Prefab = {
|
|
12
23
|
id: "prefab-default",
|
|
@@ -22,20 +33,23 @@ const DEFAULT_PREFAB: Prefab = {
|
|
|
22
33
|
}
|
|
23
34
|
};
|
|
24
35
|
|
|
25
|
-
const PrefabEditor =
|
|
36
|
+
const PrefabEditor = forwardRef<PrefabEditorRef, {
|
|
26
37
|
basePath?: string;
|
|
27
38
|
initialPrefab?: Prefab;
|
|
28
39
|
onPrefabChange?: (prefab: Prefab) => void;
|
|
29
40
|
children?: React.ReactNode;
|
|
30
|
-
}) => {
|
|
41
|
+
}>(({ basePath, initialPrefab, onPrefabChange, children }, ref) => {
|
|
31
42
|
const [editMode, setEditMode] = useState(true);
|
|
32
43
|
const [loadedPrefab, setLoadedPrefab] = useState<Prefab>(initialPrefab ?? DEFAULT_PREFAB);
|
|
33
44
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
34
45
|
const [transformMode, setTransformMode] = useState<"translate" | "rotate" | "scale">("translate");
|
|
46
|
+
const [snapResolution, setSnapResolution] = useState(0);
|
|
35
47
|
const [history, setHistory] = useState<Prefab[]>([loadedPrefab]);
|
|
36
48
|
const [historyIndex, setHistoryIndex] = useState(0);
|
|
37
49
|
const throttleRef = useRef<NodeJS.Timeout | null>(null);
|
|
38
50
|
const lastDataRef = useRef(JSON.stringify(loadedPrefab));
|
|
51
|
+
const prefabRootRef = useRef<PrefabRootRef>(null);
|
|
52
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
39
53
|
|
|
40
54
|
useEffect(() => {
|
|
41
55
|
if (initialPrefab) setLoadedPrefab(initialPrefab);
|
|
@@ -84,29 +98,76 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: {
|
|
|
84
98
|
return () => { if (throttleRef.current) clearTimeout(throttleRef.current); };
|
|
85
99
|
}, [loadedPrefab]);
|
|
86
100
|
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
const handleScreenshot = () => {
|
|
102
|
+
const canvas = canvasRef.current;
|
|
103
|
+
if (!canvas) return;
|
|
104
|
+
|
|
105
|
+
canvas.toBlob((blob) => {
|
|
106
|
+
if (!blob) return;
|
|
107
|
+
const url = URL.createObjectURL(blob);
|
|
108
|
+
const a = document.createElement('a');
|
|
109
|
+
a.href = url;
|
|
110
|
+
a.download = `${loadedPrefab.name || 'screenshot'}.png`;
|
|
111
|
+
a.click();
|
|
112
|
+
URL.revokeObjectURL(url);
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleExportGLB = () => {
|
|
117
|
+
const sceneRoot = prefabRootRef.current?.root;
|
|
118
|
+
if (!sceneRoot) return;
|
|
119
|
+
|
|
120
|
+
const exporter = new GLTFExporter();
|
|
121
|
+
exporter.parse(
|
|
122
|
+
sceneRoot,
|
|
123
|
+
(result) => {
|
|
124
|
+
const blob = new Blob([result as ArrayBuffer], { type: 'application/octet-stream' });
|
|
125
|
+
const url = URL.createObjectURL(blob);
|
|
126
|
+
const a = document.createElement('a');
|
|
127
|
+
a.href = url;
|
|
128
|
+
a.download = `${loadedPrefab.name || 'scene'}.glb`;
|
|
129
|
+
a.click();
|
|
130
|
+
URL.revokeObjectURL(url);
|
|
131
|
+
},
|
|
132
|
+
(error) => {
|
|
133
|
+
console.error('Error exporting GLB:', error);
|
|
134
|
+
},
|
|
135
|
+
{ binary: true }
|
|
136
|
+
);
|
|
96
137
|
};
|
|
97
138
|
|
|
98
|
-
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const canvas = document.querySelector('canvas');
|
|
141
|
+
if (canvas) canvasRef.current = canvas;
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
useImperativeHandle(ref, () => ({
|
|
145
|
+
screenshot: handleScreenshot,
|
|
146
|
+
exportGLB: handleExportGLB,
|
|
147
|
+
prefab: loadedPrefab,
|
|
148
|
+
setPrefab: setLoadedPrefab,
|
|
149
|
+
rootRef: prefabRootRef
|
|
150
|
+
}), [loadedPrefab]);
|
|
151
|
+
|
|
152
|
+
return <EditorContext.Provider value={{
|
|
153
|
+
transformMode,
|
|
154
|
+
setTransformMode,
|
|
155
|
+
snapResolution,
|
|
156
|
+
setSnapResolution,
|
|
157
|
+
onScreenshot: handleScreenshot,
|
|
158
|
+
onExportGLB: handleExportGLB
|
|
159
|
+
}}>
|
|
99
160
|
<GameCanvas>
|
|
100
161
|
<Physics debug={editMode} paused={editMode}>
|
|
101
162
|
<ambientLight intensity={1.5} />
|
|
102
163
|
<gridHelper args={[10, 10]} position={[0, -1, 0]} />
|
|
103
164
|
<PrefabRoot
|
|
165
|
+
ref={prefabRootRef}
|
|
104
166
|
data={loadedPrefab}
|
|
105
167
|
editMode={editMode}
|
|
106
168
|
onPrefabChange={updatePrefab}
|
|
107
169
|
selectedId={selectedId}
|
|
108
170
|
onSelect={setSelectedId}
|
|
109
|
-
transformMode={transformMode}
|
|
110
171
|
basePath={basePath}
|
|
111
172
|
/>
|
|
112
173
|
{children}
|
|
@@ -123,47 +184,15 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: {
|
|
|
123
184
|
setPrefabData={updatePrefab}
|
|
124
185
|
selectedId={selectedId}
|
|
125
186
|
setSelectedId={setSelectedId}
|
|
126
|
-
transformMode={transformMode}
|
|
127
|
-
setTransformMode={setTransformMode}
|
|
128
187
|
basePath={basePath}
|
|
129
|
-
onSave={() => saveJson(loadedPrefab, "prefab")}
|
|
130
|
-
onLoad={handleLoad}
|
|
131
188
|
onUndo={undo}
|
|
132
189
|
onRedo={redo}
|
|
133
190
|
canUndo={historyIndex > 0}
|
|
134
191
|
canRedo={historyIndex < history.length - 1}
|
|
135
192
|
/>}
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const saveJson = (data: Prefab, filename: string) => {
|
|
141
|
-
const a = document.createElement('a');
|
|
142
|
-
a.href = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
|
|
143
|
-
a.download = `${filename || 'prefab'}.json`;
|
|
144
|
-
a.click();
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
const loadJson = () => new Promise<Prefab | undefined>(resolve => {
|
|
148
|
-
const input = document.createElement('input');
|
|
149
|
-
input.type = 'file';
|
|
150
|
-
input.accept = '.json,application/json';
|
|
151
|
-
input.onchange = e => {
|
|
152
|
-
const file = (e.target as HTMLInputElement).files?.[0];
|
|
153
|
-
if (!file) return resolve(undefined);
|
|
154
|
-
const reader = new FileReader();
|
|
155
|
-
reader.onload = e => {
|
|
156
|
-
try {
|
|
157
|
-
const text = e.target?.result;
|
|
158
|
-
if (typeof text === 'string') resolve(JSON.parse(text) as Prefab);
|
|
159
|
-
} catch (err) {
|
|
160
|
-
console.error('Error parsing prefab JSON:', err);
|
|
161
|
-
resolve(undefined);
|
|
162
|
-
}
|
|
163
|
-
};
|
|
164
|
-
reader.readAsText(file);
|
|
165
|
-
};
|
|
166
|
-
input.click();
|
|
193
|
+
</EditorContext.Provider>
|
|
167
194
|
});
|
|
168
195
|
|
|
196
|
+
PrefabEditor.displayName = "PrefabEditor";
|
|
197
|
+
|
|
169
198
|
export default PrefabEditor;
|