react-three-game 0.0.38 → 0.0.40
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 +1 -0
- package/dist/index.js +1 -0
- package/dist/shared/GameCanvas.d.ts +6 -3
- package/dist/shared/GameCanvas.js +4 -4
- package/dist/tools/assetviewer/page.d.ts +12 -0
- package/dist/tools/assetviewer/page.js +21 -13
- 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/EditorUI.js +11 -11
- package/dist/tools/prefabeditor/components/ComponentRegistry.d.ts +3 -1
- package/dist/tools/prefabeditor/components/MaterialComponent.js +29 -6
- package/dist/tools/prefabeditor/components/ModelComponent.js +7 -3
- package/dist/tools/prefabeditor/styles.js +0 -1
- package/package.json +1 -1
- package/src/helpers/SoundManager.ts +130 -0
- package/src/index.ts +1 -0
- package/src/shared/GameCanvas.tsx +9 -3
- package/src/tools/assetviewer/page.tsx +31 -24
- package/src/tools/prefabeditor/EditorUI.tsx +12 -4
- package/src/tools/prefabeditor/components/ComponentRegistry.ts +3 -1
- package/src/tools/prefabeditor/components/MaterialComponent.tsx +102 -14
- package/src/tools/prefabeditor/components/ModelComponent.tsx +27 -10
- package/src/tools/prefabeditor/styles.ts +0 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
declare class SoundManager {
|
|
2
|
+
private static _instance;
|
|
3
|
+
context: AudioContext;
|
|
4
|
+
private buffers;
|
|
5
|
+
private masterGain;
|
|
6
|
+
private sfxGain;
|
|
7
|
+
private musicGain;
|
|
8
|
+
private constructor();
|
|
9
|
+
/** Singleton accessor */
|
|
10
|
+
static get instance(): SoundManager;
|
|
11
|
+
/** Required once after user gesture (browser) */
|
|
12
|
+
resume(): void;
|
|
13
|
+
/** Preload a sound from URL */
|
|
14
|
+
load(path: string, url: string): Promise<void>;
|
|
15
|
+
/** Play from already-loaded buffer (fails silently if not loaded) */
|
|
16
|
+
playSync(path: string, { volume, playbackRate, detune, pitch, }?: {
|
|
17
|
+
volume?: number;
|
|
18
|
+
playbackRate?: number;
|
|
19
|
+
detune?: number;
|
|
20
|
+
pitch?: number;
|
|
21
|
+
}): void;
|
|
22
|
+
/** Load and play SFX - accepts file path directly */
|
|
23
|
+
play(path: string, options?: {
|
|
24
|
+
volume?: number;
|
|
25
|
+
playbackRate?: number;
|
|
26
|
+
detune?: number;
|
|
27
|
+
pitch?: number;
|
|
28
|
+
}): Promise<void>;
|
|
29
|
+
/** Volume controls */
|
|
30
|
+
setMasterVolume(v: number): void;
|
|
31
|
+
setSfxVolume(v: number): void;
|
|
32
|
+
setMusicVolume(v: number): void;
|
|
33
|
+
}
|
|
34
|
+
export declare const sound: SoundManager;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
class SoundManager {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.buffers = new Map();
|
|
13
|
+
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
|
14
|
+
this.context = new AudioCtx();
|
|
15
|
+
this.masterGain = this.context.createGain();
|
|
16
|
+
this.sfxGain = this.context.createGain();
|
|
17
|
+
this.musicGain = this.context.createGain();
|
|
18
|
+
this.sfxGain.connect(this.masterGain);
|
|
19
|
+
this.musicGain.connect(this.masterGain);
|
|
20
|
+
this.masterGain.connect(this.context.destination);
|
|
21
|
+
this.masterGain.gain.value = 1;
|
|
22
|
+
this.sfxGain.gain.value = 1;
|
|
23
|
+
this.musicGain.gain.value = 1;
|
|
24
|
+
}
|
|
25
|
+
/** Singleton accessor */
|
|
26
|
+
static get instance() {
|
|
27
|
+
if (typeof window === 'undefined') {
|
|
28
|
+
// Return a dummy instance for SSR
|
|
29
|
+
return new Proxy({}, {
|
|
30
|
+
get: () => () => { }
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (!SoundManager._instance) {
|
|
34
|
+
SoundManager._instance = new SoundManager();
|
|
35
|
+
}
|
|
36
|
+
return SoundManager._instance;
|
|
37
|
+
}
|
|
38
|
+
/** Required once after user gesture (browser) */
|
|
39
|
+
resume() {
|
|
40
|
+
if (this.context.state !== "running") {
|
|
41
|
+
this.context.resume();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Preload a sound from URL */
|
|
45
|
+
load(path, url) {
|
|
46
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
47
|
+
if (this.buffers.has(path))
|
|
48
|
+
return;
|
|
49
|
+
const res = yield fetch(url);
|
|
50
|
+
const arrayBuffer = yield res.arrayBuffer();
|
|
51
|
+
const buffer = yield this.context.decodeAudioData(arrayBuffer);
|
|
52
|
+
this.buffers.set(path, buffer);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Play from already-loaded buffer (fails silently if not loaded) */
|
|
56
|
+
playSync(path, { volume = 1, playbackRate = 1, detune = 0, pitch = 1, } = {}) {
|
|
57
|
+
this.resume();
|
|
58
|
+
const buffer = this.buffers.get(path);
|
|
59
|
+
if (!buffer)
|
|
60
|
+
return;
|
|
61
|
+
const src = this.context.createBufferSource();
|
|
62
|
+
const gain = this.context.createGain();
|
|
63
|
+
src.buffer = buffer;
|
|
64
|
+
src.playbackRate.value = playbackRate * pitch;
|
|
65
|
+
src.detune.value = detune;
|
|
66
|
+
gain.gain.value = volume;
|
|
67
|
+
src.connect(gain);
|
|
68
|
+
gain.connect(this.sfxGain);
|
|
69
|
+
src.start();
|
|
70
|
+
}
|
|
71
|
+
/** Load and play SFX - accepts file path directly */
|
|
72
|
+
play(path, options) {
|
|
73
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
74
|
+
// Auto-load from path if not already loaded
|
|
75
|
+
if (!this.buffers.has(path)) {
|
|
76
|
+
yield this.load(path, path);
|
|
77
|
+
}
|
|
78
|
+
this.playSync(path, options);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** Volume controls */
|
|
82
|
+
setMasterVolume(v) {
|
|
83
|
+
this.masterGain.gain.value = v;
|
|
84
|
+
}
|
|
85
|
+
setSfxVolume(v) {
|
|
86
|
+
this.sfxGain.gain.value = v;
|
|
87
|
+
}
|
|
88
|
+
setMusicVolume(v) {
|
|
89
|
+
this.musicGain.gain.value = v;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
SoundManager._instance = null;
|
|
93
|
+
export const sound = SoundManager.instance;
|
package/dist/index.d.ts
CHANGED
|
@@ -10,4 +10,5 @@ export * as editorStyles from './tools/prefabeditor/styles';
|
|
|
10
10
|
export * from './tools/prefabeditor/utils';
|
|
11
11
|
export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
|
|
12
12
|
export { TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
|
|
13
|
+
export { sound as soundManager } from './helpers/SoundManager';
|
|
13
14
|
export * from './helpers';
|
package/dist/index.js
CHANGED
|
@@ -9,5 +9,6 @@ export * from './tools/prefabeditor/utils';
|
|
|
9
9
|
// Asset Tools
|
|
10
10
|
export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
|
|
11
11
|
export { TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
|
|
12
|
+
export { sound as soundManager } from './helpers/SoundManager';
|
|
12
13
|
// Helpers
|
|
13
14
|
export * from './helpers';
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { CanvasProps } from "@react-three/fiber";
|
|
1
2
|
import { WebGPURendererParameters } from "three/src/renderers/webgpu/WebGPURenderer.Nodes.js";
|
|
2
|
-
|
|
3
|
+
interface GameCanvasProps extends Omit<CanvasProps, 'children'> {
|
|
3
4
|
loader?: boolean;
|
|
4
5
|
children: React.ReactNode;
|
|
5
|
-
|
|
6
|
-
}
|
|
6
|
+
glConfig?: WebGPURendererParameters;
|
|
7
|
+
}
|
|
8
|
+
export default function GameCanvas({ loader, children, glConfig, ...props }: GameCanvasProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -32,17 +32,17 @@ extend({
|
|
|
32
32
|
SpriteNodeMaterial: SpriteNodeMaterial,
|
|
33
33
|
});
|
|
34
34
|
export default function GameCanvas(_a) {
|
|
35
|
-
var { loader = false, children } = _a, props = __rest(_a, ["loader", "children"]);
|
|
35
|
+
var { loader = false, children, glConfig } = _a, props = __rest(_a, ["loader", "children", "glConfig"]);
|
|
36
36
|
const [frameloop, setFrameloop] = useState("never");
|
|
37
|
-
return _jsx(_Fragment, { children: _jsxs(Canvas, { style: { touchAction: 'none' }, shadows: { type: PCFShadowMap, }, frameloop: frameloop, gl: (_a) => __awaiter(this, [_a], void 0, function* ({ canvas }) {
|
|
37
|
+
return _jsx(_Fragment, { children: _jsxs(Canvas, Object.assign({ style: { touchAction: 'none' }, shadows: { type: PCFShadowMap, }, frameloop: frameloop, gl: (_a) => __awaiter(this, [_a], void 0, function* ({ canvas }) {
|
|
38
38
|
const renderer = new WebGPURenderer(Object.assign({ canvas: canvas,
|
|
39
39
|
// @ts-expect-error futuristic
|
|
40
|
-
shadowMap: true, antialias: true },
|
|
40
|
+
shadowMap: true, antialias: true }, glConfig));
|
|
41
41
|
yield renderer.init().then(() => {
|
|
42
42
|
setFrameloop("always");
|
|
43
43
|
});
|
|
44
44
|
return renderer;
|
|
45
45
|
}), camera: {
|
|
46
46
|
position: [0, 1, 5],
|
|
47
|
-
}, children: [_jsx(Suspense, { children: children }), loader ? _jsx(Loader, {}) : null] }) });
|
|
47
|
+
} }, props, { children: [_jsx(Suspense, { children: children }), loader ? _jsx(Loader, {}) : null] })) });
|
|
48
48
|
}
|
|
@@ -19,5 +19,17 @@ interface SoundListViewerProps {
|
|
|
19
19
|
basePath?: string;
|
|
20
20
|
}
|
|
21
21
|
export declare function SoundListViewer({ files, selected, onSelect, basePath }: SoundListViewerProps): import("react/jsx-runtime").JSX.Element;
|
|
22
|
+
export declare function SingleTextureViewer({ file, basePath }: {
|
|
23
|
+
file?: string;
|
|
24
|
+
basePath?: string;
|
|
25
|
+
}): import("react/jsx-runtime").JSX.Element | null;
|
|
26
|
+
export declare function SingleModelViewer({ file, basePath }: {
|
|
27
|
+
file?: string;
|
|
28
|
+
basePath?: string;
|
|
29
|
+
}): import("react/jsx-runtime").JSX.Element | null;
|
|
30
|
+
export declare function SingleSoundViewer({ file, basePath }: {
|
|
31
|
+
file?: string;
|
|
32
|
+
basePath?: string;
|
|
33
|
+
}): import("react/jsx-runtime").JSX.Element | null;
|
|
22
34
|
export declare function SharedCanvas(): import("react/jsx-runtime").JSX.Element;
|
|
23
35
|
export {};
|
|
@@ -37,6 +37,7 @@ function getItemsInPath(files, currentPath) {
|
|
|
37
37
|
}
|
|
38
38
|
function FolderTile({ name, onClick }) {
|
|
39
39
|
return (_jsxs("div", { onClick: onClick, style: {
|
|
40
|
+
maxWidth: 60,
|
|
40
41
|
aspectRatio: '1 / 1',
|
|
41
42
|
backgroundColor: '#1f2937', /* gray-800 */
|
|
42
43
|
cursor: 'pointer',
|
|
@@ -66,21 +67,12 @@ function useInView() {
|
|
|
66
67
|
}
|
|
67
68
|
function AssetListViewer({ files, selected, onSelect, renderCard }) {
|
|
68
69
|
const [currentPath, setCurrentPath] = useState('');
|
|
69
|
-
const [showPicker, setShowPicker] = useState(false);
|
|
70
70
|
const { folders, filesInCurrentPath } = getItemsInPath(files, currentPath);
|
|
71
|
-
const showCompactView = selected && !showPicker;
|
|
72
|
-
if (showCompactView) {
|
|
73
|
-
return (_jsxs("div", { style: { display: 'flex', gap: 4, alignItems: 'center' }, children: [renderCard(selected, onSelect), _jsx("button", { onClick: () => setShowPicker(true), style: { padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 12, cursor: 'pointer', border: 'none' }, children: "Change" })] }));
|
|
74
|
-
}
|
|
75
71
|
return (_jsxs("div", { children: [currentPath && (_jsx("button", { onClick: () => {
|
|
76
72
|
const pathParts = currentPath.split('/').filter(Boolean);
|
|
77
73
|
pathParts.pop();
|
|
78
74
|
setCurrentPath(pathParts.join('/'));
|
|
79
|
-
}, style: { marginBottom: 4, padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 12, cursor: 'pointer', border: 'none' }, children: "\u2190 Back" })), _jsxs("div", { style: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }, children: [folders.map((folder) => (_jsx(FolderTile, { name: folder, onClick: () => setCurrentPath(currentPath ? `${currentPath}/${folder}` : folder) }, folder))), filesInCurrentPath.map((file) => (_jsx("div", { children: renderCard(file,
|
|
80
|
-
onSelect(f);
|
|
81
|
-
if (selected)
|
|
82
|
-
setShowPicker(false);
|
|
83
|
-
}) }, file)))] })] }));
|
|
75
|
+
}, style: { marginBottom: 4, padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 12, cursor: 'pointer', border: 'none' }, children: "\u2190 Back" })), _jsxs("div", { style: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }, children: [folders.map((folder) => (_jsx(FolderTile, { name: folder, onClick: () => setCurrentPath(currentPath ? `${currentPath}/${folder}` : folder) }, folder))), filesInCurrentPath.map((file) => (_jsx("div", { children: renderCard(file, onSelect) }, file)))] })] }));
|
|
84
76
|
}
|
|
85
77
|
export function TextureListViewer({ files, selected, onSelect, basePath = "" }) {
|
|
86
78
|
return (_jsxs(_Fragment, { children: [_jsx(AssetListViewer, { files: files, selected: selected, onSelect: onSelect, renderCard: (file, onSelectHandler) => (_jsx(TextureCard, { file: file, basePath: basePath, onSelect: onSelectHandler })) }), _jsx(SharedCanvas, {})] }));
|
|
@@ -93,7 +85,7 @@ function TextureCard({ file, onSelect, basePath = "" }) {
|
|
|
93
85
|
if (error) {
|
|
94
86
|
return (_jsx("div", { ref: ref, style: { aspectRatio: '1 / 1', backgroundColor: '#374151', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }, onClick: () => onSelect(file), children: _jsx("div", { style: styles.errorIcon, children: "\u2717" }) }));
|
|
95
87
|
}
|
|
96
|
-
return (_jsxs("div", { ref: ref, style: { aspectRatio: '1 / 1', backgroundColor: '#1f2937', cursor: 'pointer', display: 'flex', flexDirection: 'column' }, onClick: () => onSelect(file), onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [_jsx("div", { style: { flex: 1, position: 'relative' }, children: isInView ? (_jsxs(View, { style: { width: '100%', height: '100%' }, children: [_jsx(PerspectiveCamera, { makeDefault: true, position: [0, 0, 2.5], fov: 50 }), _jsxs(Suspense, { fallback: null, children: [_jsx("ambientLight", { intensity: 0.8 }), _jsx("pointLight", { position: [5, 5, 5], intensity: 0.5 }), _jsx(TextureSphere, { url: fullPath, onError: () => setError(true) }), _jsx(OrbitControls, { enableZoom: false, enablePan: false, autoRotate: isHovered, autoRotateSpeed: 2 })] })] })) : null }), _jsx("div", { style: { backgroundColor: 'rgba(0,0,0,0.6)', fontSize: 10, padding: '0 4px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }, children: file.split('/').pop() })] }));
|
|
88
|
+
return (_jsxs("div", { ref: ref, style: { maxWidth: 60, aspectRatio: '1 / 1', backgroundColor: '#1f2937', cursor: 'pointer', display: 'flex', flexDirection: 'column' }, onClick: () => onSelect(file), onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [_jsx("div", { style: { flex: 1, position: 'relative' }, children: isInView ? (_jsxs(View, { style: { width: '100%', height: '100%' }, children: [_jsx(PerspectiveCamera, { makeDefault: true, position: [0, 0, 2.5], fov: 50 }), _jsxs(Suspense, { fallback: null, children: [_jsx("ambientLight", { intensity: 0.8 }), _jsx("pointLight", { position: [5, 5, 5], intensity: 0.5 }), _jsx(TextureSphere, { url: fullPath, onError: () => setError(true) }), _jsx(OrbitControls, { enableZoom: false, enablePan: false, autoRotate: isHovered, autoRotateSpeed: 2 })] })] })) : null }), _jsx("div", { style: { backgroundColor: 'rgba(0,0,0,0.6)', fontSize: 10, padding: '0 4px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }, children: file.split('/').pop() })] }));
|
|
97
89
|
}
|
|
98
90
|
function TextureSphere({ url, onError }) {
|
|
99
91
|
const texture = useLoader(TextureLoader, url, undefined, (error) => {
|
|
@@ -112,7 +104,7 @@ function ModelCard({ file, onSelect, basePath = "" }) {
|
|
|
112
104
|
if (error) {
|
|
113
105
|
return (_jsx("div", { ref: ref, style: { aspectRatio: '1 / 1', backgroundColor: '#374151', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }, onClick: () => onSelect(file), children: _jsx("div", { style: styles.errorIcon, children: "\u2717" }) }));
|
|
114
106
|
}
|
|
115
|
-
return (_jsxs("div", { ref: ref, style: { aspectRatio: '1 / 1', backgroundColor: '#111827', cursor: 'pointer', display: 'flex', flexDirection: 'column' }, onClick: () => onSelect(file), children: [_jsx("div", { style: styles.flexFillRelative, children: isInView ? (_jsxs(View, { style: { width: '100%', height: '100%' }, children: [_jsx(PerspectiveCamera, { makeDefault: true, position: [0, 1, 3], fov: 50 }), _jsxs(Suspense, { fallback: null, children: [_jsx(Stage, { intensity: 0.5, environment: "city", children: _jsx(ModelPreview, { url: fullPath, onError: () => setError(true) }) }), _jsx(OrbitControls, { enableZoom: false })] })] })) : null }), _jsx("div", { style: { backgroundColor: 'rgba(0,0,0,0.6)', fontSize: 10, padding: '0 4px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }, children: file.split('/').pop() })] }));
|
|
107
|
+
return (_jsxs("div", { ref: ref, style: { maxWidth: 60, aspectRatio: '1 / 1', backgroundColor: '#111827', cursor: 'pointer', display: 'flex', flexDirection: 'column' }, onClick: () => onSelect(file), children: [_jsx("div", { style: styles.flexFillRelative, children: isInView ? (_jsxs(View, { style: { width: '100%', height: '100%' }, children: [_jsx(PerspectiveCamera, { makeDefault: true, position: [0, 1, 3], fov: 50 }), _jsxs(Suspense, { fallback: null, children: [_jsx(Stage, { intensity: 0.5, environment: "city", children: _jsx(ModelPreview, { url: fullPath, onError: () => setError(true) }) }), _jsx(OrbitControls, { enableZoom: false })] })] })) : null }), _jsx("div", { style: { backgroundColor: 'rgba(0,0,0,0.6)', fontSize: 10, padding: '0 4px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }, children: file.split('/').pop() })] }));
|
|
116
108
|
}
|
|
117
109
|
function ModelPreview({ url, onError }) {
|
|
118
110
|
const [model, setModel] = useState(null);
|
|
@@ -146,10 +138,26 @@ function SoundCard({ file, onSelect, basePath = "" }) {
|
|
|
146
138
|
const fullPath = basePath ? `/${basePath}${file}` : file;
|
|
147
139
|
return (_jsxs("div", { onClick: () => onSelect(file), style: { aspectRatio: '1 / 1', backgroundColor: '#374151', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }, children: [_jsx("div", { style: styles.iconLarge, children: "\uD83D\uDD0A" }), _jsx("div", { style: { fontSize: 12, padding: '0 4px', marginTop: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center', width: '100%' }, children: fileName })] }));
|
|
148
140
|
}
|
|
141
|
+
// Single Asset Viewer Components - display only one selected asset
|
|
142
|
+
export function SingleTextureViewer({ file, basePath = "" }) {
|
|
143
|
+
if (!file)
|
|
144
|
+
return null;
|
|
145
|
+
return (_jsxs(_Fragment, { children: [_jsx(TextureCard, { file: file, basePath: basePath, onSelect: () => { } }), _jsx(SharedCanvas, {})] }));
|
|
146
|
+
}
|
|
147
|
+
export function SingleModelViewer({ file, basePath = "" }) {
|
|
148
|
+
if (!file)
|
|
149
|
+
return null;
|
|
150
|
+
return (_jsxs(_Fragment, { children: [_jsx(ModelCard, { file: file, basePath: basePath, onSelect: () => { } }), _jsx(SharedCanvas, {})] }));
|
|
151
|
+
}
|
|
152
|
+
export function SingleSoundViewer({ file, basePath = "" }) {
|
|
153
|
+
if (!file)
|
|
154
|
+
return null;
|
|
155
|
+
return _jsx(SoundCard, { file: file, basePath: basePath, onSelect: () => { } });
|
|
156
|
+
}
|
|
149
157
|
// Shared Canvas Component - can be used independently in any viewer
|
|
150
158
|
export function SharedCanvas() {
|
|
151
159
|
return (_jsx(Canvas, { shadows: true, dpr: [1, 1.5], camera: { position: [0, 0, 3], fov: 45, near: 0.1, far: 1000 }, style: {
|
|
152
|
-
position: '
|
|
160
|
+
position: 'absolute',
|
|
153
161
|
top: 0,
|
|
154
162
|
left: 0,
|
|
155
163
|
width: '100vw',
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cloneElement, useState } from "react";
|
|
4
|
+
import LoadingSpinner from "../../sketches/loading/loading";
|
|
5
|
+
export default function GameWithLoader({ children }) {
|
|
6
|
+
const [isCanvasReady, setIsCanvasReady] = useState(false);
|
|
7
|
+
return (_jsxs(_Fragment, { children: [!isCanvasReady && _jsx(LoadingSpinner, {}), cloneElement(children, { onCanvasReady: () => setIsCanvasReady(true) })] }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
const lines = [
|
|
5
|
+
"$ initializing...",
|
|
6
|
+
"✓ loading scene",
|
|
7
|
+
"✓ ready"
|
|
8
|
+
];
|
|
9
|
+
const LoadingSpinner = () => {
|
|
10
|
+
const [index, setIndex] = useState(0);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (index < lines.length) {
|
|
13
|
+
const timer = setTimeout(() => setIndex(index + 1), 300);
|
|
14
|
+
return () => clearTimeout(timer);
|
|
15
|
+
}
|
|
16
|
+
}, [index]);
|
|
17
|
+
return (_jsxs("div", { className: "terminal-loading", children: [lines.slice(0, index).map((line, i) => (_jsx("div", { children: line }, i))), _jsx("div", { className: "cursor", children: "\u2588" }), _jsx("style", { jsx: true, children: `
|
|
18
|
+
.terminal-loading {
|
|
19
|
+
position: fixed;
|
|
20
|
+
inset: 0;
|
|
21
|
+
z-index: 50;
|
|
22
|
+
background: #0a0a0a;
|
|
23
|
+
color: #00ff00;
|
|
24
|
+
font-family: monospace;
|
|
25
|
+
padding: 2rem;
|
|
26
|
+
font-size: 14px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.cursor {
|
|
30
|
+
animation: blink 1s infinite;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@keyframes blink {
|
|
34
|
+
50% { opacity: 0; }
|
|
35
|
+
}
|
|
36
|
+
` })] }));
|
|
37
|
+
};
|
|
38
|
+
export default LoadingSpinner;
|
|
@@ -49,23 +49,23 @@ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransfo
|
|
|
49
49
|
if (!newAvailable.includes(addType))
|
|
50
50
|
setAddType(newAvailable[0] || "");
|
|
51
51
|
}, [Object.keys(node.components || {}).join(',')]);
|
|
52
|
-
return _jsxs("div", { style: inspector.content, className: "prefab-scroll", children: [_jsxs("div", { style: base.section, children: [_jsx("div", { style:
|
|
52
|
+
return _jsxs("div", { style: inspector.content, className: "prefab-scroll", children: [_jsxs("div", { style: base.section, children: [_jsxs("div", { style: { display: "flex", marginBottom: 8, alignItems: 'center', gap: 8 }, children: [_jsx("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 }, children: node.id }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), base.btnDanger), title: "Delete Node", onClick: deleteNode, children: "\u274C" })] }), _jsx("input", { style: base.input, value: (_a = node.name) !== null && _a !== void 0 ? _a : "", placeholder: 'Node name', onChange: e => updateNode(n => (Object.assign(Object.assign({}, n), { name: e.target.value }))) })] }), _jsxs("div", { style: base.section, children: [_jsx("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, children: _jsx("div", { style: base.label, children: "Components" }) }), node.components && Object.entries(node.components).map(([key, comp]) => {
|
|
53
53
|
if (!comp)
|
|
54
54
|
return null;
|
|
55
55
|
const def = ALL_COMPONENTS[comp.type];
|
|
56
56
|
if (!def)
|
|
57
57
|
return _jsxs("div", { style: { color: '#ff8888', fontSize: 11 }, children: ["Unknown: ", comp.type] }, key);
|
|
58
|
-
return (_jsxs("div", { style: { marginBottom: 8 }, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }, children: [_jsx("div", { style: { fontSize: 11, fontWeight: 500 }, children: key }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px' }), onClick: () => updateNode(n => {
|
|
58
|
+
return (_jsxs("div", { style: { marginBottom: 8 }, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }, children: [_jsx("div", { style: { fontSize: 11, fontWeight: 500 }, children: key }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px' }), title: "Remove Component", onClick: () => updateNode(n => {
|
|
59
59
|
const _a = n.components || {}, _b = key, _ = _a[_b], rest = __rest(_a, [typeof _b === "symbol" ? _b : _b + ""]);
|
|
60
60
|
return Object.assign(Object.assign({}, n), { components: rest });
|
|
61
|
-
}), children: "\u2715" })] }), def.Editor && (_jsx(def.Editor, { component: comp, onUpdate: (newProps) => updateNode(n => (Object.assign(Object.assign({}, n), { components: Object.assign(Object.assign({}, n.components), { [key]: Object.assign(Object.assign({}, comp), { properties: Object.assign(Object.assign({}, comp.properties), newProps) }) }) }))), basePath: basePath, transformMode: transformMode, setTransformMode: setTransformMode }))] }, key));
|
|
62
|
-
})] }), available.length > 0 && (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
}), children: "\u2715" })] }), def.Editor && (_jsx(def.Editor, { component: comp, node: node, onUpdate: (newProps) => updateNode(n => (Object.assign(Object.assign({}, n), { components: Object.assign(Object.assign({}, n.components), { [key]: Object.assign(Object.assign({}, comp), { properties: Object.assign(Object.assign({}, comp.properties), newProps) }) }) }))), basePath: basePath, transformMode: transformMode, setTransformMode: setTransformMode }))] }, key));
|
|
62
|
+
})] }), available.length > 0 && (_jsx("div", { children: _jsxs("div", { style: base.row, children: [_jsx("select", { style: Object.assign(Object.assign({}, base.input), { flex: 1 }), value: addType, onChange: e => setAddType(e.target.value), children: available.map(k => _jsx("option", { value: k, children: k }, k)) }), _jsx("button", { style: base.btn, disabled: !addType, onClick: () => {
|
|
63
|
+
if (!addType)
|
|
64
|
+
return;
|
|
65
|
+
const def = ALL_COMPONENTS[addType];
|
|
66
|
+
if (def) {
|
|
67
|
+
updateNode(n => (Object.assign(Object.assign({}, n), { components: Object.assign(Object.assign({}, n.components), { [addType.toLowerCase()]: { type: def.name, properties: def.defaultProperties } }) })));
|
|
68
|
+
}
|
|
69
|
+
}, title: "Add Component", children: "+" })] }) }))] });
|
|
70
70
|
}
|
|
71
71
|
export default EditorUI;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { FC } from "react";
|
|
2
|
+
import { ComponentData, GameObject } from "../types";
|
|
2
3
|
export interface Component {
|
|
3
4
|
name: string;
|
|
4
5
|
Editor: FC<{
|
|
5
|
-
|
|
6
|
+
node?: GameObject;
|
|
7
|
+
component: ComponentData;
|
|
6
8
|
onUpdate: (newComp: any) => void;
|
|
7
9
|
basePath?: string;
|
|
8
10
|
transformMode?: "translate" | "rotate" | "scale";
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { TextureListViewer } from '../../assetviewer/page';
|
|
2
|
+
import { SingleTextureViewer, TextureListViewer } from '../../assetviewer/page';
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { Input, Label } from './Input';
|
|
5
|
+
import { useMemo } from 'react';
|
|
6
|
+
import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace, NearestFilter, LinearFilter, NearestMipmapNearestFilter, NearestMipmapLinearFilter, LinearMipmapNearestFilter, LinearMipmapLinearFilter } from 'three';
|
|
5
7
|
function MaterialComponentEditor({ component, onUpdate, basePath = "" }) {
|
|
6
8
|
var _a, _b, _c, _d;
|
|
7
9
|
const [textureFiles, setTextureFiles] = useState([]);
|
|
10
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
8
11
|
useEffect(() => {
|
|
9
12
|
const base = basePath ? `${basePath}/` : '';
|
|
10
13
|
fetch(`/${base}textures/manifest.json`)
|
|
@@ -22,7 +25,10 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }) {
|
|
|
22
25
|
fontFamily: 'monospace',
|
|
23
26
|
outline: 'none',
|
|
24
27
|
};
|
|
25
|
-
return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: [_jsxs("div", { children: [_jsx(Label, { children: "Color" }), _jsxs("div", { style: { display: 'flex', gap: 2 }, children: [_jsx("input", { type: "color", style: { height: 20, width: 20, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }, value: component.properties.color, onChange: e => onUpdate({ color: e.target.value }) }), _jsx("input", { type: "text", style: textInputStyle, value: component.properties.color, onChange: e => onUpdate({ color: e.target.value }) })] })] }), _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 4 }, children: [_jsx("input", { type: "checkbox", style: { width: 12, height: 12 }, checked: component.properties.wireframe || false, onChange: e => onUpdate({ wireframe: e.target.checked }) }), _jsx("label", { style: { fontSize: '9px', color: 'rgba(34, 211, 238, 0.6)' }, children: "Wireframe" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Texture" }),
|
|
28
|
+
return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: [_jsxs("div", { children: [_jsx(Label, { children: "Color" }), _jsxs("div", { style: { display: 'flex', gap: 2 }, children: [_jsx("input", { type: "color", style: { height: 20, width: 20, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }, value: component.properties.color, onChange: e => onUpdate({ color: e.target.value }) }), _jsx("input", { type: "text", style: textInputStyle, value: component.properties.color, onChange: e => onUpdate({ color: e.target.value }) })] })] }), _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 4 }, children: [_jsx("input", { type: "checkbox", style: { width: 12, height: 12 }, checked: component.properties.wireframe || false, onChange: e => onUpdate({ wireframe: e.target.checked }) }), _jsx("label", { style: { fontSize: '9px', color: 'rgba(34, 211, 238, 0.6)' }, children: "Wireframe" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Texture File" }), _jsxs("div", { style: { maxHeight: 128, overflowY: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }, children: [_jsx(SingleTextureViewer, { file: component.properties.texture || undefined, basePath: basePath }), _jsx("button", { onClick: () => setShowPicker(!showPicker), style: { padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }, children: showPicker ? 'Hide' : 'Change' }), showPicker && (_jsx("div", { style: { position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }, children: _jsx(TextureListViewer, { files: textureFiles, selected: component.properties.texture || undefined, onSelect: (file) => {
|
|
29
|
+
onUpdate({ texture: file });
|
|
30
|
+
setShowPicker(false);
|
|
31
|
+
}, basePath: basePath }) }))] })] }), component.properties.texture && (_jsxs("div", { style: { borderTop: '1px solid rgba(34, 211, 238, 0.2)', paddingTop: 4, marginTop: 4 }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }, children: [_jsx("input", { type: "checkbox", style: { width: 12, height: 12 }, checked: component.properties.repeat || false, onChange: e => onUpdate({ repeat: e.target.checked }) }), _jsx("label", { style: { fontSize: '9px', color: 'rgba(34, 211, 238, 0.6)' }, children: "Repeat Texture" })] }), component.properties.repeat && (_jsxs("div", { children: [_jsx(Label, { children: "Repeat (X, Y)" }), _jsxs("div", { style: { display: 'flex', gap: 2 }, children: [_jsx(Input, { value: (_b = (_a = component.properties.repeatCount) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : 1, onChange: value => {
|
|
26
32
|
var _a, _b;
|
|
27
33
|
const y = (_b = (_a = component.properties.repeatCount) === null || _a === void 0 ? void 0 : _a[1]) !== null && _b !== void 0 ? _b : 1;
|
|
28
34
|
onUpdate({ repeatCount: [value, y] });
|
|
@@ -30,19 +36,33 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }) {
|
|
|
30
36
|
var _a, _b;
|
|
31
37
|
const x = (_b = (_a = component.properties.repeatCount) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : 1;
|
|
32
38
|
onUpdate({ repeatCount: [x, value] });
|
|
33
|
-
} })] })] }))] }))] }));
|
|
39
|
+
} })] })] })), _jsxs("div", { style: { marginTop: 4 }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }, children: [_jsx("input", { type: "checkbox", style: { width: 12, height: 12 }, checked: component.properties.generateMipmaps !== false, onChange: e => onUpdate({ generateMipmaps: e.target.checked }) }), _jsx("label", { style: { fontSize: '9px', color: 'rgba(34, 211, 238, 0.6)' }, children: "Generate Mipmaps" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Min Filter" }), _jsxs("select", { style: Object.assign(Object.assign({}, textInputStyle), { width: '100%', cursor: 'pointer' }), value: component.properties.minFilter || 'LinearMipmapLinearFilter', onChange: e => onUpdate({ minFilter: e.target.value }), children: [_jsx("option", { value: "NearestFilter", children: "Nearest" }), _jsx("option", { value: "NearestMipmapNearestFilter", children: "Nearest Mipmap Nearest" }), _jsx("option", { value: "NearestMipmapLinearFilter", children: "Nearest Mipmap Linear" }), _jsx("option", { value: "LinearFilter", children: "Linear" }), _jsx("option", { value: "LinearMipmapNearestFilter", children: "Linear Mipmap Nearest" }), _jsx("option", { value: "LinearMipmapLinearFilter", children: "Linear Mipmap Linear (Default)" })] })] }), _jsxs("div", { style: { marginTop: 4 }, children: [_jsx(Label, { children: "Mag Filter" }), _jsxs("select", { style: Object.assign(Object.assign({}, textInputStyle), { width: '100%', cursor: 'pointer' }), value: component.properties.magFilter || 'LinearFilter', onChange: e => onUpdate({ magFilter: e.target.value }), children: [_jsx("option", { value: "NearestFilter", children: "Nearest" }), _jsx("option", { value: "LinearFilter", children: "Linear (Default)" })] })] })] })] }))] }));
|
|
34
40
|
}
|
|
35
41
|
;
|
|
36
|
-
import { useMemo } from 'react';
|
|
37
|
-
import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace } from 'three';
|
|
38
42
|
// View for Material component
|
|
39
43
|
function MaterialComponentView({ properties, loadedTextures }) {
|
|
40
44
|
var _a;
|
|
41
45
|
const textureName = properties === null || properties === void 0 ? void 0 : properties.texture;
|
|
42
46
|
const repeat = properties === null || properties === void 0 ? void 0 : properties.repeat;
|
|
43
47
|
const repeatCount = properties === null || properties === void 0 ? void 0 : properties.repeatCount;
|
|
48
|
+
const generateMipmaps = (properties === null || properties === void 0 ? void 0 : properties.generateMipmaps) !== false;
|
|
49
|
+
const minFilter = (properties === null || properties === void 0 ? void 0 : properties.minFilter) || 'LinearMipmapLinearFilter';
|
|
50
|
+
const magFilter = (properties === null || properties === void 0 ? void 0 : properties.magFilter) || 'LinearFilter';
|
|
44
51
|
const texture = textureName && loadedTextures ? loadedTextures[textureName] : undefined;
|
|
52
|
+
const minFilterMap = {
|
|
53
|
+
NearestFilter,
|
|
54
|
+
LinearFilter,
|
|
55
|
+
NearestMipmapNearestFilter,
|
|
56
|
+
NearestMipmapLinearFilter,
|
|
57
|
+
LinearMipmapNearestFilter,
|
|
58
|
+
LinearMipmapLinearFilter
|
|
59
|
+
};
|
|
60
|
+
const magFilterMap = {
|
|
61
|
+
NearestFilter,
|
|
62
|
+
LinearFilter
|
|
63
|
+
};
|
|
45
64
|
const finalTexture = useMemo(() => {
|
|
65
|
+
var _a, _b;
|
|
46
66
|
if (!texture)
|
|
47
67
|
return undefined;
|
|
48
68
|
const t = texture.clone();
|
|
@@ -56,9 +76,12 @@ function MaterialComponentView({ properties, loadedTextures }) {
|
|
|
56
76
|
t.repeat.set(1, 1);
|
|
57
77
|
}
|
|
58
78
|
t.colorSpace = SRGBColorSpace;
|
|
79
|
+
t.generateMipmaps = generateMipmaps;
|
|
80
|
+
t.minFilter = (_a = minFilterMap[minFilter]) !== null && _a !== void 0 ? _a : LinearMipmapLinearFilter;
|
|
81
|
+
t.magFilter = (_b = magFilterMap[magFilter]) !== null && _b !== void 0 ? _b : LinearFilter;
|
|
59
82
|
t.needsUpdate = true;
|
|
60
83
|
return t;
|
|
61
|
-
}, [texture, repeat, repeatCount === null || repeatCount === void 0 ? void 0 : repeatCount[0], repeatCount === null || repeatCount === void 0 ? void 0 : repeatCount[1]]);
|
|
84
|
+
}, [texture, repeat, repeatCount === null || repeatCount === void 0 ? void 0 : repeatCount[0], repeatCount === null || repeatCount === void 0 ? void 0 : repeatCount[1], generateMipmaps, minFilter, magFilter]);
|
|
62
85
|
if (!properties) {
|
|
63
86
|
return _jsx("meshStandardMaterial", { color: "red", wireframe: true });
|
|
64
87
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { ModelListViewer } from '../../assetviewer/page';
|
|
2
|
+
import { ModelListViewer, SingleModelViewer } from '../../assetviewer/page';
|
|
3
3
|
import { useEffect, useState, useMemo } from 'react';
|
|
4
4
|
import { Label } from './Input';
|
|
5
|
-
function ModelComponentEditor({ component, onUpdate, basePath = "" }) {
|
|
5
|
+
function ModelComponentEditor({ component, node, onUpdate, basePath = "" }) {
|
|
6
6
|
const [modelFiles, setModelFiles] = useState([]);
|
|
7
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
7
8
|
useEffect(() => {
|
|
8
9
|
const base = basePath ? `${basePath}/` : '';
|
|
9
10
|
fetch(`/${base}models/manifest.json`)
|
|
@@ -16,7 +17,10 @@ function ModelComponentEditor({ component, onUpdate, basePath = "" }) {
|
|
|
16
17
|
const filename = file.startsWith('/') ? file.slice(1) : file;
|
|
17
18
|
onUpdate({ 'filename': filename });
|
|
18
19
|
};
|
|
19
|
-
return _jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: [_jsxs("div", { children: [_jsx(Label, { children: "Model" }),
|
|
20
|
+
return _jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: [_jsxs("div", { children: [_jsx(Label, { children: "Model File" }), _jsxs("div", { style: { maxHeight: 128, overflowY: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }, children: [_jsx(SingleModelViewer, { file: component.properties.filename ? `/${component.properties.filename}` : undefined, basePath: basePath }), _jsx("button", { onClick: () => setShowPicker(!showPicker), style: { padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }, children: showPicker ? 'Hide' : 'Change' }), showPicker && (_jsx("div", { style: { position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }, children: _jsx(ModelListViewer, { files: modelFiles, selected: component.properties.filename ? `/${component.properties.filename}` : undefined, onSelect: (file) => {
|
|
21
|
+
handleModelSelect(file);
|
|
22
|
+
setShowPicker(false);
|
|
23
|
+
}, basePath: basePath }, node === null || node === void 0 ? void 0 : node.id) }))] })] }), _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 4 }, children: [_jsx("input", { type: "checkbox", id: "instanced-checkbox", checked: component.properties.instanced || false, onChange: e => onUpdate({ instanced: e.target.checked }), style: { width: 12, height: 12 } }), _jsx("label", { htmlFor: "instanced-checkbox", style: { fontSize: '9px', color: 'rgba(34, 211, 238, 0.6)' }, children: "Instanced" })] })] });
|
|
20
24
|
}
|
|
21
25
|
// View for Model component
|
|
22
26
|
function ModelComponentView({ properties, loadedModels, children }) {
|
package/package.json
CHANGED
|
@@ -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,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}
|
|
@@ -48,6 +48,7 @@ function FolderTile({ name, onClick }: { name: string; onClick: () => void }) {
|
|
|
48
48
|
<div
|
|
49
49
|
onClick={onClick}
|
|
50
50
|
style={{
|
|
51
|
+
maxWidth: 60,
|
|
51
52
|
aspectRatio: '1 / 1',
|
|
52
53
|
backgroundColor: '#1f2937', /* gray-800 */
|
|
53
54
|
cursor: 'pointer',
|
|
@@ -98,25 +99,8 @@ interface AssetListViewerProps {
|
|
|
98
99
|
|
|
99
100
|
function AssetListViewer({ files, selected, onSelect, renderCard }: AssetListViewerProps) {
|
|
100
101
|
const [currentPath, setCurrentPath] = useState('');
|
|
101
|
-
const [showPicker, setShowPicker] = useState(false);
|
|
102
102
|
const { folders, filesInCurrentPath } = getItemsInPath(files, currentPath);
|
|
103
103
|
|
|
104
|
-
const showCompactView = selected && !showPicker;
|
|
105
|
-
|
|
106
|
-
if (showCompactView) {
|
|
107
|
-
return (
|
|
108
|
-
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
109
|
-
{renderCard(selected, onSelect)}
|
|
110
|
-
<button
|
|
111
|
-
onClick={() => setShowPicker(true)}
|
|
112
|
-
style={{ padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 12, cursor: 'pointer', border: 'none' }}
|
|
113
|
-
>
|
|
114
|
-
Change
|
|
115
|
-
</button>
|
|
116
|
-
</div>
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
104
|
return (
|
|
121
105
|
<div>
|
|
122
106
|
{currentPath && (
|
|
@@ -141,10 +125,7 @@ function AssetListViewer({ files, selected, onSelect, renderCard }: AssetListVie
|
|
|
141
125
|
))}
|
|
142
126
|
{filesInCurrentPath.map((file) => (
|
|
143
127
|
<div key={file}>
|
|
144
|
-
{renderCard(file,
|
|
145
|
-
onSelect(f);
|
|
146
|
-
if (selected) setShowPicker(false);
|
|
147
|
-
})}
|
|
128
|
+
{renderCard(file, onSelect)}
|
|
148
129
|
</div>
|
|
149
130
|
))}
|
|
150
131
|
</div>
|
|
@@ -196,7 +177,7 @@ function TextureCard({ file, onSelect, basePath = "" }: { file: string; onSelect
|
|
|
196
177
|
return (
|
|
197
178
|
<div
|
|
198
179
|
ref={ref}
|
|
199
|
-
style={{ aspectRatio: '1 / 1', backgroundColor: '#1f2937', cursor: 'pointer', display: 'flex', flexDirection: 'column' }}
|
|
180
|
+
style={{ maxWidth: 60, aspectRatio: '1 / 1', backgroundColor: '#1f2937', cursor: 'pointer', display: 'flex', flexDirection: 'column' }}
|
|
200
181
|
onClick={() => onSelect(file)}
|
|
201
182
|
onMouseEnter={() => setIsHovered(true)}
|
|
202
183
|
onMouseLeave={() => setIsHovered(false)}
|
|
@@ -282,7 +263,7 @@ function ModelCard({ file, onSelect, basePath = "" }: { file: string; onSelect:
|
|
|
282
263
|
return (
|
|
283
264
|
<div
|
|
284
265
|
ref={ref}
|
|
285
|
-
style={{ aspectRatio: '1 / 1', backgroundColor: '#111827', cursor: 'pointer', display: 'flex', flexDirection: 'column' }}
|
|
266
|
+
style={{ maxWidth: 60, aspectRatio: '1 / 1', backgroundColor: '#111827', cursor: 'pointer', display: 'flex', flexDirection: 'column' }}
|
|
286
267
|
onClick={() => onSelect(file)}
|
|
287
268
|
>
|
|
288
269
|
<div style={styles.flexFillRelative}>
|
|
@@ -364,6 +345,32 @@ function SoundCard({ file, onSelect, basePath = "" }: { file: string; onSelect:
|
|
|
364
345
|
);
|
|
365
346
|
}
|
|
366
347
|
|
|
348
|
+
// Single Asset Viewer Components - display only one selected asset
|
|
349
|
+
export function SingleTextureViewer({ file, basePath = "" }: { file?: string; basePath?: string }) {
|
|
350
|
+
if (!file) return null;
|
|
351
|
+
return (
|
|
352
|
+
<>
|
|
353
|
+
<TextureCard file={file} basePath={basePath} onSelect={() => { }} />
|
|
354
|
+
<SharedCanvas />
|
|
355
|
+
</>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function SingleModelViewer({ file, basePath = "" }: { file?: string; basePath?: string }) {
|
|
360
|
+
if (!file) return null;
|
|
361
|
+
return (
|
|
362
|
+
<>
|
|
363
|
+
<ModelCard file={file} basePath={basePath} onSelect={() => { }} />
|
|
364
|
+
<SharedCanvas />
|
|
365
|
+
</>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function SingleSoundViewer({ file, basePath = "" }: { file?: string; basePath?: string }) {
|
|
370
|
+
if (!file) return null;
|
|
371
|
+
return <SoundCard file={file} basePath={basePath} onSelect={() => { }} />;
|
|
372
|
+
}
|
|
373
|
+
|
|
367
374
|
// Shared Canvas Component - can be used independently in any viewer
|
|
368
375
|
export function SharedCanvas() {
|
|
369
376
|
return (
|
|
@@ -372,7 +379,7 @@ export function SharedCanvas() {
|
|
|
372
379
|
dpr={[1, 1.5]}
|
|
373
380
|
camera={{ position: [0, 0, 3], fov: 45, near: 0.1, far: 1000 }}
|
|
374
381
|
style={{
|
|
375
|
-
position: '
|
|
382
|
+
position: 'absolute',
|
|
376
383
|
top: 0,
|
|
377
384
|
left: 0,
|
|
378
385
|
width: '100vw',
|
|
@@ -111,12 +111,19 @@ function NodeInspector({
|
|
|
111
111
|
}, [Object.keys(node.components || {}).join(',')]);
|
|
112
112
|
|
|
113
113
|
return <div style={inspector.content} className="prefab-scroll">
|
|
114
|
-
{/* Node
|
|
114
|
+
{/* Node Name */}
|
|
115
115
|
<div style={base.section}>
|
|
116
|
-
<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
|
+
|
|
117
123
|
<input
|
|
118
124
|
style={base.input}
|
|
119
125
|
value={node.name ?? ""}
|
|
126
|
+
placeholder='Node name'
|
|
120
127
|
onChange={e =>
|
|
121
128
|
updateNode(n => ({ ...n, name: e.target.value }))
|
|
122
129
|
}
|
|
@@ -127,7 +134,6 @@ function NodeInspector({
|
|
|
127
134
|
<div style={base.section}>
|
|
128
135
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
129
136
|
<div style={base.label}>Components</div>
|
|
130
|
-
<button style={{ ...base.btn, ...base.btnDanger }} onClick={deleteNode}>Delete Node</button>
|
|
131
137
|
</div>
|
|
132
138
|
|
|
133
139
|
{node.components && Object.entries(node.components).map(([key, comp]: [string, any]) => {
|
|
@@ -143,6 +149,7 @@ function NodeInspector({
|
|
|
143
149
|
<div style={{ fontSize: 11, fontWeight: 500 }}>{key}</div>
|
|
144
150
|
<button
|
|
145
151
|
style={{ ...base.btn, padding: '2px 6px' }}
|
|
152
|
+
title="Remove Component"
|
|
146
153
|
onClick={() => updateNode(n => {
|
|
147
154
|
const { [key]: _, ...rest } = n.components || {};
|
|
148
155
|
return { ...n, components: rest };
|
|
@@ -154,6 +161,7 @@ function NodeInspector({
|
|
|
154
161
|
{def.Editor && (
|
|
155
162
|
<def.Editor
|
|
156
163
|
component={comp}
|
|
164
|
+
node={node}
|
|
157
165
|
onUpdate={(newProps: any) => updateNode(n => ({
|
|
158
166
|
...n,
|
|
159
167
|
components: {
|
|
@@ -174,7 +182,6 @@ function NodeInspector({
|
|
|
174
182
|
{/* Add Component */}
|
|
175
183
|
{available.length > 0 && (
|
|
176
184
|
<div>
|
|
177
|
-
<div style={base.label}>Add Component</div>
|
|
178
185
|
<div style={base.row}>
|
|
179
186
|
<select
|
|
180
187
|
style={{ ...base.input, flex: 1 }}
|
|
@@ -199,6 +206,7 @@ function NodeInspector({
|
|
|
199
206
|
}));
|
|
200
207
|
}
|
|
201
208
|
}}
|
|
209
|
+
title="Add Component"
|
|
202
210
|
>
|
|
203
211
|
+
|
|
204
212
|
</button>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { FC } from "react";
|
|
2
|
+
import { ComponentData, GameObject } from "../types";
|
|
2
3
|
|
|
3
4
|
export interface Component {
|
|
4
5
|
name: string;
|
|
5
6
|
Editor: FC<{
|
|
6
|
-
|
|
7
|
+
node?: GameObject;
|
|
8
|
+
component: ComponentData;
|
|
7
9
|
onUpdate: (newComp: any) => void;
|
|
8
10
|
basePath?: string;
|
|
9
11
|
transformMode?: "translate" | "rotate" | "scale";
|
|
@@ -1,10 +1,27 @@
|
|
|
1
|
-
import { TextureListViewer } from '../../assetviewer/page';
|
|
1
|
+
import { SingleTextureViewer, TextureListViewer } from '../../assetviewer/page';
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { Component } from './ComponentRegistry';
|
|
4
4
|
import { Input, Label } from './Input';
|
|
5
|
+
import { useMemo } from 'react';
|
|
6
|
+
import {
|
|
7
|
+
DoubleSide,
|
|
8
|
+
RepeatWrapping,
|
|
9
|
+
ClampToEdgeWrapping,
|
|
10
|
+
SRGBColorSpace,
|
|
11
|
+
Texture,
|
|
12
|
+
NearestFilter,
|
|
13
|
+
LinearFilter,
|
|
14
|
+
NearestMipmapNearestFilter,
|
|
15
|
+
NearestMipmapLinearFilter,
|
|
16
|
+
LinearMipmapNearestFilter,
|
|
17
|
+
LinearMipmapLinearFilter,
|
|
18
|
+
MinificationTextureFilter,
|
|
19
|
+
MagnificationTextureFilter
|
|
20
|
+
} from 'three';
|
|
5
21
|
|
|
6
22
|
function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { component: any; onUpdate: (newComp: any) => void; basePath?: string }) {
|
|
7
23
|
const [textureFiles, setTextureFiles] = useState<string[]>([]);
|
|
24
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
8
25
|
|
|
9
26
|
useEffect(() => {
|
|
10
27
|
const base = basePath ? `${basePath}/` : '';
|
|
@@ -56,15 +73,30 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { compo
|
|
|
56
73
|
</div>
|
|
57
74
|
|
|
58
75
|
<div>
|
|
59
|
-
<Label>Texture</Label>
|
|
60
|
-
<div style={{ maxHeight: 128, overflowY: 'auto' }}>
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
<Label>Texture File</Label>
|
|
77
|
+
<div style={{ maxHeight: 128, overflowY: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }}>
|
|
78
|
+
<SingleTextureViewer file={component.properties.texture || undefined} basePath={basePath} />
|
|
79
|
+
<button
|
|
80
|
+
onClick={() => setShowPicker(!showPicker)}
|
|
81
|
+
style={{ padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }}
|
|
82
|
+
>
|
|
83
|
+
{showPicker ? 'Hide' : 'Change'}
|
|
84
|
+
</button>
|
|
85
|
+
{showPicker && (
|
|
86
|
+
<div style={{ position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }}>
|
|
87
|
+
<TextureListViewer
|
|
88
|
+
files={textureFiles}
|
|
89
|
+
selected={component.properties.texture || undefined}
|
|
90
|
+
onSelect={(file) => {
|
|
91
|
+
onUpdate({ texture: file });
|
|
92
|
+
setShowPicker(false);
|
|
93
|
+
}}
|
|
94
|
+
basePath={basePath}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
67
98
|
</div>
|
|
99
|
+
|
|
68
100
|
</div>
|
|
69
101
|
|
|
70
102
|
{component.properties.texture && (
|
|
@@ -100,23 +132,76 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { compo
|
|
|
100
132
|
</div>
|
|
101
133
|
</div>
|
|
102
134
|
)}
|
|
135
|
+
|
|
136
|
+
<div style={{ marginTop: 4 }}>
|
|
137
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
|
|
138
|
+
<input
|
|
139
|
+
type="checkbox"
|
|
140
|
+
style={{ width: 12, height: 12 }}
|
|
141
|
+
checked={component.properties.generateMipmaps !== false}
|
|
142
|
+
onChange={e => onUpdate({ generateMipmaps: e.target.checked })}
|
|
143
|
+
/>
|
|
144
|
+
<label style={{ fontSize: '9px', color: 'rgba(34, 211, 238, 0.6)' }}>Generate Mipmaps</label>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div>
|
|
148
|
+
<Label>Min Filter</Label>
|
|
149
|
+
<select
|
|
150
|
+
style={{ ...textInputStyle, width: '100%', cursor: 'pointer' }}
|
|
151
|
+
value={component.properties.minFilter || 'LinearMipmapLinearFilter'}
|
|
152
|
+
onChange={e => onUpdate({ minFilter: e.target.value })}
|
|
153
|
+
>
|
|
154
|
+
<option value="NearestFilter">Nearest</option>
|
|
155
|
+
<option value="NearestMipmapNearestFilter">Nearest Mipmap Nearest</option>
|
|
156
|
+
<option value="NearestMipmapLinearFilter">Nearest Mipmap Linear</option>
|
|
157
|
+
<option value="LinearFilter">Linear</option>
|
|
158
|
+
<option value="LinearMipmapNearestFilter">Linear Mipmap Nearest</option>
|
|
159
|
+
<option value="LinearMipmapLinearFilter">Linear Mipmap Linear (Default)</option>
|
|
160
|
+
</select>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div style={{ marginTop: 4 }}>
|
|
164
|
+
<Label>Mag Filter</Label>
|
|
165
|
+
<select
|
|
166
|
+
style={{ ...textInputStyle, width: '100%', cursor: 'pointer' }}
|
|
167
|
+
value={component.properties.magFilter || 'LinearFilter'}
|
|
168
|
+
onChange={e => onUpdate({ magFilter: e.target.value })}
|
|
169
|
+
>
|
|
170
|
+
<option value="NearestFilter">Nearest</option>
|
|
171
|
+
<option value="LinearFilter">Linear (Default)</option>
|
|
172
|
+
</select>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
103
175
|
</div>
|
|
104
176
|
)}
|
|
105
177
|
</div>
|
|
106
178
|
);
|
|
107
179
|
};
|
|
108
180
|
|
|
109
|
-
|
|
110
|
-
import { useMemo } from 'react';
|
|
111
|
-
import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace, Texture } from 'three';
|
|
112
|
-
|
|
113
181
|
// View for Material component
|
|
114
182
|
function MaterialComponentView({ properties, loadedTextures }: { properties: any, loadedTextures?: Record<string, Texture> }) {
|
|
115
183
|
const textureName = properties?.texture;
|
|
116
184
|
const repeat = properties?.repeat;
|
|
117
185
|
const repeatCount = properties?.repeatCount;
|
|
186
|
+
const generateMipmaps = properties?.generateMipmaps !== false;
|
|
187
|
+
const minFilter = properties?.minFilter || 'LinearMipmapLinearFilter';
|
|
188
|
+
const magFilter = properties?.magFilter || 'LinearFilter';
|
|
118
189
|
const texture = textureName && loadedTextures ? loadedTextures[textureName] : undefined;
|
|
119
190
|
|
|
191
|
+
const minFilterMap: Record<string, MinificationTextureFilter> = {
|
|
192
|
+
NearestFilter,
|
|
193
|
+
LinearFilter,
|
|
194
|
+
NearestMipmapNearestFilter,
|
|
195
|
+
NearestMipmapLinearFilter,
|
|
196
|
+
LinearMipmapNearestFilter,
|
|
197
|
+
LinearMipmapLinearFilter
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const magFilterMap: Record<string, MagnificationTextureFilter> = {
|
|
201
|
+
NearestFilter,
|
|
202
|
+
LinearFilter
|
|
203
|
+
};
|
|
204
|
+
|
|
120
205
|
const finalTexture = useMemo(() => {
|
|
121
206
|
if (!texture) return undefined;
|
|
122
207
|
const t = texture.clone();
|
|
@@ -128,9 +213,12 @@ function MaterialComponentView({ properties, loadedTextures }: { properties: any
|
|
|
128
213
|
t.repeat.set(1, 1);
|
|
129
214
|
}
|
|
130
215
|
t.colorSpace = SRGBColorSpace;
|
|
216
|
+
t.generateMipmaps = generateMipmaps;
|
|
217
|
+
t.minFilter = minFilterMap[minFilter] ?? LinearMipmapLinearFilter;
|
|
218
|
+
t.magFilter = magFilterMap[magFilter] ?? LinearFilter;
|
|
131
219
|
t.needsUpdate = true;
|
|
132
220
|
return t;
|
|
133
|
-
}, [texture, repeat, repeatCount?.[0], repeatCount?.[1]]);
|
|
221
|
+
}, [texture, repeat, repeatCount?.[0], repeatCount?.[1], generateMipmaps, minFilter, magFilter]);
|
|
134
222
|
|
|
135
223
|
if (!properties) {
|
|
136
224
|
return <meshStandardMaterial color="red" wireframe />;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { ModelListViewer } from '../../assetviewer/page';
|
|
1
|
+
import { ModelListViewer, SingleModelViewer } from '../../assetviewer/page';
|
|
2
2
|
import { useEffect, useState, useMemo } from 'react';
|
|
3
3
|
import { Component } from './ComponentRegistry';
|
|
4
4
|
import { Label } from './Input';
|
|
5
|
+
import { GameObject } from '../types';
|
|
5
6
|
|
|
6
|
-
function ModelComponentEditor({ component, onUpdate, basePath = "" }: { component: any; onUpdate: (newComp: any) => void; basePath?: string }) {
|
|
7
|
+
function ModelComponentEditor({ component, node, onUpdate, basePath = "" }: { component: any; node?: GameObject; onUpdate: (newComp: any) => void; basePath?: string }) {
|
|
7
8
|
const [modelFiles, setModelFiles] = useState<string[]>([]);
|
|
9
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
8
10
|
|
|
9
11
|
useEffect(() => {
|
|
10
12
|
const base = basePath ? `${basePath}/` : '';
|
|
@@ -22,14 +24,29 @@ function ModelComponentEditor({ component, onUpdate, basePath = "" }: { componen
|
|
|
22
24
|
|
|
23
25
|
return <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
24
26
|
<div>
|
|
25
|
-
<Label>Model</Label>
|
|
26
|
-
<div style={{ maxHeight: 128, overflowY: 'auto' }}>
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
<Label>Model File</Label>
|
|
28
|
+
<div style={{ maxHeight: 128, overflowY: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }}>
|
|
29
|
+
<SingleModelViewer file={component.properties.filename ? `/${component.properties.filename}` : undefined} basePath={basePath} />
|
|
30
|
+
<button
|
|
31
|
+
onClick={() => setShowPicker(!showPicker)}
|
|
32
|
+
style={{ padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }}
|
|
33
|
+
>
|
|
34
|
+
{showPicker ? 'Hide' : 'Change'}
|
|
35
|
+
</button>
|
|
36
|
+
{showPicker && (
|
|
37
|
+
<div style={{ position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }}>
|
|
38
|
+
<ModelListViewer
|
|
39
|
+
key={node?.id}
|
|
40
|
+
files={modelFiles}
|
|
41
|
+
selected={component.properties.filename ? `/${component.properties.filename}` : undefined}
|
|
42
|
+
onSelect={(file) => {
|
|
43
|
+
handleModelSelect(file);
|
|
44
|
+
setShowPicker(false);
|
|
45
|
+
}}
|
|
46
|
+
basePath={basePath}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
33
50
|
</div>
|
|
34
51
|
</div>
|
|
35
52
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|