refacto: cleanning the codebase

This commit is contained in:
2026-04-17 16:03:29 +02:00
parent 638022339e
commit f9c4495610
17 changed files with 317 additions and 76 deletions
+5 -2
View File
@@ -1,3 +1,4 @@
import { Suspense } from "react";
import { Canvas } from "@react-three/fiber";
import { Crosshair } from "@/components/ui/Crosshair";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
@@ -8,8 +9,10 @@ function App(): React.JSX.Element {
return (
<>
<Canvas camera={{ position: [85, 60, 85], fov: 42 }} shadows>
<World />
<DebugPerf />
<Suspense fallback={null}>
<World />
<DebugPerf />
</Suspense>
</Canvas>
<Crosshair />
<InteractPrompt />
+4 -5
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useState } from "react";
import { useGLTF } from "@react-three/drei";
import { RigidBody } from "@react-three/rapier";
import { InteractableObject } from "@/components/3d/InteractableObject";
@@ -50,7 +50,6 @@ export function TriggerObject({
spawnOffset = TRIGGER_DEFAULT_SPAWN_OFFSET,
}: TriggerObjectProps): React.JSX.Element {
const [spawned, setSpawned] = useState<SpawnedModel[]>([]);
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,
+2 -2
View File
@@ -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;
+5 -3
View File
@@ -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 (
<div className="interact-prompt" aria-live="polite">
<kbd className="interact-prompt__key">E</kbd>
<kbd className="interact-prompt__key">{INTERACT_KEY.toUpperCase()}</kbd>
<span className="interact-prompt__label">{focused.label}</span>
</div>
);
+11
View File
@@ -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;
+1 -10
View File
@@ -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;
+16 -5
View File
@@ -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]);
}
+15 -11
View File
@@ -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<InteractionSnapshot>(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<T>(
selector: (state: InteractionSnapshot) => T,
): T {
return useSyncExternalStore(manager.subscribe.bind(manager), () =>
selector(manager.getState()),
);
}
+40 -2
View File
@@ -1,5 +1,8 @@
export class AudioManager {
private static _instance: AudioManager | null = null;
private readonly _audioPools = new Map<string, HTMLAudioElement[]>();
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;
}
}
+6 -7
View File
@@ -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;
}
+23 -9
View File
@@ -7,7 +7,7 @@ export class Debug {
public readonly active: boolean;
private readonly gui: GUI | null;
private readonly folders = new Map<string, GUI>();
private readonly registeredFolders = new Set<string>();
private readonly folderRefCounts = new Map<string, number>();
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);
+14 -4
View File
@@ -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 (
<OrbitControls
enableDamping
dampingFactor={0.05}
minDistance={100}
maxDistance={1000}
target={[0, 1.75, 0]}
dampingFactor={DEBUG_CAMERA_DAMPING_FACTOR}
minDistance={DEBUG_CAMERA_MIN_DISTANCE}
maxDistance={DEBUG_CAMERA_MAX_DISTANCE}
target={[PLAYER_SPAWN_X, PLAYER_EYE_HEIGHT, PLAYER_SPAWN_Z]}
/>
);
}
+16 -3
View File
@@ -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 (
<>
<gridHelper
args={[180, 36, "#1d4ed8", "#1e293b"]}
position={[0, 0.01, 0]}
args={[
DEBUG_GRID_SIZE,
DEBUG_GRID_DIVISIONS,
DEBUG_GRID_PRIMARY_COLOR,
DEBUG_GRID_SECONDARY_COLOR,
]}
position={[0, DEBUG_GRID_Y, 0]}
/>
<axesHelper args={[10]} />
<axesHelper args={[DEBUG_AXES_SIZE]} />
</>
);
}
+3 -10
View File
@@ -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 <primitive attach="background" object={texture} />;
}
export function Environment(): React.JSX.Element {
const sceneMode = useSceneMode();
@@ -21,5 +14,5 @@ export function Environment(): React.JSX.Element {
);
}
return <SkyBox />;
return <DreiEnvironment background files={GAME_SCENE_SKYBOX_PATH} />;
}