# La-Fabrik 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 | 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/ | ### 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/ | ### 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 | ## 🗂 Project Structure ``` la-fabrik/ ├── public/ │ ├── models/ │ │ ├── map/ # Base map — loaded once at start │ │ ├── workshop/ │ │ ├── powerGrid/ │ │ └── farm/ │ ├── textures/ │ └── sounds/ │ └── src/ ├── world/ # Single persistent 3D world │ ├── Map.tsx # Base map, always mounted │ ├── Lighting.tsx # Ambient, directional, point lights │ ├── 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 │ │ └── ResidentialZone.tsx │ └── player/ │ ├── FPSController.tsx # PointerLockControls + Rapier movement │ └── Crosshair.tsx │ ├── components/ │ ├── 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 │ ├── stateManager/ # All logic, state, orchestration │ ├── GameManager.ts # Single source of truth: phase, zone, mission │ ├── CinematicManager.ts # GSAP timelines, camera lock/unlock │ ├── AudioManager.ts # Music, SFX, spatial audio │ └── ZoneManager.ts # Zone detection, LOD triggers │ ├── hooks/ # React hooks — thin wrappers on managers │ ├── useGameState.ts # Subscribes to GameManager │ ├── useZoneDetection.ts │ ├── useInteraction.ts │ ├── useCinematic.ts │ ├── useAudio.ts │ └── useLOD.ts │ ├── data/ │ ├── zones.ts # { id, position, radius, missionId } │ ├── dialogues.ts # Narrative scripts, PNJ states │ └── missions.ts # Mission definitions, steps │ ├── shaders/ │ └── hologram/ │ ├── vertex.glsl │ └── fragment.glsl │ ├── utils/ │ ├── Debug.ts # lil-gui panel │ ├── EventEmitter.ts # Simple pub/sub for manager-to-manager events │ └── Dispose.ts # traverse() + dispose() helper │ ├── App.tsx # Canvas + UI superimposed └── main.tsx ``` ## 🏗 Architecture Patterns The project uses **two complementary patterns**: - **Singleton service classes** for orchestration and side effects - **Declarative React components** for all 3D scene objects 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**. Consistency matters, but the codebase does **not** force the same lifecycle pattern on scene components and global services. --- ### 1. Singleton Pattern for Global Managers Only Only cross-cutting services use the singleton pattern. Examples: - `GameManager` - `CinematicManager` - `AudioManager` - `ZoneManager` - `Debug` - `EventEmitter` 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: ```text 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 Utility The debug panel can be activated by appending `?debug` to the URL: `http://localhost:5173?debug` All debug logic is centralized in `Debug.ts`. Do not scatter debug checks across the codebase. ```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; } } ``` Usage: ```ts const debug = Debug.getInstance(); if (debug.active) { debug.gui!.add(params, "bloomIntensity", 0, 3).name("Bloom"); } ``` ## 🚀 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.