update : docs and skills
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
# 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 `<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
|
||||
// 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<THREE.Group>(null);
|
||||
const gltf = useGLTF("/models/workshop/ebike.glb");
|
||||
const mixer = useRef<THREE.AnimationMixer | null>(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 <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. 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:
|
||||
|
||||
```
|
||||
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");
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,153 @@
|
||||
# Best Practices
|
||||
|
||||
Generate code that is **simple**, **understandable**, **reviewable**, **scalable**, **optimized**, and **modern**. Follow W3C web standards and platform conventions.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Files
|
||||
|
||||
| Type | Convention | Example |
|
||||
| ---------- | --------------------------- | -------------------- |
|
||||
| Components | PascalCase | `WorkshopZone.tsx` |
|
||||
| Hooks | camelCase with `use` prefix | `useGameState.ts` |
|
||||
| Managers | PascalCase | `GameManager.ts` |
|
||||
| Utils | PascalCase | `Dispose.ts` |
|
||||
| Data | PascalCase | `missions.ts` |
|
||||
| Shaders | kebab-case | `hologram.vert.glsl` |
|
||||
|
||||
### Variables & Functions
|
||||
|
||||
| Type | Convention | Example |
|
||||
| ---------------- | -------------------- | ----------------------------------------- |
|
||||
| Variables | camelCase | `activeZone`, `missionStep` |
|
||||
| Functions | camelCase | `startMission()`, `setActiveZone()` |
|
||||
| Constants | UPPER_SNAKE_CASE | `MAX_SPEED`, `DEFAULT_PHASE` |
|
||||
| React components | PascalCase | `function WorkshopZone()` |
|
||||
| React hooks | camelCase with `use` | `useGameState()`, `useFrame()` |
|
||||
| Classes | PascalCase | `class GameManager` |
|
||||
| Interfaces/Types | PascalCase | `type GameSnapshot`, `interface ZoneData` |
|
||||
|
||||
## Code Style
|
||||
|
||||
### Simplicity First
|
||||
|
||||
```ts
|
||||
// Good — clear, direct
|
||||
function getZoneRadius(zone: Zone): number {
|
||||
return zone.radius;
|
||||
}
|
||||
|
||||
// Bad — over-abstracted
|
||||
function getZoneRadius(zone: Zone): number {
|
||||
return zone[ZoneFields.RADIUS] ?? RADIUS_DEFAULTS[zone.type]?.default ?? 50;
|
||||
}
|
||||
```
|
||||
|
||||
### Early Return
|
||||
|
||||
```ts
|
||||
// Good
|
||||
if (!gltf) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
return <primitive object={gltf.scene} />;
|
||||
```
|
||||
|
||||
### Avoid Nested Callbacks
|
||||
|
||||
```ts
|
||||
// Good — flat structure
|
||||
useEffect(() => {
|
||||
const mixer = new THREE.AnimationMixer(model);
|
||||
|
||||
return () => mixer.stopAllAction();
|
||||
}, [model]);
|
||||
```
|
||||
|
||||
## TypeScript Rules
|
||||
|
||||
### Explicit Types for Exports
|
||||
|
||||
```ts
|
||||
export function useGameState(): GameSnapshot { ... }
|
||||
|
||||
export function setPhase(phase: Phase): void { ... }
|
||||
```
|
||||
|
||||
### Never use `any`
|
||||
|
||||
```ts
|
||||
// Good
|
||||
const ref = useRef<THREE.Group>(null);
|
||||
|
||||
// Bad
|
||||
const ref = useRef<any>(null);
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### useRef for Mutable Values
|
||||
|
||||
```ts
|
||||
// Good — no re-render
|
||||
const position = useRef(new THREE.Vector3());
|
||||
|
||||
// Bad — triggers re-render every frame
|
||||
const [position, setPosition] = useState(new THREE.Vector3());
|
||||
```
|
||||
|
||||
### Memoize Expensive Computations
|
||||
|
||||
```ts
|
||||
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
|
||||
```
|
||||
|
||||
## Scalability
|
||||
|
||||
### Single Responsibility
|
||||
|
||||
```ts
|
||||
// Good — focused component
|
||||
export function WorkshopZone() {
|
||||
// Only handles workshop zone logic
|
||||
}
|
||||
|
||||
// Bad — does everything
|
||||
export function WorkshopZone() {
|
||||
// Handles zone logic + audio + cinematics + missions
|
||||
}
|
||||
```
|
||||
|
||||
### Constants for Magic Numbers
|
||||
|
||||
```ts
|
||||
const DEFAULT_CAMERA_DISTANCE = 5;
|
||||
const ZONE_DETECTION_RADIUS = 20;
|
||||
```
|
||||
|
||||
## Accessibility (W3C)
|
||||
|
||||
### Semantic HTML
|
||||
|
||||
```tsx
|
||||
// Good
|
||||
<button onClick={handleInteract} aria-label="Interact with bike">
|
||||
<Model />
|
||||
</button>
|
||||
|
||||
// Bad
|
||||
<div onClick={handleInteract}>
|
||||
<Model />
|
||||
</div>
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Simplicity** — Every line of code must be justified.
|
||||
2. **Readability** — Code is read 10x more than it's written.
|
||||
3. **Reviewability** — PRs should be understandable in < 5 minutes.
|
||||
4. **Scalability** — Architecture should support growth without refactoring.
|
||||
5. **Performance** — Don't optimize prematurely, but don't introduce obvious bottlenecks.
|
||||
6. **Modern** — Use ES2022+ features, TypeScript strict mode, React hooks.
|
||||
7. **W3C** — Follow web standards: semantic HTML, ARIA, keyboard navigation.
|
||||
8. **No Over-Engineering** — Avoid patterns that add complexity without benefit.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Features
|
||||
|
||||
TODO: Documenter les fonctionnalités du jeu.
|
||||
Reference in New Issue
Block a user