diff --git a/.agent/AGENTS.md b/.agent/AGENTS.md index 69b879e..d6e9dc6 100644 --- a/.agent/AGENTS.md +++ b/.agent/AGENTS.md @@ -31,7 +31,7 @@ Scene objects are **never** singleton classes. Managers are **never** React comp - Scene components live in `src/world/` and `src/components/3d/` - UI overlays live in `src/components/ui/` - Managers live in `src/stateManager/` -- Debug tooling lives in `src/debug/` +- Debug tooling lives in `src/utils/debug/` - Hooks live in `src/hooks/` - Static data lives in `src/data/` - Shaders live in `src/shaders/` @@ -55,9 +55,9 @@ import { useGameState } from "@/hooks/useGameState"; ### Debug - Debug panel activates with `?debug` in URL -- All debug logic goes through `Debug.getInstance()` from `src/debug/Debug.ts` +- All debug logic goes through `Debug.getInstance()` from `src/utils/debug/Debug.ts` - Never scatter `if (isDev)` blocks across files -- `r3f-perf` is lazy-loaded only in debug mode via `src/debug/DebugPerf.tsx` +- `r3f-perf` is lazy-loaded only in debug mode via `src/utils/debug/DebugPerf.tsx` ## Managers (4 max) diff --git a/.agent/skills/debug.md b/.agent/skills/debug.md index f000409..94ae992 100644 --- a/.agent/skills/debug.md +++ b/.agent/skills/debug.md @@ -13,7 +13,7 @@ The free debug camera is toggled from the debug panel, not mounted permanently. ## Debug singleton ```ts -// src/debug/Debug.ts +// src/utils/debug/Debug.ts import GUI from "lil-gui"; export class Debug { @@ -58,9 +58,9 @@ if (debug.active) { r3f-perf is loaded only in debug mode to avoid dependency issues in production: ```tsx -// src/debug/DebugPerf.tsx +// src/utils/debug/DebugPerf.tsx import { Suspense, lazy } from "react"; -import { Debug } from "@/debug/Debug"; +import { Debug } from "@/utils/debug/Debug"; const Perf = lazy(() => import("r3f-perf").then((m) => ({ default: m.Perf }))); diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cc775b3..44cec6b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,8 +3,12 @@ name: πŸ” Lint on: pull_request: types: [opened, synchronize, reopened] + branches: [develop, main] push: - branches: [main] + branches: + - main + - develop + workflow_dispatch: jobs: lint: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index c72f91c..66b47f7 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -3,8 +3,12 @@ name: πŸ“Š Quality on: pull_request: types: [opened, synchronize, reopened] + branches: [develop, main] push: - branches: [main] + branches: + - main + - develop + workflow_dispatch: jobs: security: diff --git a/README.md b/README.md index 96487db..40cee18 100644 --- a/README.md +++ b/README.md @@ -8,31 +8,31 @@ Built with React, Three.js, and Vite. Runs in the browser, no installation requi ### Build & Language -| Package | Doc | -| -------------------------------------------------- | ------------------------------------ | -| [TypeScript](https://www.typescriptlang.org/docs/) | https://www.typescriptlang.org/docs/ | -| [React](https://react.dev/learn) | https://react.dev/learn | -| [Vite](https://vite.dev/guide/) | https://vite.dev/guide/ | -| [ESLint](https://eslint.org/docs/latest/) | https://eslint.org/docs/latest/ | -| [Prettier](https://prettier.io/docs/) | https://prettier.io/docs/ | +| Package | +| -------------------------------------------------- | +| [TypeScript](https://www.typescriptlang.org/docs/) | +| [React](https://react.dev/learn) | +| [Vite](https://vite.dev/guide/) | +| [ESLint](https://eslint.org/docs/latest/) | +| [Prettier](https://prettier.io/docs/) | ### 3D Engine -| Package | Doc | -| ----------------------------------------------------------------------------------------- | ---------------------------------------------- | -| [Three.js](https://threejs.org/docs/) | https://threejs.org/docs/ | -| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) | https://docs.pmnd.rs/react-three-fiber | -| [@react-three/drei](https://pmndrs.github.io/drei) | https://pmndrs.github.io/drei | -| [@react-three/rapier](https://rapier.rs/docs/) | https://rapier.rs/docs/user_guides/javascript/ | -| [@react-three/postprocessing](https://github.com/pmndrs/postprocessing) | https://github.com/pmndrs/postprocessing | -| [GSAP](https://gsap.com/docs/v3/Installation/) | https://gsap.com/docs/v3/ | +| Package | +| ----------------------------------------------------------------------------------------- | +| [Three.js](https://threejs.org/docs/) | +| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) | +| [@react-three/drei](https://pmndrs.github.io/drei) | +| [@react-three/rapier](https://rapier.rs/docs/) | +| [@react-three/postprocessing](https://github.com/pmndrs/postprocessing) | +| [GSAP](https://gsap.com/docs/v3/Installation/) | ### Performance & Effects -| Package | Doc | -| --------------------------------------------------------------------------- | --------------------------------------------------------- | -| [r3f-perf](https://github.com/utsuboco/r3f-perf) | https://github.com/utsuboco/r3f-perf | -| [AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer) | https://threejs.org/docs/#api/en/animation/AnimationMixer | +| Package | +| --------------------------------------------------------------------------- | +| [r3f-perf](https://github.com/utsuboco/r3f-perf) | +| [AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer) | ## πŸ—‚ Project Structure @@ -98,16 +98,20 @@ la-fabrik/ β”‚ β”œβ”€β”€ vertex.glsl β”‚ └── fragment.glsl β”‚ - β”œβ”€β”€ debug/ # Dev-only tools and scene inspection - β”‚ β”œβ”€β”€ Debug.ts # Global lil-gui manager - β”‚ β”œβ”€β”€ DebugPerf.tsx # r3f-perf overlay mounted in Canvas - β”‚ └── scene/ - β”‚ β”œβ”€β”€ DebugHelpers.tsx # Grid + axes helpers shown in debug mode - β”‚ └── DebugCameraControls.tsx # Free debug camera for map inspection - β”‚ β”œβ”€β”€ utils/ - β”‚ β”œβ”€β”€ EventEmitter.ts # Simple pub/sub for manager-to-manager events - β”‚ └── Dispose.ts # traverse() + dispose() helper + β”‚ β”œβ”€β”€ EventEmitter.ts # Simple typed pub/sub utility + β”‚ β”œβ”€β”€ Sizes.ts # Viewport size tracking + β”‚ β”œβ”€β”€ Time.ts # Animation frame timing utility + β”‚ β”œβ”€β”€ Readme.md + β”‚ └── debug/ # Dev-only tools and scene inspection + β”‚ β”œβ”€β”€ Debug.ts # Global lil-gui manager + β”‚ β”œβ”€β”€ DebugPerf.tsx # r3f-perf overlay mounted in Canvas + β”œβ”€β”€ hooks/ + β”‚ └── debug/ + β”‚ └── useCameraMode.ts + β”‚ └── scene/ + β”‚ β”œβ”€β”€ DebugHelpers.tsx # Grid + axes helpers shown in debug mode + β”‚ └── DebugCameraControls.tsx # Free debug camera for map inspection β”‚ β”œβ”€β”€ App.tsx # Canvas bootstrap └── main.tsx @@ -122,12 +126,8 @@ npm install npm run dev ``` -Open `http://localhost:5173` β€” standard experience. -Open `http://localhost:5173?debug` β€” debug panel + r3f-perf overlay. The free debug camera is enabled from the debug panel. - -## 🧭 Conventions - -Coding conventions and generation rules live in `.agent/skills/best-practices.md`. +- `http://localhost:5173` for the app +- `http://localhost:5173?debug` to enable debug tooling ## πŸ“œ License diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index a3bc69b..97783d8 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -1,414 +1,43 @@ -# Architecture Patterns +# Implemented Architecture -Coding conventions are maintained in `.agent/skills/best-practices.md`. +This document describes the code that exists today in the repository. -The project uses **two complementary patterns**: +## Runtime Structure -- **Singleton service classes** for orchestration and side effects -- **Declarative React components** for all 3D scene objects +- `src/App.tsx` mounts the `Canvas`, the 3D `World`, the debug perf overlay, and the HTML crosshair overlay. +- `src/world/World.tsx` composes the active 3D scene. +- `src/world/Map.tsx` loads and centers the blocking map model. +- `src/world/Lighting.tsx` owns the current ambient and directional light setup. +- `src/world/Environment.tsx` owns the current background color. +- `src/world/player/FPSController.tsx` provides the current player camera, pointer lock, and `ZQSD` movement. +- `src/utils/debug/` contains debug-only tooling such as `lil-gui`, scene helpers, and the free debug camera. +- `src/components/ui/Crosshair.tsx` is the only current HTML overlay component in use. -This distinction is intentional. Scene elements such as the map, lights, environment, zones, and player are implemented as **React Three Fiber components** and mounted through ``. -Global systems such as gameplay flow, cinematics, audio, and debug tooling are implemented as **manager classes**. +## Camera Modes -Consistency matters, but the codebase does **not** force the same lifecycle pattern on scene components and global services. +The application currently has two camera modes: ---- +- `player` + - controlled by `FPSController` + - player height is `1.75m` + - movement uses `ZQSD` + - `E` is reserved for future interaction +- `debug` + - controlled by `DebugCameraControls` + - enabled from the debug panel -## 1. Singleton Pattern for Global Managers Only +The active mode is stored in the debug subsystem and consumed through `src/hooks/debug/useCameraMode.ts`. -Only cross-cutting services use the singleton pattern. +## Debug System -Examples: +- `src/utils/debug/Debug.ts` is a singleton wrapper around `lil-gui` +- `src/utils/debug/DebugPerf.tsx` lazy-loads `r3f-perf` +- `src/utils/debug/scene/DebugHelpers.tsx` mounts grid and axes in debug mode +- `src/utils/debug/scene/DebugCameraControls.tsx` mounts the free camera in debug mode -- `GameManager` -- `CinematicManager` -- `AudioManager` -- `ZoneManager` -- `Debug` -- `EventEmitter` +## Current Limitations -These services must exist once, be accessible from anywhere, and coordinate the experience globally. - -```ts -// stateManager/GameManager.ts -export class GameManager { - private static _instance: GameManager | null = null; - - cinematic!: CinematicManager; - audio!: AudioManager; - zone!: ZoneManager; - - static getInstance(): GameManager { - if (!GameManager._instance) { - GameManager._instance = new GameManager(); - } - return GameManager._instance; - } - - private constructor() { - this.cinematic = CinematicManager.getInstance(); - this.audio = AudioManager.getInstance(); - this.zone = ZoneManager.getInstance(); - } - - destroy(): void { - this.cinematic.destroy(); - this.audio.destroy(); - this.zone.destroy(); - GameManager._instance = null; - } -} -``` - -Usage: - -```ts -const game = GameManager.getInstance(); -game.startMission("workshop"); -``` - -**Important:** scene objects such as `Map`, `WorkshopZone`, `Lighting`, or `Environment` are **not** singletons and must remain standard React components. - ---- - -## 2. Scene Objects Are React Components, Not Manager Classes - -All 3D scene objects are implemented as **declarative React components**. - -This includes: - -- maps -- lights -- environments -- player controllers -- zones -- interactive props -- postprocessing layers - -This keeps the code aligned with the R3F runtime instead of rebuilding a parallel imperative engine. - -Example: - -```tsx -// world/zones/WorkshopZone.tsx -import { useEffect, useRef } from "react"; -import * as THREE from "three"; -import { useFrame } from "@react-three/fiber"; -import { useGLTF } from "@react-three/drei"; - -export function WorkshopZone() { - const root = useRef(null); - const gltf = useGLTF("/models/workshop/ebike.glb"); - const mixer = useRef(null); - - useEffect(() => { - mixer.current = new THREE.AnimationMixer(gltf.scene); - - return () => { - mixer.current?.stopAllAction(); - mixer.current = null; - }; - }, [gltf.scene]); - - useFrame((_, delta) => { - mixer.current?.update(delta); - }); - - return ; -} -``` - -Per-frame values such as movement, interpolation, camera smoothing, and physics must stay in: - -- `useRef` -- `useFrame` -- Rapier bodies -- other frame-based systems - -They must **never** go through React state. - ---- - -## 3. Single Source of Truth for Durable Gameplay State - -The project uses a single authoritative `GameManager` for durable gameplay state. - -React components subscribe to that state through thin hooks. -Other managers communicate through `GameManager`, which acts as the main gameplay orchestrator. - -High-frequency values such as movement, camera interpolation, or physics never go through React state and stay in refs or frame-based systems. - -```ts -// stateManager/GameManager.ts -type Phase = "loading" | "intro" | "exploring" | "cinematic" | "outro"; -type ZoneId = "workshop" | "powerGrid" | "farm" | null; - -type GameSnapshot = { - phase: Phase; - activeZone: ZoneId; - missionId: string | null; - missionStep: number; - inputLocked: boolean; - dialogueId: string | null; -}; - -export class GameManager { - private static _instance: GameManager | null = null; - private listeners = new Set<() => void>(); - - private state: GameSnapshot = { - phase: "loading", - activeZone: null, - missionId: null, - missionStep: 0, - inputLocked: false, - dialogueId: null, - }; - - static getInstance(): GameManager { - if (!GameManager._instance) { - GameManager._instance = new GameManager(); - } - return GameManager._instance; - } - - getState(): GameSnapshot { - return this.state; - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private emit(): void { - this.listeners.forEach((cb) => cb()); - } - - setPhase(phase: Phase): void { - this.state.phase = phase; - this.emit(); - } - - setActiveZone(zone: ZoneId): void { - this.state.activeZone = zone; - this.emit(); - } - - startMission(id: string): void { - this.state.missionId = id; - this.state.missionStep = 0; - this.emit(); - } -} -``` - -```ts -// hooks/useGameState.ts -import { useEffect, useState } from "react"; -import { GameManager } from "@/stateManager/GameManager"; - -export function useGameState() { - const game = GameManager.getInstance(); - const [state, setState] = useState(game.getState()); - - useEffect(() => { - return game.subscribe(() => { - setState({ ...game.getState() }); - }); - }, [game]); - - return state; -} -``` - -This keeps the architecture simple: - -- **GameManager** owns durable gameplay state -- **other managers** handle side effects -- **React components** render that state -- **R3F frame systems** handle fast-changing values - ---- - -## 4. Side Effects Stay in Specialized Managers - -Managers other than `GameManager` should not become secondary state stores. - -Their role is to manage side effects and specialized runtime logic, such as: - -- GSAP timelines -- audio playback -- zone entry detection -- interaction triggers -- camera lock/unlock -- temporary event coordination - -They can read from `GameManager`, react to its state, or notify it of important transitions. - -Example flow: - -``` -Component / Hook - ↓ -GameManager.getInstance() - β”œβ”€β”€ startMission('workshop') - β”œβ”€β”€ cinematic.play('intro_workshop') - β”œβ”€β”€ audio.playAmbience('workshop') - └── zone.setActive('workshop') -``` - -This keeps the dependency graph understandable while avoiding duplicated durable state. - ---- - -## 5. Memory Management β€” Dispose Only What You Own - -GPU memory must be cleaned carefully. - -However, the project does **not** blindly deep-dispose every object on unmount. -Only resources explicitly created and owned by the current component or manager should be disposed. - -This includes things like: - -- custom materials -- render targets -- postprocessing passes -- manually created geometries -- manually created textures -- temporary clones with owned resources - -Shared or cached assets must **not** be blindly disposed. - -```ts -// utils/Dispose.ts -import * as THREE from "three"; - -export class Dispose { - static material(material: THREE.Material): void { - for (const value of Object.values(material)) { - if (value instanceof THREE.Texture) { - value.dispose(); - } - } - material.dispose(); - } - - static mesh(mesh: THREE.Mesh): void { - mesh.geometry?.dispose(); - - const materials = Array.isArray(mesh.material) - ? mesh.material - : [mesh.material]; - - for (const material of materials) { - if (material) this.material(material); - } - } - - static renderTarget(rt: THREE.WebGLRenderTarget): void { - rt.texture.dispose(); - rt.dispose(); - } -} -``` - -Example usage: - -```ts -useEffect(() => { - const material = new THREE.ShaderMaterial({ - vertexShader, - fragmentShader, - }); - - meshRef.current.material = material; - - return () => { - Dispose.material(material); - }; -}, []); -``` - -**Rule:** disposal is ownership-based, not automatic and not blind. - ---- - -## 6. Debug System - -The debug panel can be activated by appending `?debug` to the URL: - -`http://localhost:5173?debug` - -All debug logic is centralized in `src/debug/`. -Do not scatter debug checks across the codebase. - -- `src/debug/Debug.ts` owns the global lil-gui singleton -- `src/debug/DebugPerf.tsx` mounts the perf overlay -- `src/debug/scene/*` contains debug-only R3F helpers such as free camera controls and axes/grid helpers - -`world/` stays focused on product scene components, while `debug/` contains developer tooling. - -```ts -// src/debug/Debug.ts -import GUI from "lil-gui"; - -export class Debug { - private static _instance: Debug | null = null; - - readonly active: boolean; - gui: GUI | null = null; - - static getInstance(): Debug { - if (!Debug._instance) Debug._instance = new Debug(); - return Debug._instance; - } - - private constructor() { - this.active = new URLSearchParams(window.location.search).has("debug"); - if (this.active) { - this.gui = new GUI({ title: "La-Fabrik Debug" }); - } - } - - destroy(): void { - this.gui?.destroy(); - Debug._instance = null; - } -} -``` - -Usage: - -```ts -const debug = Debug.getInstance(); - -if (debug.active) { - debug.gui!.add(params, "bloomIntensity", 0, 3).name("Bloom"); -} -``` - -Debug-only scene helpers should live outside `world/`: - -```tsx -// src/debug/scene/DebugHelpers.tsx -import { Debug } from "@/debug/Debug"; - -export function DebugHelpers(): React.JSX.Element | null { - const debug = Debug.getInstance(); - - if (!debug.active) { - return null; - } - - return ( - <> - - - - ); -} -``` +- There is no gameplay state manager implemented yet. +- There are no zone systems, missions, dialogue systems, or cinematic systems implemented yet. +- Player movement currently uses a simple height clamp instead of real collision or ground detection. +- The map is currently a blocking preview scene, not a full playable world. diff --git a/docs/technical/target-architecture.md b/docs/technical/target-architecture.md new file mode 100644 index 0000000..38b1905 --- /dev/null +++ b/docs/technical/target-architecture.md @@ -0,0 +1,61 @@ +# Target Architecture + +This document describes the intended medium-term architecture for the project. + +## Goals + +- Keep `main` stable, `develop` as the integration branch, and `feat/*` for feature work. +- Keep the runtime split between scene composition, gameplay systems, debug tooling, and HTML UI. +- Keep one clear source of truth per concern. + +## Intended Layers + +### App Layer + +- `App.tsx` should stay small and orchestration-oriented. +- It should mount the canvas scene and top-level HTML overlays. + +### World Layer + +- `src/world/` should contain only production scene objects and scene composition. +- Expected responsibilities: + - world composition + - map/environment/lighting + - player controller + - zones + - post-processing used in production + +### Debug Layer + +- `src/utils/debug/` should contain only developer tooling. +- Expected responsibilities: + - `lil-gui` + - performance overlay + - scene helpers + - free camera and calibration controls + +### UI Layer + +- `src/components/ui/` should contain HTML overlays used by the player. +- Expected examples: + - crosshair + - loading screen + - mission HUD + - narrative overlays + +### Gameplay Layer + +- Gameplay state should eventually live in dedicated managers and thin hooks once those systems exist. +- Expected future concerns: + - missions + - zones + - cinematics + - audio + - interactions + +## Rules + +- `world/` should not contain debug-only tooling. +- `debug/` should not own production gameplay systems. +- Shared types should live close to their domain and move outward only when they gain multiple real consumers. +- New files should only be created when they have an active runtime purpose. diff --git a/docs/user/features.md b/docs/user/features.md index ce78679..8334c37 100644 --- a/docs/user/features.md +++ b/docs/user/features.md @@ -1,3 +1,44 @@ -# Features +# Implemented Features -TODO: Documenter les fonctionnalitΓ©s du jeu. +This document lists features that are actually implemented in the current codebase. + +## Scene Preview + +- Fullscreen React Three Fiber scene +- Blocking map loaded from `public/models/map/blocking/model.glb` +- Ambient and directional lighting +- Solid background environment color + +## Camera Modes + +- Player camera mode + - eye height at `1.75m` + - pointer lock mouse look + - movement with `ZQSD` + - vertical clamp to prevent falling below the map plane +- Debug camera mode + - free orbit camera + - switchable from the debug panel + +## UI + +- Center-screen crosshair shown only in player mode + +## Debug Tooling + +- `?debug` query param enables the debug panel +- `lil-gui` panel with camera mode selection +- debug lighting controls +- debug scene helpers +- `r3f-perf` overlay + +## Not Implemented Yet + +- missions +- interactions on `E` +- gameplay zones +- cinematics +- audio systems +- loading flow +- minimap and mission HUD +- collisions beyond the current simple player height clamp diff --git a/public/models/environment/README.md b/public/models/environment/README.md deleted file mode 100644 index 4a1490d..0000000 --- a/public/models/environment/README.md +++ /dev/null @@ -1 +0,0 @@ -# public/models/environment/\* diff --git a/public/models/farm/README.md b/public/models/farm/README.md deleted file mode 100644 index 97a0558..0000000 --- a/public/models/farm/README.md +++ /dev/null @@ -1 +0,0 @@ -# public/models/farm/\* diff --git a/public/models/general/README.md b/public/models/general/README.md deleted file mode 100644 index a7b53a8..0000000 --- a/public/models/general/README.md +++ /dev/null @@ -1 +0,0 @@ -# public/models/general/\* diff --git a/public/models/powerGrid/README.md b/public/models/powerGrid/README.md deleted file mode 100644 index 0e17831..0000000 --- a/public/models/powerGrid/README.md +++ /dev/null @@ -1 +0,0 @@ -# public/models/powergrid/\* diff --git a/public/models/workshop/README.md b/public/models/workshop/README.md deleted file mode 100644 index 3eac41d..0000000 --- a/public/models/workshop/README.md +++ /dev/null @@ -1 +0,0 @@ -# public/models/workshop/\* diff --git a/public/sounds/README.md b/public/sounds/README.md deleted file mode 100644 index 6526135..0000000 --- a/public/sounds/README.md +++ /dev/null @@ -1 +0,0 @@ -# public/sounds/\* diff --git a/public/textures/README.md b/public/textures/README.md deleted file mode 100644 index e8c8b42..0000000 --- a/public/textures/README.md +++ /dev/null @@ -1 +0,0 @@ -# public/textures/\* diff --git a/src/App.tsx b/src/App.tsx index 6b30f8e..f0f5d32 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { Canvas } from "@react-three/fiber"; import { Crosshair } from "@/components/ui/Crosshair"; -import { DebugPerf } from "@/debug/DebugPerf"; +import { DebugPerf } from "@/utils/debug/DebugPerf"; import { World } from "@/world/World"; function App(): React.JSX.Element { diff --git a/src/components/3d/InteractiveObject.tsx b/src/components/3d/InteractiveObject.tsx deleted file mode 100644 index b07fb2f..0000000 --- a/src/components/3d/InteractiveObject.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/components/3d/InteractiveObject.tsx diff --git a/src/components/ui/CinematicBars.tsx b/src/components/ui/CinematicBars.tsx deleted file mode 100644 index 5c38ed7..0000000 --- a/src/components/ui/CinematicBars.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/components/ui/CinematicBars.tsx diff --git a/src/components/ui/Crosshair.tsx b/src/components/ui/Crosshair.tsx index e43affd..1b4b54f 100644 --- a/src/components/ui/Crosshair.tsx +++ b/src/components/ui/Crosshair.tsx @@ -1,4 +1,4 @@ -import { useCameraMode } from "@/debug/useCameraMode"; +import { useCameraMode } from "@/hooks/debug/useCameraMode"; export function Crosshair(): React.JSX.Element | null { const cameraMode = useCameraMode(); diff --git a/src/components/ui/LoadingScreen.tsx b/src/components/ui/LoadingScreen.tsx deleted file mode 100644 index 5f4726f..0000000 --- a/src/components/ui/LoadingScreen.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/components/ui/LoadingScreen.tsx diff --git a/src/components/ui/MapHUD.tsx b/src/components/ui/MapHUD.tsx deleted file mode 100644 index 899f697..0000000 --- a/src/components/ui/MapHUD.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/components/ui/MapHUD.tsx diff --git a/src/components/ui/MissionHUD.tsx b/src/components/ui/MissionHUD.tsx deleted file mode 100644 index 3fad028..0000000 --- a/src/components/ui/MissionHUD.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/components/ui/MissionHUD.tsx diff --git a/src/components/ui/NarrativeOverlay.tsx b/src/components/ui/NarrativeOverlay.tsx deleted file mode 100644 index d7cd33b..0000000 --- a/src/components/ui/NarrativeOverlay.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/components/ui/NarrativeOverlay.tsx diff --git a/src/data/dialogues.ts b/src/data/dialogues.ts deleted file mode 100644 index a7c6fdb..0000000 --- a/src/data/dialogues.ts +++ /dev/null @@ -1 +0,0 @@ -// src/data/dialogues.ts diff --git a/src/data/missions.ts b/src/data/missions.ts deleted file mode 100644 index 120ae59..0000000 --- a/src/data/missions.ts +++ /dev/null @@ -1 +0,0 @@ -// src/data/missions.ts diff --git a/src/data/zones.ts b/src/data/zones.ts deleted file mode 100644 index 8cf5f8f..0000000 --- a/src/data/zones.ts +++ /dev/null @@ -1 +0,0 @@ -// src/data/zones.ts diff --git a/src/debug/useCameraMode.ts b/src/hooks/debug/useCameraMode.ts similarity index 74% rename from src/debug/useCameraMode.ts rename to src/hooks/debug/useCameraMode.ts index 3ef7d5a..31ee4dc 100644 --- a/src/debug/useCameraMode.ts +++ b/src/hooks/debug/useCameraMode.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from "react"; -import type { CameraMode } from "@/debug/Debug"; -import { Debug } from "@/debug/Debug"; +import type { CameraMode } from "@/types/debug"; +import { Debug } from "@/utils/debug/Debug"; export function useCameraMode(): CameraMode { const debug = Debug.getInstance(); diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts deleted file mode 100644 index ba6c821..0000000 --- a/src/hooks/useAudio.ts +++ /dev/null @@ -1 +0,0 @@ -// src/hooks/useAudio.ts diff --git a/src/hooks/useCinematic.ts b/src/hooks/useCinematic.ts deleted file mode 100644 index cb38a60..0000000 --- a/src/hooks/useCinematic.ts +++ /dev/null @@ -1 +0,0 @@ -// src/hooks/useCinematic.ts diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts deleted file mode 100644 index 8439ec4..0000000 --- a/src/hooks/useGameState.ts +++ /dev/null @@ -1 +0,0 @@ -// src/hooks/useGameState.ts diff --git a/src/hooks/useInteraction.ts b/src/hooks/useInteraction.ts deleted file mode 100644 index e4858fd..0000000 --- a/src/hooks/useInteraction.ts +++ /dev/null @@ -1 +0,0 @@ -// src/hooks/useInteraction.ts diff --git a/src/hooks/useLOD.ts b/src/hooks/useLOD.ts deleted file mode 100644 index a2cf4ce..0000000 --- a/src/hooks/useLOD.ts +++ /dev/null @@ -1 +0,0 @@ -// src/hooks/useLOD.ts diff --git a/src/hooks/useZoneDetection.ts b/src/hooks/useZoneDetection.ts deleted file mode 100644 index edf9264..0000000 --- a/src/hooks/useZoneDetection.ts +++ /dev/null @@ -1 +0,0 @@ -// src/hooks/useZoneDetection.ts diff --git a/src/stateManager/AudioManager.ts b/src/stateManager/AudioManager.ts deleted file mode 100644 index 0b2be9a..0000000 --- a/src/stateManager/AudioManager.ts +++ /dev/null @@ -1 +0,0 @@ -// src/stateManager/AudioManager.ts diff --git a/src/stateManager/CinematicManager.ts b/src/stateManager/CinematicManager.ts deleted file mode 100644 index 80afdd6..0000000 --- a/src/stateManager/CinematicManager.ts +++ /dev/null @@ -1 +0,0 @@ -// src/stateManager/CinematicManager.ts diff --git a/src/stateManager/GameManager.ts b/src/stateManager/GameManager.ts deleted file mode 100644 index c847c00..0000000 --- a/src/stateManager/GameManager.ts +++ /dev/null @@ -1 +0,0 @@ -// src/stateManager/GameManager.ts diff --git a/src/stateManager/ZoneManager.ts b/src/stateManager/ZoneManager.ts deleted file mode 100644 index c0b83dd..0000000 --- a/src/stateManager/ZoneManager.ts +++ /dev/null @@ -1 +0,0 @@ -// src/stateManager/ZoneManager.ts diff --git a/src/types/debug.ts b/src/types/debug.ts new file mode 100644 index 0000000..2528812 --- /dev/null +++ b/src/types/debug.ts @@ -0,0 +1 @@ +export type CameraMode = "player" | "debug"; diff --git a/src/utils/Dispose.ts b/src/utils/Dispose.ts deleted file mode 100644 index 46df606..0000000 --- a/src/utils/Dispose.ts +++ /dev/null @@ -1 +0,0 @@ -// src/utils/Dispose.ts diff --git a/src/utils/EventEmitter.ts b/src/utils/EventEmitter.ts index d151711..52c73a9 100644 --- a/src/utils/EventEmitter.ts +++ b/src/utils/EventEmitter.ts @@ -1 +1,54 @@ -// src/utils/EventEmitter.ts +type Listener = (payload: TPayload) => void; + +export class EventEmitter> { + private readonly listeners = new Map< + keyof TEvents, + Set> + >(); + + on( + event: TKey, + listener: Listener, + ): () => void { + const currentListeners = this.listeners.get(event) ?? new Set(); + currentListeners.add(listener as Listener); + this.listeners.set(event, currentListeners); + + return () => { + this.off(event, listener); + }; + } + + off( + event: TKey, + listener: Listener, + ): void { + const currentListeners = this.listeners.get(event); + + if (!currentListeners) { + return; + } + + currentListeners.delete(listener as Listener); + + if (currentListeners.size === 0) { + this.listeners.delete(event); + } + } + + emit(event: TKey, payload: TEvents[TKey]): void { + const currentListeners = this.listeners.get(event); + + if (!currentListeners) { + return; + } + + currentListeners.forEach((listener) => { + listener(payload as TEvents[keyof TEvents]); + }); + } + + clear(): void { + this.listeners.clear(); + } +} diff --git a/src/utils/Sizes.ts b/src/utils/Sizes.ts index f923849..8a69c15 100644 --- a/src/utils/Sizes.ts +++ b/src/utils/Sizes.ts @@ -1 +1,50 @@ -// src/utils/Sizes.ts +type SizeSnapshot = { + width: number; + height: number; + pixelRatio: number; +}; + +type SizeListener = (snapshot: SizeSnapshot) => void; + +export class Sizes { + private snapshot: SizeSnapshot; + private readonly listeners = new Set(); + private readonly handleResize = (): void => { + this.snapshot = Sizes.readWindow(); + this.emit(); + }; + + constructor() { + this.snapshot = Sizes.readWindow(); + window.addEventListener("resize", this.handleResize); + } + + subscribe(listener: SizeListener): () => void { + this.listeners.add(listener); + + return () => { + this.listeners.delete(listener); + }; + } + + getSnapshot(): SizeSnapshot { + return this.snapshot; + } + + destroy(): void { + window.removeEventListener("resize", this.handleResize); + this.listeners.clear(); + } + + private emit(): void { + this.listeners.forEach((listener) => listener(this.snapshot)); + } + + private static readWindow(): SizeSnapshot { + return { + width: window.innerWidth, + height: window.innerHeight, + pixelRatio: Math.min(window.devicePixelRatio, 2), + }; + } +} diff --git a/src/utils/Time.ts b/src/utils/Time.ts index f40cebd..b2930dd 100644 --- a/src/utils/Time.ts +++ b/src/utils/Time.ts @@ -1 +1,42 @@ -// src/utils/Time.ts +type TickListener = (delta: number, elapsed: number) => void; + +export class Time { + private readonly listeners = new Set(); + private animationFrameId = 0; + private lastTick = performance.now(); + private elapsed = 0; + + constructor() { + this.tick = this.tick.bind(this); + this.animationFrameId = window.requestAnimationFrame(this.tick); + } + + subscribe(listener: TickListener): () => void { + this.listeners.add(listener); + + return () => { + this.listeners.delete(listener); + }; + } + + getElapsed(): number { + return this.elapsed; + } + + destroy(): void { + window.cancelAnimationFrame(this.animationFrameId); + this.listeners.clear(); + } + + private tick(now: number): void { + const delta = (now - this.lastTick) / 1000; + this.lastTick = now; + this.elapsed += delta; + + this.listeners.forEach((listener) => { + listener(delta, this.elapsed); + }); + + this.animationFrameId = window.requestAnimationFrame(this.tick); + } +} diff --git a/src/debug/Debug.ts b/src/utils/debug/Debug.ts similarity index 73% rename from src/debug/Debug.ts rename to src/utils/debug/Debug.ts index 66d4596..a05ce06 100644 --- a/src/debug/Debug.ts +++ b/src/utils/debug/Debug.ts @@ -1,6 +1,5 @@ import GUI from "lil-gui"; - -export type CameraMode = "player" | "debug"; +import type { CameraMode } from "@/types/debug"; export class Debug { private static instance: Debug | null = null; @@ -9,8 +8,9 @@ export class Debug { private readonly gui: GUI | null; private readonly folders = new Map(); private readonly listeners = new Set<() => void>(); - private readonly controls = { cameraMode: "player" as CameraMode }; - private cameraMode: CameraMode = "player"; + private readonly controls: { cameraMode: CameraMode } = { + cameraMode: "player", + }; static getInstance(): Debug { if (!Debug.instance) { @@ -27,17 +27,22 @@ export class Debug { if (this.gui) { const folder = this.createFolder("Debug"); + if (!folder) { + return; + } + folder - ?.add(this.controls, "cameraMode", { Player: "player", Debug: "debug" }) + .add(this.controls, "cameraMode", { Player: "player", Debug: "debug" }) .name("Camera Mode") .onChange((value: CameraMode) => { this.controls.cameraMode = value; - this.cameraMode = value; this.emit(); }); } } + createFolder(name: string): GUI; + createFolder(name: string): GUI | null; createFolder(name: string): GUI | null { if (!this.gui) { return null; @@ -63,19 +68,8 @@ export class Debug { }; } - isDebugCameraEnabled(): boolean { - return this.cameraMode === "debug"; - } - getCameraMode(): CameraMode { - return this.cameraMode; - } - - destroy(): void { - this.listeners.clear(); - this.folders.clear(); - this.gui?.destroy(); - Debug.instance = null; + return this.controls.cameraMode; } private emit(): void { diff --git a/src/debug/DebugPerf.tsx b/src/utils/debug/DebugPerf.tsx similarity index 89% rename from src/debug/DebugPerf.tsx rename to src/utils/debug/DebugPerf.tsx index 36efac7..9053017 100644 --- a/src/debug/DebugPerf.tsx +++ b/src/utils/debug/DebugPerf.tsx @@ -1,5 +1,5 @@ import { Suspense, lazy } from "react"; -import { Debug } from "@/debug/Debug"; +import { Debug } from "@/utils/debug/Debug"; const Perf = lazy(() => import("r3f-perf").then((m) => ({ default: m.Perf }))); diff --git a/src/debug/scene/DebugCameraControls.tsx b/src/utils/debug/scene/DebugCameraControls.tsx similarity index 100% rename from src/debug/scene/DebugCameraControls.tsx rename to src/utils/debug/scene/DebugCameraControls.tsx diff --git a/src/debug/scene/DebugHelpers.tsx b/src/utils/debug/scene/DebugHelpers.tsx similarity index 87% rename from src/debug/scene/DebugHelpers.tsx rename to src/utils/debug/scene/DebugHelpers.tsx index 3b41fc3..5abf9a9 100644 --- a/src/debug/scene/DebugHelpers.tsx +++ b/src/utils/debug/scene/DebugHelpers.tsx @@ -1,4 +1,4 @@ -import { Debug } from "@/debug/Debug"; +import { Debug } from "@/utils/debug/Debug"; export function DebugHelpers(): React.JSX.Element | null { const debug = Debug.getInstance(); diff --git a/src/world/Lighting.tsx b/src/world/Lighting.tsx index ed42b98..c95c0cb 100644 --- a/src/world/Lighting.tsx +++ b/src/world/Lighting.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { useFrame } from "@react-three/fiber"; import type { AmbientLight, DirectionalLight } from "three"; -import { Debug } from "@/debug/Debug"; +import { Debug } from "@/utils/debug/Debug"; type LightingState = { ambientIntensity: number; @@ -27,22 +27,16 @@ export function Lighting(): React.JSX.Element { const debug = Debug.getInstance(); if (!debug.active) { - return undefined; + return; } const folder = debug.createFolder("Lighting"); - if (!folder) { - return undefined; - } - folder.add(LIGHTING_STATE, "ambientIntensity", 0, 5, 0.1).name("Ambient"); folder.add(LIGHTING_STATE, "sunIntensity", 0, 8, 0.1).name("Sun Intensity"); folder.add(LIGHTING_STATE, "sunX", -100, 100, 1).name("Sun X"); folder.add(LIGHTING_STATE, "sunY", 0, 150, 1).name("Sun Y"); folder.add(LIGHTING_STATE, "sunZ", -100, 100, 1).name("Sun Z"); - - return undefined; }, []); useFrame(() => { diff --git a/src/world/PostFX.tsx b/src/world/PostFX.tsx deleted file mode 100644 index a4d6d7d..0000000 --- a/src/world/PostFX.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/world/PostFX.tsx diff --git a/src/world/World.tsx b/src/world/World.tsx index 48764da..8213516 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -1,7 +1,7 @@ import { Suspense } from "react"; -import { DebugCameraControls } from "@/debug/scene/DebugCameraControls"; -import { DebugHelpers } from "@/debug/scene/DebugHelpers"; -import { useCameraMode } from "@/debug/useCameraMode"; +import { useCameraMode } from "@/hooks/debug/useCameraMode"; +import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls"; +import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers"; import { Environment } from "@/world/Environment"; import { Lighting } from "@/world/Lighting"; import { Map } from "@/world/Map"; diff --git a/src/world/player/Crosshair.tsx b/src/world/player/Crosshair.tsx deleted file mode 100644 index e7e761f..0000000 --- a/src/world/player/Crosshair.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/world/player/Crosshair.tsx diff --git a/src/world/zones/FarmZone.tsx b/src/world/zones/FarmZone.tsx deleted file mode 100644 index 8b20bde..0000000 --- a/src/world/zones/FarmZone.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/world/zones/FarmZone.tsx diff --git a/src/world/zones/PowerGridZone.tsx b/src/world/zones/PowerGridZone.tsx deleted file mode 100644 index aa56322..0000000 --- a/src/world/zones/PowerGridZone.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/world/zones/PowerGridZone.tsx diff --git a/src/world/zones/ResidentialZone.tsx b/src/world/zones/ResidentialZone.tsx deleted file mode 100644 index 59e13b9..0000000 --- a/src/world/zones/ResidentialZone.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/world/zones/ResidentialZone.tsx diff --git a/src/world/zones/SchoolZone.tsx b/src/world/zones/SchoolZone.tsx deleted file mode 100644 index a8ec1d5..0000000 --- a/src/world/zones/SchoolZone.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/world/zones/SchoolZone.tsx diff --git a/src/world/zones/WorkshopZone.tsx b/src/world/zones/WorkshopZone.tsx deleted file mode 100644 index 0fa3432..0000000 --- a/src/world/zones/WorkshopZone.tsx +++ /dev/null @@ -1 +0,0 @@ -// src/world/zones/WorkshopZone.tsx diff --git a/tsconfig.app.json b/tsconfig.app.json index 5e4f9b6..bbe5067 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -7,6 +7,9 @@ "types": ["vite/client"], "skipLibCheck": true, "ignoreDeprecations": "6.0", + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, /* Bundler mode */ "moduleResolution": "bundler", diff --git a/tsconfig.node.json b/tsconfig.node.json index d3c52ea..583198a 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -6,6 +6,9 @@ "module": "esnext", "types": ["node"], "skipLibCheck": true, + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, /* Bundler mode */ "moduleResolution": "bundler",