Files
La-Fabrik/README.md
T
2026-04-13 22:59:56 +02:00

377 lines
12 KiB
Markdown

# 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/ |
### State
| Package | Doc |
|--------|-----|
| [Zustand](https://zustand.docs.pmnd.rs/) | https://zustand.docs.pmnd.rs/ |
### 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
│ ├── 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 # 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/ # React hooks — thin wrappers on managers
│ ├── useZoneDetection.ts
│ ├── useInteraction.ts
│ ├── useCinematic.ts
│ ├── useAudio.ts
│ └── useLOD.ts
├── data/
│ ├── zones.ts # { id, position, radius, missionId }
│ └── dialogues.ts # Narrative scripts per zone
├── shaders/
│ └── hologram/
│ ├── vertex.glsl
│ └── fragment.glsl
├── utils/
│ ├── 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 # 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<void> // 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<void> {
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<GameState>((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.