diff --git a/README.md b/README.md index 018fb4d..71cc8e7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ An interactive 3D web experience for La Fabrik Durable — a low-tech repair and transformation service in Altera, a post-capitalist city rebuilt in 2039. Players step into the role of a newly onboarded technician and experience a day at the service: repairing an e-bike, fixing a power grid, and upgrading a vertical farm's irrigation system. +Built with React, Three.js, and Vite. Runs in the browser, no installation required. + ## 📦 Tech Stack ### Build & Language @@ -35,75 +37,341 @@ An interactive 3D web experience for La Fabrik Durable — a low-tech repair and | [AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer) | https://threejs.org/docs/#api/en/animation/AnimationMixer | -## Architecture +## 🗂 Project Structure + +``` la-fabrik/ ├── public/ │ ├── models/ -│ │ ├── map/ +│ │ ├── map/ # Base map — loaded once at start +│ │ ├── workshop/ +│ │ ├── powerGrid/ +│ │ └── farm/ │ ├── textures/ │ └── sounds/ │ └── src/ - ├── world/ - │ ├── Map.tsx - │ ├── Environment.tsx - │ ├── PostFX.tsx - │ ├── zones/ + ├── world/ # Single persistent 3D world + │ ├── Map.tsx # Base map, always mounted + │ ├── Environment.tsx # HDRI, fog, sky + │ ├── PostFX.tsx # Bloom, SSAO, chromatic aberration + │ ├── zones/ # Spatial zones — LOD per zone │ │ ├── WorkshopZone.tsx │ │ ├── PowerGridZone.tsx - │ │ └── FarmZone.tsx - │ │ └── SchoolZone.tsx + │ │ ├── FarmZone.tsx + │ │ ├── SchoolZone.tsx │ │ └── ResidentialZone.tsx │ └── player/ - │ ├── FPSController.tsx + │ ├── FPSController.tsx # PointerLockControls + Rapier movement │ └── Crosshair.tsx │ ├── components/ - │ ├── 3d/ - │ │ └── InteractiveObject.tsx - │ └── ui/ - │ ├── NarrativeOverlay.tsx - │ ├── MissionHUD.tsx - │ ├── MapHUD.tsx - │ ├── CinematicBars.tsx - │ └── LoadingScreen.tsx + │ ├── 3d/ # Shared reusable 3D elements + │ │ └── InteractiveObject.tsx # Raycasting + outline wrapper + │ └── ui/ # HTML overlays — outside Canvas + │ ├── NarrativeOverlay.tsx # Floating dialogues + │ ├── MissionHUD.tsx # Current objective + │ ├── MapHUD.tsx # Minimap + │ ├── CinematicBars.tsx # GSAP black bars + │ └── LoadingScreen.tsx # Asset progress │ - ├── stores/ - │ ├── useExperienceStore.ts - │ ├── usePlayerStore.ts - │ └── useAudioStore.ts + ├── stateManager/ # All logic, state, orchestration + │ ├── GameManager.ts # Orchestrator: phase, missions, steps + │ ├── CinematicManager.ts # GSAP timelines, camera lock/unlock + │ ├── AudioManager.ts # Music, SFX, spatial audio + │ ├── NPCManager.ts # Dialogues, NPC state + │ └── ZoneManager.ts # Zone detection, LOD triggers │ - ├── hooks/ + ├── hooks/ # React hooks — thin wrappers on managers │ ├── useZoneDetection.ts │ ├── useInteraction.ts - │ └── useAudio.ts │ ├── useCinematic.ts + │ ├── useAudio.ts │ └── useLOD.ts │ ├── data/ - │ ├── zones.ts - │ └── dialogues.ts + │ ├── zones.ts # { id, position, radius, missionId } + │ └── dialogues.ts # Narrative scripts per zone │ ├── shaders/ + │ └── hologram/ + │ ├── vertex.glsl + │ └── fragment.glsl │ ├── utils/ - │ ├── debug.js - │ ├── sizes.js - │ ├── time.js - │ └── loadingscreen.js + │ ├── Debug.ts # lil-gui panel + │ ├── Sizes.ts # Viewport dimensions, resize listener + │ ├── Time.ts # Delta, elapsed — outside useFrame + │ ├── EventEmitter.ts # Decoupled event bus between managers + │ └── Dispose.ts # traverse() + dispose() helper │ - ├── App.tsx - └── main.tsx + ├── App.tsx # Canvas + UI superimposed + └── main.tsx +``` + +## 🏗 Architecture Patterns + +These patterns are **mandatory across the entire codebase**. Every class, every 3D object, every manager follows the same conventions. Consistency over cleverness. +### 1. Singleton Pattern + +Every Manager and core utility uses the same singleton pattern. One instance, shared everywhere — no prop drilling, no context, no stray `new` calls + +```ts +// stateManager/GameManager.ts +export class GameManager { + private static _instance: GameManager | null = null + + cinematic!: CinematicManager + audio!: AudioManager + zone!: ZoneManager + npc!: NPCManager + + 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() + this.npc = NPCManager.getInstance() + } + + destroy(): void { + this.cinematic.destroy() + this.audio.destroy() + this.zone.destroy() + this.npc.destroy() + GameManager._instance = null + } +} + +// Usage — anywhere in the codebase +const game = GameManager.getInstance() +game.startMission('workshop') +``` + +Apply the **exact same pattern** to every Manager and utility class (`CinematicManager`, `AudioManager`, `Debug`, `Sizes`, `EventEmitter`) + +--- + +### 2. Class Interface — `load` / `update` / `destroy` + +Every 3D class implements the same three-method lifecycle. No exceptions. + +```ts +// Enforced interface for all world objects +interface WorldObject { + load(): Promise // Load assets, build scene graph + update(delta: number): void // Per-frame logic (called from useFrame) + destroy(): void // Clean GPU memory — mandatory +} +``` + +```ts +// world/zones/WorkshopZone.tsx +export class WorkshopZone implements WorldObject { + private group: THREE.Group = new THREE.Group() + private mixer: THREE.AnimationMixer | null = null + + async load(): Promise { + const gltf = await loadGLTF('/models/workshop/ebike.glb') + this.group = gltf.scene + this.mixer = new THREE.AnimationMixer(this.group) + this.scene.add(this.group) + } + + update(delta: number): void { + this.mixer?.update(delta) + } + + destroy(): void { + this.mixer?.stopAllAction() + Dispose.object(this.group) // ← always traverse before remove + this.scene.remove(this.group) + } +} +``` + +--- + +### 3. Memory Management — `traverse()` + `dispose()` + +**Every `destroy()` must call `Dispose.object()` before removing anything from the scene.** Skipping this leaks GPU memory (VRAM) silently — no error thrown, just a crash after a few zone transitions. + +**Rule: traverse first, remove second. Always.** + +```ts +// utils/Dispose.ts +import * as THREE from 'three' + +export class Dispose { + /** + * Recursively disposes all geometries, materials, and textures + * from an Object3D and its entire subtree. + * + * Always call this before scene.remove() to prevent VRAM leaks. + */ + static object(obj: THREE.Object3D): void { + obj.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return + + // 1. Dispose geometry buffers + child.geometry.dispose() + + // 2. Handle single and multi-material meshes + const materials = Array.isArray(child.material) + ? child.material + : [child.material] + + for (const mat of materials) { + // 3. Dispose every texture referenced by the material + for (const value of Object.values(mat)) { + if (value instanceof THREE.Texture) { + value.dispose() + } + } + // 4. Dispose the material itself + mat.dispose() + } + }) + } + + /** + * Dispose a WebGL render target and its textures. + */ + static renderTarget(rt: THREE.WebGLRenderTarget): void { + rt.texture.dispose() + rt.dispose() + } +} +``` + +Usage pattern — identical in every `destroy()`: + +```ts +destroy(): void { + Dispose.object(this.mesh) // Frees VRAM: geometries, materials, textures + this.scene.remove(this.mesh) // Then removes from scene graph +} +``` + +--- + +### 4. Manager Coordination + +`GameManager` is the **single entry point** for all logic. Components and hooks never import `CinematicManager` or `AudioManager` directly — always through `GameManager`. This keeps the dependency graph flat and every interaction auditable from one place. + +``` +Component / Hook + ↓ +GameManager.getInstance() + ├── .cinematic.play('intro_workshop') + ├── .audio.playAmbience('workshop') + ├── .zone.setActive('workshop') + └── .npc.startDialogue('mechanic_greeting') +``` + +```ts +// hooks/useCinematic.ts — thin wrapper, no logic +export function useCinematic() { + const trigger = useCallback((id: string) => { + GameManager.getInstance().cinematic.play(id) + }, []) + + return { trigger } +} +``` + +Zustand is used **only for UI reactivity** — to push state from managers into React components. The logic lives in the manager class, not in the store. + +```ts +// stateManager/GameManager.ts — Zustand as a thin reactive bridge +import { create } from 'zustand' + +type GameState = { + phase: 'loading' | 'intro' | 'exploring' | 'cinematic' | 'outro' + activeZone: 'workshop' | 'powerGrid' | 'farm' | null + setPhase: (phase: GameState['phase']) => void + setActiveZone: (zone: GameState['activeZone']) => void +} + +export const useGameStore = create((set) => ({ + phase: 'loading', + activeZone: null, + setPhase: (phase) => set({ phase }), + setActiveZone: (zone) => set({ activeZone: zone }), +})) + +// GameManager writes — React components read +export class GameManager { + setPhase(phase: GameState['phase']): void { + useGameStore.getState().setPhase(phase) + } +} +``` + +--- + +### 5. Debug Utility + +Activate the debug panel by appending `?debug` to the URL (`http://localhost:5173?debug`). Never scatter `if (isDev)` blocks across files — all debug logic flows through `Debug.ts`. + +```ts +// utils/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 + } +} +``` + +```ts +// Usage in any class +const debug = Debug.getInstance() +if (debug.active) { + debug.gui!.add(this.mesh.position, 'y', -5, 5).name('Height') +} +``` + + ## 🚀 Getting Started - + ```bash git clone https://github.com/La-Fabrik-Durable/La-Fabrik.git cd La-Fabrik npm install npm run dev ``` - + +Open `http://localhost:5173` — standard experience. +Open `http://localhost:5173?debug` — debug panel + r3f-perf overlay. + + ## 📜 License - + See [LICENSE](./LICENSE) file. \ No newline at end of file