add : finish readme, git lfs and gitignore
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
# Git LFS for 3D assets
|
||||||
|
# Track large binary files to avoid repo bloat
|
||||||
|
|
||||||
|
*.glb filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.gltf filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
||||||
|
# Textures
|
||||||
|
*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.webp filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.hdr filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.exr filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ktx2 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ktx filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
||||||
|
# Audio
|
||||||
|
*.wav filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ogg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
||||||
|
# Video (cinematics)
|
||||||
|
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.webm filter=lfs diff=lfs merge=lfs -text
|
||||||
+27
-11
@@ -1,24 +1,40 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs/
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
# Editor
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea/
|
||||||
.DS_Store
|
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
*.debug
|
||||||
|
|
||||||
|
# 3D Assets Cache (drei, GLTFJSX)
|
||||||
|
.drei/
|
||||||
|
.glitchdrei-cache/
|
||||||
@@ -48,6 +48,7 @@ la-fabrik/
|
|||||||
└── src/
|
└── src/
|
||||||
├── world/ # Single persistent 3D world
|
├── world/ # Single persistent 3D world
|
||||||
│ ├── Map.tsx # Base map, always mounted
|
│ ├── Map.tsx # Base map, always mounted
|
||||||
|
│ ├── Lighting.tsx # Ambient, directional, point lights
|
||||||
│ ├── Environment.tsx # HDRI, fog, sky
|
│ ├── Environment.tsx # HDRI, fog, sky
|
||||||
│ ├── PostFX.tsx # Bloom, SSAO, chromatic aberration
|
│ ├── PostFX.tsx # Bloom, SSAO, chromatic aberration
|
||||||
│ ├── zones/ # Spatial zones — LOD per zone
|
│ ├── zones/ # Spatial zones — LOD per zone
|
||||||
@@ -96,109 +97,150 @@ la-fabrik/
|
|||||||
│
|
│
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── Debug.ts # lil-gui panel
|
│ ├── Debug.ts # lil-gui panel
|
||||||
│ ├── EventEmitter.ts # Decoupled event bus between managers
|
│ ├── EventEmitter.ts # Simple pub/sub for manager-to-manager events
|
||||||
│ └── Dispose.ts # traverse() + dispose() helper
|
│ └── Dispose.ts # traverse() + dispose() helper
|
||||||
│
|
│
|
||||||
├── App.tsx # Canvas + UI superimposed
|
├── App.tsx # Canvas + UI superimposed
|
||||||
└── main.tsx
|
└── main.tsx
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏗 Architecture Patterns
|
## 🏗 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
|
The project uses **two complementary patterns**:
|
||||||
|
|
||||||
Every Manager and core utility uses the same singleton pattern. One instance, shared everywhere — no prop drilling, no context, no stray `new` calls
|
- **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 `<Canvas>`.
|
||||||
|
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
|
```ts
|
||||||
// stateManager/GameManager.ts
|
// stateManager/GameManager.ts
|
||||||
export class GameManager {
|
export class GameManager {
|
||||||
private static _instance: GameManager | null = null
|
private static _instance: GameManager | null = null
|
||||||
|
|
||||||
cinematic!: CinematicManager
|
cinematic!: CinematicManager
|
||||||
audio!: AudioManager
|
audio!: AudioManager
|
||||||
zone!: ZoneManager
|
zone!: ZoneManager
|
||||||
npc!: NPCManager
|
|
||||||
|
|
||||||
static getInstance(): GameManager {
|
static getInstance(): GameManager {
|
||||||
if (!GameManager._instance) {
|
if (!GameManager._instance) {
|
||||||
GameManager._instance = new GameManager()
|
GameManager._instance = new GameManager()
|
||||||
}
|
}
|
||||||
return GameManager._instance
|
return GameManager._instance
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.cinematic = CinematicManager.getInstance()
|
this.cinematic = CinematicManager.getInstance()
|
||||||
this.audio = AudioManager.getInstance()
|
this.audio = AudioManager.getInstance()
|
||||||
this.zone = ZoneManager.getInstance()
|
this.zone = ZoneManager.getInstance()
|
||||||
this.npc = NPCManager.getInstance()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.cinematic.destroy()
|
this.cinematic.destroy()
|
||||||
this.audio.destroy()
|
this.audio.destroy()
|
||||||
this.zone.destroy()
|
this.zone.destroy()
|
||||||
this.npc.destroy()
|
|
||||||
GameManager._instance = null
|
GameManager._instance = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
```
|
||||||
// Usage — anywhere in the codebase
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```ts
|
||||||
const game = GameManager.getInstance()
|
const game = GameManager.getInstance()
|
||||||
game.startMission('workshop')
|
game.startMission('workshop')
|
||||||
```
|
```
|
||||||
|
|
||||||
Apply the **exact same pattern** to every Manager and utility class (`CinematicManager`, `AudioManager`, `Debug`, `Sizes`, `EventEmitter`)
|
**Important:** scene objects such as `Map`, `WorkshopZone`, `Lighting`, or `Environment` are **not** singletons and must remain standard React components.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. Class Interface — `load` / `update` / `destroy`
|
### 2. Scene Objects Are React Components, Not Manager Classes
|
||||||
|
|
||||||
Every 3D class implements the same three-method lifecycle. No exceptions.
|
All 3D scene objects are implemented as **declarative React components**.
|
||||||
|
|
||||||
```ts
|
This includes:
|
||||||
// Enforced interface for all world objects
|
- maps
|
||||||
interface WorldObject {
|
- lights
|
||||||
load(): Promise<void> // Load assets, build scene graph
|
- environments
|
||||||
update(delta: number): void // Per-frame logic (called from useFrame)
|
- player controllers
|
||||||
destroy(): void // Clean GPU memory — mandatory
|
- zones
|
||||||
}
|
- interactive props
|
||||||
```
|
- postprocessing layers
|
||||||
|
|
||||||
```ts
|
This keeps the code aligned with the R3F runtime instead of rebuilding a parallel imperative engine.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```tsx
|
||||||
// world/zones/WorkshopZone.tsx
|
// world/zones/WorkshopZone.tsx
|
||||||
export class WorkshopZone implements WorldObject {
|
import { useEffect, useRef } from 'react'
|
||||||
private group: THREE.Group = new THREE.Group()
|
import * as THREE from 'three'
|
||||||
private mixer: THREE.AnimationMixer | null = null
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import { useGLTF } from '@react-three/drei'
|
||||||
async load(): Promise<void> {
|
|
||||||
const gltf = await loadGLTF('/models/workshop/ebike.glb')
|
export function WorkshopZone() {
|
||||||
this.group = gltf.scene
|
const root = useRef<THREE.Group>(null)
|
||||||
this.mixer = new THREE.AnimationMixer(this.group)
|
const gltf = useGLTF('/models/workshop/ebike.glb')
|
||||||
this.scene.add(this.group)
|
const mixer = useRef<THREE.AnimationMixer | null>(null)
|
||||||
}
|
|
||||||
|
useEffect(() => {
|
||||||
update(delta: number): void {
|
mixer.current = new THREE.AnimationMixer(gltf.scene)
|
||||||
this.mixer?.update(delta)
|
|
||||||
}
|
return () => {
|
||||||
|
mixer.current?.stopAllAction()
|
||||||
destroy(): void {
|
mixer.current = null
|
||||||
this.mixer?.stopAllAction()
|
}
|
||||||
Dispose.object(this.group) // ← always traverse before remove
|
}, [gltf.scene])
|
||||||
this.scene.remove(this.group)
|
|
||||||
}
|
useFrame((_, delta) => {
|
||||||
|
mixer.current?.update(delta)
|
||||||
|
})
|
||||||
|
|
||||||
|
return <primitive ref={root} object={gltf.scene.clone()} />
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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. State Management — Single Source of Truth
|
### 3. Single Source of Truth for Durable Gameplay State
|
||||||
|
|
||||||
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.**
|
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
|
```ts
|
||||||
// GameManager.ts — single source of truth
|
// stateManager/GameManager.ts
|
||||||
type Phase = 'loading' | 'intro' | 'exploring' | 'cinematic' | 'outro'
|
type Phase = 'loading' | 'intro' | 'exploring' | 'cinematic' | 'outro'
|
||||||
type ZoneId = 'workshop' | 'powerGrid' | 'farm' | null
|
type ZoneId = 'workshop' | 'powerGrid' | 'farm' | null
|
||||||
|
|
||||||
@@ -281,53 +323,87 @@ export function useGameState() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All other managers (Cinemactic, Audio, Zone) remain as side effects that communicate through `GameManager`
|
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. Memory Management — `traverse()` + `dispose()`
|
### 4. Side Effects Stay in Specialized Managers
|
||||||
|
|
||||||
**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.**
|
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
|
```ts
|
||||||
// utils/Dispose.ts
|
// utils/Dispose.ts
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
|
||||||
export class Dispose {
|
export class Dispose {
|
||||||
/**
|
static material(material: THREE.Material): void {
|
||||||
* Recursively disposes all geometries, materials, and textures
|
for (const value of Object.values(material)) {
|
||||||
* from an Object3D and its entire subtree.
|
if (value instanceof THREE.Texture) {
|
||||||
*
|
value.dispose()
|
||||||
* 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()
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispose a WebGL render target and its textures.
|
|
||||||
*/
|
|
||||||
static renderTarget(rt: THREE.WebGLRenderTarget): void {
|
static renderTarget(rt: THREE.WebGLRenderTarget): void {
|
||||||
rt.texture.dispose()
|
rt.texture.dispose()
|
||||||
rt.dispose()
|
rt.dispose()
|
||||||
@@ -335,59 +411,75 @@ export class Dispose {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Usage pattern — identical in every `destroy()`:
|
Example usage:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
destroy(): void {
|
useEffect(() => {
|
||||||
Dispose.object(this.mesh) // Frees VRAM: geometries, materials, textures
|
const material = new THREE.ShaderMaterial({
|
||||||
this.scene.remove(this.mesh) // Then removes from scene graph
|
vertexShader,
|
||||||
}
|
fragmentShader,
|
||||||
|
})
|
||||||
|
|
||||||
|
meshRef.current.material = material
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Dispose.material(material)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Rule:** disposal is ownership-based, not automatic and not blind.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. Debug Utility
|
### 6. 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`.
|
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
|
```ts
|
||||||
// utils/Debug.ts
|
// utils/Debug.ts
|
||||||
import GUI from 'lil-gui'
|
import GUI from 'lil-gui'
|
||||||
|
|
||||||
export class Debug {
|
export class Debug {
|
||||||
private static _instance: Debug | null = null
|
private static _instance: Debug | null = null
|
||||||
|
|
||||||
readonly active: boolean
|
readonly active: boolean
|
||||||
gui: GUI | null = null
|
gui: GUI | null = null
|
||||||
|
|
||||||
static getInstance(): Debug {
|
static getInstance(): Debug {
|
||||||
if (!Debug._instance) Debug._instance = new Debug()
|
if (!Debug._instance) Debug._instance = new Debug()
|
||||||
return Debug._instance
|
return Debug._instance
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.active = new URLSearchParams(window.location.search).has('debug')
|
this.active = new URLSearchParams(window.location.search).has('debug')
|
||||||
if (this.active) {
|
if (this.active) {
|
||||||
this.gui = new GUI({ title: 'La-Fabrik Debug' })
|
this.gui = new GUI({ title: 'La-Fabrik Debug' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.gui?.destroy()
|
this.gui?.destroy()
|
||||||
Debug._instance = null
|
Debug._instance = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// Usage in any class
|
|
||||||
const debug = Debug.getInstance()
|
const debug = Debug.getInstance()
|
||||||
|
|
||||||
if (debug.active) {
|
if (debug.active) {
|
||||||
debug.gui!.add(this.mesh.position, 'y', -5, 5).name('Height')
|
debug.gui!.add(params, 'bloomIntensity', 0, 3).name('Bloom')
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user