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.
@@ -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
- export default function GameCanvas({ loader, children, ...props }: {
3
+ interface GameCanvasProps extends Omit<CanvasProps, 'children'> {
3
4
  loader?: boolean;
4
5
  children: React.ReactNode;
5
- props?: WebGPURendererParameters;
6
- }): import("react/jsx-runtime").JSX.Element;
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 }, props));
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, (f) => {
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: 'fixed',
160
+ position: 'absolute',
153
161
  top: 0,
154
162
  left: 0,
155
163
  width: '100vw',
@@ -0,0 +1,6 @@
1
+ import { ReactElement } from "react";
2
+ export default function GameWithLoader({ children }: {
3
+ children: ReactElement<{
4
+ onCanvasReady?: () => void;
5
+ }>;
6
+ }): import("react/jsx-runtime").JSX.Element;
@@ -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,2 @@
1
+ declare const LoadingSpinner: () => import("react/jsx-runtime").JSX.Element;
2
+ export default LoadingSpinner;
@@ -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: base.label, children: "Node ID" }), _jsx("input", { style: base.input, value: (_a = node.name) !== null && _a !== void 0 ? _a : "", onChange: e => updateNode(n => (Object.assign(Object.assign({}, n), { name: e.target.value }))) })] }), _jsxs("div", { style: base.section, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, children: [_jsx("div", { style: base.label, children: "Components" }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), base.btnDanger), onClick: deleteNode, children: "Delete Node" })] }), node.components && Object.entries(node.components).map(([key, comp]) => {
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 && (_jsxs("div", { children: [_jsx("div", { style: base.label, children: "Add Component" }), _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
- }, children: "+" })] })] }))] });
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
- component: any;
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" }), _jsx("div", { style: { maxHeight: 128, overflowY: 'auto' }, children: _jsx(TextureListViewer, { files: textureFiles, selected: component.properties.texture || undefined, onSelect: file => onUpdate({ texture: file }), 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 => {
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" }), _jsx("div", { style: { maxHeight: 128, overflowY: 'auto' }, children: _jsx(ModelListViewer, { files: modelFiles, selected: component.properties.filename ? `/${component.properties.filename}` : undefined, onSelect: handleModelSelect, basePath: basePath }) })] }), _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
+ 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 }) {
@@ -24,7 +24,6 @@ export const base = {
24
24
  color: colors.text,
25
25
  border: `1px solid ${colors.border}`,
26
26
  borderRadius: 4,
27
- overflow: 'hidden',
28
27
  backdropFilter: 'blur(8px)',
29
28
  fontFamily: fonts.family,
30
29
  fontSize: fonts.size,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.38",
3
+ "version": "0.0.40",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -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
@@ -20,6 +20,7 @@ export {
20
20
  SoundListViewer,
21
21
  SharedCanvas,
22
22
  } from './tools/assetviewer/page';
23
+ export { sound as soundManager } from './helpers/SoundManager';
23
24
 
24
25
  // Helpers
25
26
  export * from './helpers';
@@ -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 }: { loader?: boolean, children: React.ReactNode, props?: WebGPURendererParameters }) {
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
- ...props,
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, (f) => {
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: 'fixed',
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 ID */}
114
+ {/* Node Name */}
115
115
  <div style={base.section}>
116
- <div style={base.label}>Node ID</div>
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
- component: any;
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
- <TextureListViewer
62
- files={textureFiles}
63
- selected={component.properties.texture || undefined}
64
- onSelect={file => onUpdate({ texture: file })}
65
- basePath={basePath}
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
- <ModelListViewer
28
- files={modelFiles}
29
- selected={component.properties.filename ? `/${component.properties.filename}` : undefined}
30
- onSelect={handleModelSelect}
31
- basePath={basePath}
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 }}>
@@ -27,7 +27,6 @@ export const base = {
27
27
  color: colors.text,
28
28
  border: `1px solid ${colors.border}`,
29
29
  borderRadius: 4,
30
- overflow: 'hidden',
31
30
  backdropFilter: 'blur(8px)',
32
31
  fontFamily: fonts.family,
33
32
  fontSize: fonts.size,