react-three-game 0.0.38 → 0.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -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";
@@ -2,6 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { 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([]);
@@ -30,19 +32,33 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }) {
30
32
  var _a, _b;
31
33
  const x = (_b = (_a = component.properties.repeatCount) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : 1;
32
34
  onUpdate({ repeatCount: [x, value] });
33
- } })] })] }))] }))] }));
35
+ } })] })] })), _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
36
  }
35
37
  ;
36
- import { useMemo } from 'react';
37
- import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace } from 'three';
38
38
  // View for Material component
39
39
  function MaterialComponentView({ properties, loadedTextures }) {
40
40
  var _a;
41
41
  const textureName = properties === null || properties === void 0 ? void 0 : properties.texture;
42
42
  const repeat = properties === null || properties === void 0 ? void 0 : properties.repeat;
43
43
  const repeatCount = properties === null || properties === void 0 ? void 0 : properties.repeatCount;
44
+ const generateMipmaps = (properties === null || properties === void 0 ? void 0 : properties.generateMipmaps) !== false;
45
+ const minFilter = (properties === null || properties === void 0 ? void 0 : properties.minFilter) || 'LinearMipmapLinearFilter';
46
+ const magFilter = (properties === null || properties === void 0 ? void 0 : properties.magFilter) || 'LinearFilter';
44
47
  const texture = textureName && loadedTextures ? loadedTextures[textureName] : undefined;
48
+ const minFilterMap = {
49
+ NearestFilter,
50
+ LinearFilter,
51
+ NearestMipmapNearestFilter,
52
+ NearestMipmapLinearFilter,
53
+ LinearMipmapNearestFilter,
54
+ LinearMipmapLinearFilter
55
+ };
56
+ const magFilterMap = {
57
+ NearestFilter,
58
+ LinearFilter
59
+ };
45
60
  const finalTexture = useMemo(() => {
61
+ var _a, _b;
46
62
  if (!texture)
47
63
  return undefined;
48
64
  const t = texture.clone();
@@ -56,9 +72,12 @@ function MaterialComponentView({ properties, loadedTextures }) {
56
72
  t.repeat.set(1, 1);
57
73
  }
58
74
  t.colorSpace = SRGBColorSpace;
75
+ t.generateMipmaps = generateMipmaps;
76
+ t.minFilter = (_a = minFilterMap[minFilter]) !== null && _a !== void 0 ? _a : LinearMipmapLinearFilter;
77
+ t.magFilter = (_b = magFilterMap[magFilter]) !== null && _b !== void 0 ? _b : LinearFilter;
59
78
  t.needsUpdate = true;
60
79
  return t;
61
- }, [texture, repeat, repeatCount === null || repeatCount === void 0 ? void 0 : repeatCount[0], repeatCount === null || repeatCount === void 0 ? void 0 : repeatCount[1]]);
80
+ }, [texture, repeat, repeatCount === null || repeatCount === void 0 ? void 0 : repeatCount[0], repeatCount === null || repeatCount === void 0 ? void 0 : repeatCount[1], generateMipmaps, minFilter, magFilter]);
62
81
  if (!properties) {
63
82
  return _jsx("meshStandardMaterial", { color: "red", wireframe: true });
64
83
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { ModelListViewer } 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
7
  useEffect(() => {
8
8
  const base = basePath ? `${basePath}/` : '';
@@ -16,7 +16,7 @@ function ModelComponentEditor({ component, onUpdate, basePath = "" }) {
16
16
  const filename = file.startsWith('/') ? file.slice(1) : file;
17
17
  onUpdate({ 'filename': filename });
18
18
  };
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" })] })] });
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 }, 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
20
  }
21
21
  // View for Model component
22
22
  function ModelComponentView({ properties, loadedModels, children }) {
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.39",
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}
@@ -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";
@@ -2,6 +2,22 @@ import { 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[]>([]);
@@ -100,23 +116,76 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { compo
100
116
  </div>
101
117
  </div>
102
118
  )}
119
+
120
+ <div style={{ marginTop: 4 }}>
121
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
122
+ <input
123
+ type="checkbox"
124
+ style={{ width: 12, height: 12 }}
125
+ checked={component.properties.generateMipmaps !== false}
126
+ onChange={e => onUpdate({ generateMipmaps: e.target.checked })}
127
+ />
128
+ <label style={{ fontSize: '9px', color: 'rgba(34, 211, 238, 0.6)' }}>Generate Mipmaps</label>
129
+ </div>
130
+
131
+ <div>
132
+ <Label>Min Filter</Label>
133
+ <select
134
+ style={{ ...textInputStyle, width: '100%', cursor: 'pointer' }}
135
+ value={component.properties.minFilter || 'LinearMipmapLinearFilter'}
136
+ onChange={e => onUpdate({ minFilter: e.target.value })}
137
+ >
138
+ <option value="NearestFilter">Nearest</option>
139
+ <option value="NearestMipmapNearestFilter">Nearest Mipmap Nearest</option>
140
+ <option value="NearestMipmapLinearFilter">Nearest Mipmap Linear</option>
141
+ <option value="LinearFilter">Linear</option>
142
+ <option value="LinearMipmapNearestFilter">Linear Mipmap Nearest</option>
143
+ <option value="LinearMipmapLinearFilter">Linear Mipmap Linear (Default)</option>
144
+ </select>
145
+ </div>
146
+
147
+ <div style={{ marginTop: 4 }}>
148
+ <Label>Mag Filter</Label>
149
+ <select
150
+ style={{ ...textInputStyle, width: '100%', cursor: 'pointer' }}
151
+ value={component.properties.magFilter || 'LinearFilter'}
152
+ onChange={e => onUpdate({ magFilter: e.target.value })}
153
+ >
154
+ <option value="NearestFilter">Nearest</option>
155
+ <option value="LinearFilter">Linear (Default)</option>
156
+ </select>
157
+ </div>
158
+ </div>
103
159
  </div>
104
160
  )}
105
161
  </div>
106
162
  );
107
163
  };
108
164
 
109
-
110
- import { useMemo } from 'react';
111
- import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace, Texture } from 'three';
112
-
113
165
  // View for Material component
114
166
  function MaterialComponentView({ properties, loadedTextures }: { properties: any, loadedTextures?: Record<string, Texture> }) {
115
167
  const textureName = properties?.texture;
116
168
  const repeat = properties?.repeat;
117
169
  const repeatCount = properties?.repeatCount;
170
+ const generateMipmaps = properties?.generateMipmaps !== false;
171
+ const minFilter = properties?.minFilter || 'LinearMipmapLinearFilter';
172
+ const magFilter = properties?.magFilter || 'LinearFilter';
118
173
  const texture = textureName && loadedTextures ? loadedTextures[textureName] : undefined;
119
174
 
175
+ const minFilterMap: Record<string, MinificationTextureFilter> = {
176
+ NearestFilter,
177
+ LinearFilter,
178
+ NearestMipmapNearestFilter,
179
+ NearestMipmapLinearFilter,
180
+ LinearMipmapNearestFilter,
181
+ LinearMipmapLinearFilter
182
+ };
183
+
184
+ const magFilterMap: Record<string, MagnificationTextureFilter> = {
185
+ NearestFilter,
186
+ LinearFilter
187
+ };
188
+
120
189
  const finalTexture = useMemo(() => {
121
190
  if (!texture) return undefined;
122
191
  const t = texture.clone();
@@ -128,9 +197,12 @@ function MaterialComponentView({ properties, loadedTextures }: { properties: any
128
197
  t.repeat.set(1, 1);
129
198
  }
130
199
  t.colorSpace = SRGBColorSpace;
200
+ t.generateMipmaps = generateMipmaps;
201
+ t.minFilter = minFilterMap[minFilter] ?? LinearMipmapLinearFilter;
202
+ t.magFilter = magFilterMap[magFilter] ?? LinearFilter;
131
203
  t.needsUpdate = true;
132
204
  return t;
133
- }, [texture, repeat, repeatCount?.[0], repeatCount?.[1]]);
205
+ }, [texture, repeat, repeatCount?.[0], repeatCount?.[1], generateMipmaps, minFilter, magFilter]);
134
206
 
135
207
  if (!properties) {
136
208
  return <meshStandardMaterial color="red" wireframe />;
@@ -2,8 +2,9 @@ import { ModelListViewer } 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[]>([]);
8
9
 
9
10
  useEffect(() => {
@@ -25,6 +26,7 @@ function ModelComponentEditor({ component, onUpdate, basePath = "" }: { componen
25
26
  <Label>Model</Label>
26
27
  <div style={{ maxHeight: 128, overflowY: 'auto' }}>
27
28
  <ModelListViewer
29
+ key={node?.id}
28
30
  files={modelFiles}
29
31
  selected={component.properties.filename ? `/${component.properties.filename}` : undefined}
30
32
  onSelect={handleModelSelect}