([]);
- const positionRef = useRef(position);
return (
<>
@@ -66,9 +65,9 @@ export function TriggerObject({
if (spawnModel) {
const spawnPos: [number, number, number] = [
- positionRef.current[0] + spawnOffset[0],
- positionRef.current[1] + spawnOffset[1],
- positionRef.current[2] + spawnOffset[2],
+ position[0] + spawnOffset[0],
+ position[1] + spawnOffset[1],
+ position[2] + spawnOffset[2],
];
setSpawned((prev) => [
...prev,
diff --git a/src/components/ui/Crosshair.tsx b/src/components/ui/Crosshair.tsx
index dae485e..73f31b5 100644
--- a/src/components/ui/Crosshair.tsx
+++ b/src/components/ui/Crosshair.tsx
@@ -1,9 +1,9 @@
import { useCameraMode } from "@/hooks/debug/useCameraMode";
-import { useInteraction } from "@/hooks/useInteraction";
+import { useInteractionSelector } from "@/hooks/useInteraction";
export function Crosshair(): React.JSX.Element | null {
const cameraMode = useCameraMode();
- const { focused } = useInteraction();
+ const focused = useInteractionSelector((state) => state.focused);
if (cameraMode !== "player") return null;
diff --git a/src/components/ui/InteractPrompt.tsx b/src/components/ui/InteractPrompt.tsx
index 8fff76d..8acb5c3 100644
--- a/src/components/ui/InteractPrompt.tsx
+++ b/src/components/ui/InteractPrompt.tsx
@@ -1,16 +1,18 @@
+import { INTERACT_KEY } from "@/data/keybindings";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
-import { useInteraction } from "@/hooks/useInteraction";
+import { useInteractionSelector } from "@/hooks/useInteraction";
export function InteractPrompt(): React.JSX.Element | null {
const cameraMode = useCameraMode();
- const { focused, holding } = useInteraction();
+ const focused = useInteractionSelector((state) => state.focused);
+ const holding = useInteractionSelector((state) => state.holding);
if (cameraMode !== "player") return null;
if (!focused || holding || focused.kind !== "trigger") return null;
return (
- E
+ {INTERACT_KEY.toUpperCase()}
{focused.label}
);
diff --git a/src/data/debugConfig.ts b/src/data/debugConfig.ts
index 946b8f6..6f5e4fc 100644
--- a/src/data/debugConfig.ts
+++ b/src/data/debugConfig.ts
@@ -3,3 +3,14 @@ export const INTERACTION_DEBUG_SPHERE_COLOR = "#facc15";
export const INTERACTION_DEBUG_SPHERE_OPACITY = 0.25;
export const MAP_DEBUG_BOX_HELPER_COLOR = 0x00ff88;
+
+export const DEBUG_CAMERA_DAMPING_FACTOR = 0.05;
+export const DEBUG_CAMERA_MIN_DISTANCE = 100;
+export const DEBUG_CAMERA_MAX_DISTANCE = 1000;
+
+export const DEBUG_GRID_SIZE = 180;
+export const DEBUG_GRID_DIVISIONS = 36;
+export const DEBUG_GRID_PRIMARY_COLOR = "#1d4ed8";
+export const DEBUG_GRID_SECONDARY_COLOR = "#1e293b";
+export const DEBUG_GRID_Y = 0.01;
+export const DEBUG_AXES_SIZE = 10;
diff --git a/src/data/environmentConfig.ts b/src/data/environmentConfig.ts
index 36c7dd9..fe277fa 100644
--- a/src/data/environmentConfig.ts
+++ b/src/data/environmentConfig.ts
@@ -1,11 +1,2 @@
+export const GAME_SCENE_SKYBOX_PATH = "/skybox/sky.exr";
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
-
-// CubeTextureLoader face order: +X, -X, +Y, -Y, +Z, -Z
-export const SKYBOX_FACES = [
- "/skybox/px.jpg",
- "/skybox/nx.jpg",
- "/skybox/py.jpg",
- "/skybox/ny.jpg",
- "/skybox/pz.jpg",
- "/skybox/nz.jpg",
-] as const;
diff --git a/src/hooks/debug/useDebugFolder.ts b/src/hooks/debug/useDebugFolder.ts
index 7c19e96..ce51f1a 100644
--- a/src/hooks/debug/useDebugFolder.ts
+++ b/src/hooks/debug/useDebugFolder.ts
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
import type GUI from "lil-gui";
import { Debug } from "@/utils/debug/Debug";
@@ -6,12 +6,23 @@ export function useDebugFolder(
name: string,
setup: (folder: GUI) => void,
): void {
+ const setupRef = useRef(setup);
+
+ useEffect(() => {
+ setupRef.current = setup;
+ }, [setup]);
+
useEffect(() => {
const debug = Debug.getInstance();
if (!debug.active) return;
+
const folder = debug.createFolder(name);
- if (!folder) return;
- setup(folder);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ if (folder) {
+ setupRef.current(folder);
+ }
+
+ return () => {
+ debug.destroyFolder(name);
+ };
+ }, [name]);
}
diff --git a/src/hooks/useInteraction.ts b/src/hooks/useInteraction.ts
index 503c2a6..79582ff 100644
--- a/src/hooks/useInteraction.ts
+++ b/src/hooks/useInteraction.ts
@@ -1,18 +1,22 @@
-import { useEffect, useState } from "react";
+import { useSyncExternalStore } from "react";
import {
InteractionManager,
type InteractionSnapshot,
} from "@/stateManager/InteractionManager";
+const manager = InteractionManager.getInstance();
+
export function useInteraction(): InteractionSnapshot {
- const manager = InteractionManager.getInstance();
- const [state, setState] = useState(manager.getState());
-
- useEffect(() => {
- return manager.subscribe(() => {
- setState({ ...manager.getState() });
- });
- }, [manager]);
-
- return state;
+ return useSyncExternalStore(
+ manager.subscribe.bind(manager),
+ manager.getState.bind(manager),
+ );
+}
+
+export function useInteractionSelector(
+ selector: (state: InteractionSnapshot) => T,
+): T {
+ return useSyncExternalStore(manager.subscribe.bind(manager), () =>
+ selector(manager.getState()),
+ );
}
diff --git a/src/stateManager/AudioManager.ts b/src/stateManager/AudioManager.ts
index 7d7d418..5049dd6 100644
--- a/src/stateManager/AudioManager.ts
+++ b/src/stateManager/AudioManager.ts
@@ -1,5 +1,8 @@
export class AudioManager {
private static _instance: AudioManager | null = null;
+ private readonly _audioPools = new Map();
+
+ private static readonly MAX_POOL_SIZE_PER_SOUND = 6;
static getInstance(): AudioManager {
if (!AudioManager._instance) {
@@ -12,12 +15,47 @@ export class AudioManager {
private constructor() {}
playSound(path: string, volume = 1): void {
- const audio = new Audio(path);
+ const audio = this._acquireAudio(path);
audio.volume = Math.max(0, Math.min(1, volume));
- void audio.play();
+ audio.currentTime = 0;
+
+ void audio.play().catch(() => {
+ audio.pause();
+ audio.currentTime = 0;
+ });
}
destroy(): void {
+ this._audioPools.forEach((pool) => {
+ pool.forEach((audio) => {
+ audio.pause();
+ audio.src = "";
+ });
+ });
+ this._audioPools.clear();
AudioManager._instance = null;
}
+
+ private _acquireAudio(path: string): HTMLAudioElement {
+ const existingPool = this._audioPools.get(path);
+
+ if (existingPool) {
+ const availableAudio = existingPool.find(
+ (audio) => audio.paused || audio.ended,
+ );
+ if (availableAudio) return availableAudio;
+
+ if (existingPool.length < AudioManager.MAX_POOL_SIZE_PER_SOUND) {
+ const pooledAudio = new Audio(path);
+ existingPool.push(pooledAudio);
+ return pooledAudio;
+ }
+
+ return existingPool[0]!;
+ }
+
+ const initialAudio = new Audio(path);
+ this._audioPools.set(path, [initialAudio]);
+ return initialAudio;
+ }
}
diff --git a/src/stateManager/InteractionManager.ts b/src/stateManager/InteractionManager.ts
index 467a58c..1573997 100644
--- a/src/stateManager/InteractionManager.ts
+++ b/src/stateManager/InteractionManager.ts
@@ -39,12 +39,8 @@ export class InteractionManager {
setFocused(handle: InteractableHandle | null): void {
if (this._focused === handle) return;
- // Never interrupt an active grab via focus change
- if (this._holding) {
- this._focused = handle;
- this._emit();
- return;
- }
+ if (this._holding) return;
+
this._focused = handle;
this._emit();
}
@@ -59,7 +55,7 @@ export class InteractionManager {
}
releaseInteract(): void {
- const handle = this._holdingHandle ?? this._focused;
+ const handle = this._holding ? this._holdingHandle : null;
if (!handle) return;
handle.onRelease();
@@ -77,6 +73,9 @@ export class InteractionManager {
}
destroy(): void {
+ this._focused = null;
+ this._holding = false;
+ this._holdingHandle = null;
this._listeners.clear();
InteractionManager._instance = null;
}
diff --git a/src/utils/debug/Debug.ts b/src/utils/debug/Debug.ts
index ba98a2a..4860a23 100644
--- a/src/utils/debug/Debug.ts
+++ b/src/utils/debug/Debug.ts
@@ -7,7 +7,7 @@ export class Debug {
public readonly active: boolean;
private readonly gui: GUI | null;
private readonly folders = new Map();
- private readonly registeredFolders = new Set();
+ private readonly folderRefCounts = new Map();
private readonly listeners = new Set<() => void>();
private readonly controls: {
cameraMode: CameraMode;
@@ -63,27 +63,41 @@ export class Debug {
}
/**
- * Creates a named GUI folder. Returns the folder on first call, null on
- * subsequent calls with the same name — callers should skip `.add()` when
- * null is returned to avoid duplicating controls under StrictMode double-mount.
+ * Acquires a named GUI folder. Returns the folder on first acquisition and null
+ * on subsequent acquisitions so callers only register controls once.
*/
createFolder(name: string): GUI | null {
if (!this.gui) return null;
- if (this.registeredFolders.has(name)) return null;
-
- this.registeredFolders.add(name);
-
const existing = this.folders.get(name);
- if (existing) return existing;
+ if (existing) {
+ this.folderRefCounts.set(name, (this.folderRefCounts.get(name) ?? 0) + 1);
+ return null;
+ }
const folder = this.gui.addFolder(name);
this.folders.set(name, folder);
+ this.folderRefCounts.set(name, 1);
return folder;
}
+ destroyFolder(name: string): void {
+ const folder = this.folders.get(name);
+ const refCount = this.folderRefCounts.get(name);
+ if (!folder || refCount === undefined) return;
+
+ if (refCount > 1) {
+ this.folderRefCounts.set(name, refCount - 1);
+ return;
+ }
+
+ folder.destroy();
+ this.folders.delete(name);
+ this.folderRefCounts.delete(name);
+ }
+
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
diff --git a/src/utils/debug/scene/DebugCameraControls.tsx b/src/utils/debug/scene/DebugCameraControls.tsx
index 85977a0..04efd92 100644
--- a/src/utils/debug/scene/DebugCameraControls.tsx
+++ b/src/utils/debug/scene/DebugCameraControls.tsx
@@ -1,13 +1,23 @@
import { OrbitControls } from "@react-three/drei";
+import {
+ DEBUG_CAMERA_DAMPING_FACTOR,
+ DEBUG_CAMERA_MAX_DISTANCE,
+ DEBUG_CAMERA_MIN_DISTANCE,
+} from "@/data/debugConfig";
+import {
+ PLAYER_EYE_HEIGHT,
+ PLAYER_SPAWN_X,
+ PLAYER_SPAWN_Z,
+} from "@/data/playerConfig";
export function DebugCameraControls(): React.JSX.Element {
return (
);
}
diff --git a/src/utils/debug/scene/DebugHelpers.tsx b/src/utils/debug/scene/DebugHelpers.tsx
index 5abf9a9..738c46e 100644
--- a/src/utils/debug/scene/DebugHelpers.tsx
+++ b/src/utils/debug/scene/DebugHelpers.tsx
@@ -1,3 +1,11 @@
+import {
+ DEBUG_AXES_SIZE,
+ DEBUG_GRID_DIVISIONS,
+ DEBUG_GRID_PRIMARY_COLOR,
+ DEBUG_GRID_SECONDARY_COLOR,
+ DEBUG_GRID_SIZE,
+ DEBUG_GRID_Y,
+} from "@/data/debugConfig";
import { Debug } from "@/utils/debug/Debug";
export function DebugHelpers(): React.JSX.Element | null {
@@ -10,10 +18,15 @@ export function DebugHelpers(): React.JSX.Element | null {
return (
<>
-
+
>
);
}
diff --git a/src/world/Environment.tsx b/src/world/Environment.tsx
index b314bf4..72c12b6 100644
--- a/src/world/Environment.tsx
+++ b/src/world/Environment.tsx
@@ -1,17 +1,10 @@
-import * as THREE from "three";
-import { useLoader } from "@react-three/fiber";
+import { Environment as DreiEnvironment } from "@react-three/drei";
import {
+ GAME_SCENE_SKYBOX_PATH,
PHYSICS_SCENE_BACKGROUND_COLOR,
- SKYBOX_FACES,
} from "@/data/environmentConfig";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
-function SkyBox(): React.JSX.Element {
- const texture = useLoader(THREE.CubeTextureLoader, [...SKYBOX_FACES]);
-
- return ;
-}
-
export function Environment(): React.JSX.Element {
const sceneMode = useSceneMode();
@@ -21,5 +14,5 @@ export function Environment(): React.JSX.Element {
);
}
- return ;
+ return ;
}