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

12 KiB

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/
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/
@react-three/fiber https://docs.pmnd.rs/react-three-fiber
@react-three/drei https://pmndrs.github.io/drei
@react-three/rapier https://rapier.rs/docs/user_guides/javascript/
@react-three/postprocessing https://github.com/pmndrs/postprocessing
GSAP https://gsap.com/docs/v3/

Performance & Effects

Package Doc
r3f-perf https://github.com/utsuboco/r3f-perf
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                  # 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                 # 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

// 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.

// 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
}
// 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. State Management — Single Source of Truth

The project uses a single authoritative GameManager for durable gameplay state. React components subscribe to this state through thin custom hooks. High-frequency values such as movement, camera interpolation, or physics never go through React state and stay in refs or frame-based systems.

// GameManager.ts — single source of truth
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()
  }
}
// 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
}

All other managers (Cinemactic, Audio, Zone) remain as side effects that communicate through GameManager


4. 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.

// 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():

destroy(): void {
  Dispose.object(this.mesh)     // Frees VRAM: geometries, materials, textures
  this.scene.remove(this.mesh)  // Then removes from scene graph
}

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.

// 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 in any class
const debug = Debug.getInstance()
if (debug.active) {
  debug.gui!.add(this.mesh.position, 'y', -5, 5).name('Height')
}

🚀 Getting Started

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 file.