refacto: cleanning the codebase
This commit is contained in:
+5
-2
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user