Merge branch 'main' into design
This commit is contained in:
@@ -80,6 +80,7 @@ import { useGameState } from "@/hooks/useGameState";
|
|||||||
|
|
||||||
See `.agent/skills/` for detailed patterns per technology:
|
See `.agent/skills/` for detailed patterns per technology:
|
||||||
|
|
||||||
|
- `best-practices.md` — Code generation conventions (W3C, simple, scalable, modern)
|
||||||
- `r3f.md` — React Three Fiber component patterns
|
- `r3f.md` — React Three Fiber component patterns
|
||||||
- `three.md` — Three.js conventions and AnimationMixer
|
- `three.md` — Three.js conventions and AnimationMixer
|
||||||
- `gsap.md` — GSAP timeline and cinematic patterns
|
- `gsap.md` — GSAP timeline and cinematic patterns
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# Skill — Best Practices
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
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` |
|
||||||
|
| Enums | PascalCase | `enum Phase` (prefer string literals instead) |
|
||||||
|
|
||||||
|
### CSS Classes (if applicable)
|
||||||
|
|
||||||
|
Use kebab-case:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.mission-hud {
|
||||||
|
}
|
||||||
|
.narrative-overlay {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
Every file starts with a route comment:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
# route path src/world/zones/WorkshopZone.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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} />;
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
if (gltf && visible) {
|
||||||
|
return <primitive object={gltf.scene} />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Destructuring
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good
|
||||||
|
const { position, rotation, scale } = object;
|
||||||
|
|
||||||
|
// Good — explicit
|
||||||
|
const game = GameManager.getInstance();
|
||||||
|
const { phase, activeZone } = game.getState();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avoid Nested Callbacks
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good — flat structure
|
||||||
|
useEffect(() => {
|
||||||
|
const mixer = new THREE.AnimationMixer(model);
|
||||||
|
|
||||||
|
return () => mixer.stopAllAction();
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
// Bad — nested
|
||||||
|
useEffect(() => {
|
||||||
|
useEffect(() => {
|
||||||
|
const mixer = new THREE.AnimationMixer(model);
|
||||||
|
}, [model]);
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Rules
|
||||||
|
|
||||||
|
### Explicit Types for Exports
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good — explicit return type
|
||||||
|
export function useGameState(): GameSnapshot {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good — explicit parameter types
|
||||||
|
export function setPhase(phase: Phase): void {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interface over Type (for object shapes)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good
|
||||||
|
interface ZoneData {
|
||||||
|
id: string;
|
||||||
|
position: [number, number, number];
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK — simple unions
|
||||||
|
type Phase = "loading" | "exploring" | "cinematic";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Never use `any`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good
|
||||||
|
const ref = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
const ref = useRef<any>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
## React Patterns
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
# route path src/world/zones/WorkshopZone.tsx
|
||||||
|
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useGLTF } from "@react-three/drei";
|
||||||
|
|
||||||
|
export function WorkshopZone() {
|
||||||
|
const ref = useRef<THREE.Group>(null);
|
||||||
|
const gltf = useGLTF("/models/workshop/ebike.glb");
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
// per-frame logic
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// setup
|
||||||
|
return () => {
|
||||||
|
// cleanup
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <primitive ref={ref} object={gltf.scene.clone()} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hooks Return Simple Values
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good — returns simple object
|
||||||
|
export function useGameState() {
|
||||||
|
const game = GameManager.getInstance();
|
||||||
|
const [state, setState] = useState(game.getState());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return game.subscribe(() => setState({ ...game.getState() }));
|
||||||
|
}, [game]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad — returns methods that modify state directly
|
||||||
|
export function useGameActions() {
|
||||||
|
const game = GameManager.getInstance();
|
||||||
|
return {
|
||||||
|
setPhase: (p: Phase) => game.setPhase(p),
|
||||||
|
startMission: (id: string) => game.startMission(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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]);
|
||||||
|
|
||||||
|
const memoizedCallback = useCallback(
|
||||||
|
(x: number) => doSomething(x),
|
||||||
|
[dependency],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lazy Loading
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Components
|
||||||
|
const HeavyComponent = lazy(() => import("./HeavyComponent"));
|
||||||
|
|
||||||
|
// Assets
|
||||||
|
useGLTF.preload("/models/map/base.glb");
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Separate Data from View
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good — data in src/data/
|
||||||
|
// src/data/missions.ts
|
||||||
|
export const missions = {
|
||||||
|
workshop: { steps: [...] },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bad — data inline
|
||||||
|
export function WorkshopZone() {
|
||||||
|
const missions = [{ steps: [...] }]; // scattered everywhere
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constants for Magic Numbers
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good
|
||||||
|
const DEFAULT_CAMERA_DISTANCE = 5;
|
||||||
|
const ZONE_DETECTION_RADIUS = 20;
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
mesh.position.set(5, 0, 0); // magic number
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessibility (W3C)
|
||||||
|
|
||||||
|
### Semantic HTML
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good
|
||||||
|
<button onClick={handleInteract} aria-label="Interact with bike">
|
||||||
|
<Model />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
<div onClick={handleInteract}>
|
||||||
|
<Model />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard Navigation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
onClick={openDialogue}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && openDialogue()}
|
||||||
|
>
|
||||||
|
Talk
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Simplicity** — Every line of code must be justified. If it's not obviously necessary, remove it.
|
||||||
|
2. **Readability** — Code is read 10x more than it's written. Optimize for the reader.
|
||||||
|
3. **Reviewability** — Pull requests should be understandable in < 5 minutes.
|
||||||
|
4. **Scalability** — Architecture should support growth without refactoring existing code.
|
||||||
|
5. **Performance** — Don't optimize prematurely, but don't introduce obvious bottlenecks.
|
||||||
|
6. **Modern** — Use ES2022+ features, TypeScript strict mode, React hooks over class lifecycle.
|
||||||
|
7. **W3C** — Follow web standards: semantic HTML, ARIA, keyboard navigation.
|
||||||
|
8. **No Over-Engineering** — Avoid patterns that add complexity without benefit (factories for 1 instance, generic wrappers for single use cases, etc.)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: 🔍 CI
|
name: 🔍 Lint
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: 📊 Quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
name: 🔒 Security Audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: ⬇️ Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: 🧰 Setup Node
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: 📥 Install
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: 🔒 Audit
|
||||||
|
run: npm audit --audit-level=high
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
name: 📋 Dependency Freshness
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: ⬇️ Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: 🧰 Setup Node
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: 📥 Install
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: 📋 Check outdated
|
||||||
|
run: npm outdated --depth=0
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
bundle-size:
|
||||||
|
name: 📦 Bundle Size
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: ⬇️ Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: 🧰 Setup Node
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: 📥 Install
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: 📦 Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: 📏 Check bundle size
|
||||||
|
run: |
|
||||||
|
# Get bundle size in KB
|
||||||
|
SIZE=$(du -k dist | cut -f1)
|
||||||
|
echo "Bundle size: ${SIZE}KB"
|
||||||
|
|
||||||
|
# Threshold: 1000KB (configurable)
|
||||||
|
THRESHOLD=1000
|
||||||
|
|
||||||
|
if [ "$SIZE" -gt "$THRESHOLD" ]; then
|
||||||
|
echo "❌ Bundle size ${SIZE}KB exceeds threshold ${THRESHOLD}KB"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Bundle size ${SIZE}KB is under threshold"
|
||||||
@@ -106,388 +106,6 @@ la-fabrik/
|
|||||||
└── main.tsx
|
└── 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 `<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:
|
|
||||||
|
|
||||||
```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
|
## 🚀 Getting Started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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