From 82c4b612bf5ee2c7ef317672b84437051c695646 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Tue, 14 Apr 2026 09:20:30 +0200 Subject: [PATCH] update: add agent.md + skills --- .agent/AGENTS.md | 88 ++++++++++++++++++++++++++++++ .agent/skills/debug.md | 91 +++++++++++++++++++++++++++++++ .agent/skills/gsap.md | 111 ++++++++++++++++++++++++++++++++++++++ .agent/skills/managers.md | 103 +++++++++++++++++++++++++++++++++++ .agent/skills/memory.md | 88 ++++++++++++++++++++++++++++++ .agent/skills/r3f.md | 89 ++++++++++++++++++++++++++++++ .agent/skills/three.md | 96 +++++++++++++++++++++++++++++++++ src/utils/DebugPerf.tsx | 12 ++--- 8 files changed, 672 insertions(+), 6 deletions(-) create mode 100644 .agent/AGENTS.md create mode 100644 .agent/skills/debug.md create mode 100644 .agent/skills/gsap.md create mode 100644 .agent/skills/managers.md create mode 100644 .agent/skills/memory.md create mode 100644 .agent/skills/r3f.md create mode 100644 .agent/skills/three.md diff --git a/.agent/AGENTS.md b/.agent/AGENTS.md new file mode 100644 index 0000000..dffc6c3 --- /dev/null +++ b/.agent/AGENTS.md @@ -0,0 +1,88 @@ +# Agent — La Fabrik + +You are working on **La Fabrik**, an interactive 3D web experience built with React Three Fiber. The player steps into the role of a technician in Altera (2050) and completes missions: repairing an e-bike, fixing a power grid, upgrading a vertical farm. + +## Project Identity + +- **Stack:** React 19, Three.js, @react-three/fiber 9, @react-three/drei, @react-three/rapier, GSAP, TypeScript, Vite +- **No external state lib.** State is managed by a custom `GameManager` singleton with a subscribe/getState pattern. +- **No Zustand, no Redux, no Context for global state.** +- **Versions are pinned** (no `^` in dependencies). Do not upgrade packages without explicit request. + +## Architecture Rules + +### Two patterns coexist + +1. **Singleton manager classes** — for orchestration, audio, cinematics, zone detection, debug +2. **Declarative React components** — for all 3D scene objects (map, zones, lights, player, postprocessing) + +Scene objects are **never** singleton classes. Managers are **never** React components. + +### State ownership + +- `GameManager` is the single source of truth for durable gameplay state (phase, zone, mission, input lock, dialogue) +- Other managers (`CinematicManager`, `AudioManager`, `ZoneManager`) handle side effects only — they read from GameManager but do not duplicate its state +- React components subscribe to GameManager through `useGameState()` hook +- **High-frequency values** (movement, camera interpolation, physics) stay in `useRef` + `useFrame` — never in React state + +### File conventions + +- Every file starts with a comment: `# route path ` (e.g. `# route path src/world/Map.tsx`) +- Scene components live in `src/world/` and `src/components/3d/` +- UI overlays live in `src/components/ui/` +- Managers live in `src/stateManager/` +- Hooks live in `src/hooks/` +- Static data lives in `src/data/` +- Shaders live in `src/shaders/` +- Utilities live in `src/utils/` + +### Import paths + +Use `@/` alias for imports from `src/`: + +```ts +import { GameManager } from "@/stateManager/GameManager"; +import { useGameState } from "@/hooks/useGameState"; +``` + +### Memory management + +- Dispose only what you own (custom materials, render targets, manual clones) +- Never blindly deep-dispose shared/cached assets (drei loaders cache models) +- Use `Dispose.material()`, `Dispose.mesh()`, `Dispose.renderTarget()` from `src/utils/Dispose.ts` + +### Debug + +- Debug panel activates with `?debug` in URL +- All debug logic goes through `Debug.getInstance()` from `src/utils/Debug.ts` +- Never scatter `if (isDev)` blocks across files +- `r3f-perf` is lazy-loaded only in debug mode via `src/components/3d/DebugPerf.tsx` + +## Managers (4 max) + +| Manager | Responsibility | +| ------------------ | ------------------------------------------------------------------- | +| `GameManager` | Phase, zone, mission, input lock, dialogue — single source of truth | +| `CinematicManager` | GSAP timelines, camera lock/unlock | +| `AudioManager` | Music, SFX, spatial audio | +| `ZoneManager` | Zone detection, LOD triggers | + +## Do NOT + +- Create new manager classes without explicit request +- Use Zustand, Redux, or React Context for global state +- Put high-frequency values in React state (`useState`) +- Import `CinematicManager`/`AudioManager`/`ZoneManager` directly from components — always go through `GameManager` +- Upgrade pinned dependency versions +- Create files outside the documented architecture without explicit request + +## Skills + +See `.agent/skills/` for detailed patterns per technology: + +- `r3f.md` — React Three Fiber component patterns +- `three.md` — Three.js conventions and AnimationMixer +- `gsap.md` — GSAP timeline and cinematic patterns +- `managers.md` — Singleton manager implementation +- `memory.md` — GPU memory and disposal rules +- `debug.md` — Debug utility and r3f-perf setup diff --git a/.agent/skills/debug.md b/.agent/skills/debug.md new file mode 100644 index 0000000..b0e14a3 --- /dev/null +++ b/.agent/skills/debug.md @@ -0,0 +1,91 @@ +# Skill — Debug + +## Activation + +Append `?debug` to the URL: + +``` +http://localhost:5173?debug +``` + +## Debug singleton + +```ts +// src/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; + } +} +``` + +## Adding debug controls + +```ts +const debug = Debug.getInstance(); + +if (debug.active) { + const folder = debug.gui!.addFolder("Lighting"); + folder.add(params, "intensity", 0, 5).name("Sun intensity"); + folder.addColor(params, "color").name("Sun color"); +} +``` + +## r3f-perf (lazy loaded) + +r3f-perf is loaded only in debug mode to avoid dependency issues in production: + +```tsx +// src/components/3d/DebugPerf.tsx +import { Suspense, lazy } from "react"; + +const Perf = lazy(() => import("r3f-perf").then((m) => ({ default: m.Perf }))); + +export function DebugPerf() { + const debug = new URLSearchParams(window.location.search).has("debug"); + if (!debug) return null; + + return ( + + + + ); +} +``` + +Usage in Canvas: + +```tsx + + {/* scene content */} + + +``` + +## Rules + +- All debug UI goes through `Debug.getInstance()` — never inline `if (isDev)` checks +- r3f-perf is always lazy-imported, never a hard dependency in scene components +- Debug folders should be organized by domain (Lighting, PostFX, Player, Zone) +- Debug panel must not affect production builds — it simply doesn't mount when `?debug` is absent +- Clean up debug folders in `destroy()` when relevant diff --git a/.agent/skills/gsap.md b/.agent/skills/gsap.md new file mode 100644 index 0000000..18577c2 --- /dev/null +++ b/.agent/skills/gsap.md @@ -0,0 +1,111 @@ +# Skill — GSAP + +GSAP is used exclusively for **cinematic sequences** and **UI transitions**. It is never used for per-frame 3D animation (that's `useFrame` + AnimationMixer). + +## Cinematic timeline pattern + +```ts +import gsap from "gsap"; + +export class CinematicManager { + private static _instance: CinematicManager | null = null; + private timeline: gsap.core.Timeline | null = null; + + static getInstance(): CinematicManager { + if (!CinematicManager._instance) { + CinematicManager._instance = new CinematicManager(); + } + return CinematicManager._instance; + } + + play(id: string, camera: THREE.Camera): void { + this.timeline?.kill(); + + this.timeline = gsap.timeline({ + onStart: () => { + GameManager.getInstance().setPhase("cinematic"); + GameManager.getInstance().lockInput(true); + }, + onComplete: () => { + GameManager.getInstance().setPhase("exploring"); + GameManager.getInstance().lockInput(false); + }, + }); + + // Example: camera pan to workshop + this.timeline + .to(camera.position, { + x: 5, + y: 3, + z: 10, + duration: 2, + ease: "power2.inOut", + }) + .to( + camera.rotation, + { y: Math.PI / 4, duration: 1.5, ease: "power2.out" }, + "-=1", + ); + } + + destroy(): void { + this.timeline?.kill(); + this.timeline = null; + CinematicManager._instance = null; + } +} +``` + +## UI animation pattern + +For HTML overlays (cinematic bars, dialogue fade-in): + +```tsx +import { useRef, useEffect } from "react"; +import gsap from "gsap"; + +export function CinematicBars({ visible }: { visible: boolean }) { + const topRef = useRef(null); + const bottomRef = useRef(null); + + useEffect(() => { + if (visible) { + gsap.to(topRef.current, { y: 0, duration: 0.6, ease: "power2.out" }); + gsap.to(bottomRef.current, { y: 0, duration: 0.6, ease: "power2.out" }); + } else { + gsap.to(topRef.current, { y: -60, duration: 0.4, ease: "power2.in" }); + gsap.to(bottomRef.current, { y: 60, duration: 0.4, ease: "power2.in" }); + } + }, [visible]); + + return ( + <> +
+
+ + ); +} +``` + +## Rules + +- Always `.kill()` previous timeline before creating a new one +- Lock input via `GameManager.lockInput(true)` during cinematics +- Set phase to `'cinematic'` at start, restore to `'exploring'` at end +- Use `ease: 'power2.inOut'` for camera moves, `'power2.out'` for UI reveals +- Never use GSAP to animate values that R3F's `useFrame` already handles (positions updated every frame) +- Timelines are owned by `CinematicManager` — components trigger them, they don't create them diff --git a/.agent/skills/managers.md b/.agent/skills/managers.md new file mode 100644 index 0000000..3fea605 --- /dev/null +++ b/.agent/skills/managers.md @@ -0,0 +1,103 @@ +# Skill — Singleton Managers + +## Pattern + +Every manager follows the exact same singleton structure: + +```ts +export class SomeManager { + private static _instance: SomeManager | null = null; + + static getInstance(): SomeManager { + if (!SomeManager._instance) { + SomeManager._instance = new SomeManager(); + } + return SomeManager._instance; + } + + private constructor() { + // init logic + } + + destroy(): void { + // cleanup logic + SomeManager._instance = null; + } +} +``` + +## Managers in this project + +| Manager | File | Role | +| ------------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `GameManager` | `src/stateManager/GameManager.ts` | Single source of truth. Owns phase, zone, mission, input lock, dialogue. Has `subscribe()` + `getState()`. | +| `CinematicManager` | `src/stateManager/CinematicManager.ts` | GSAP timelines. Locks/unlocks input via GameManager. | +| `AudioManager` | `src/stateManager/AudioManager.ts` | Music, SFX, spatial audio. Reads phase from GameManager. | +| `ZoneManager` | `src/stateManager/ZoneManager.ts` | Zone entry/exit detection, LOD triggers. Notifies GameManager of zone changes. | + +## GameManager is the orchestrator + +```ts +export class GameManager { + cinematic!: CinematicManager; + audio!: AudioManager; + zone!: ZoneManager; + + private constructor() { + this.cinematic = CinematicManager.getInstance(); + this.audio = AudioManager.getInstance(); + this.zone = ZoneManager.getInstance(); + } +} +``` + +Components and hooks access other managers **through GameManager only**: + +```ts +// Correct +GameManager.getInstance().cinematic.play("intro"); + +// Wrong — never import sub-managers directly in components +CinematicManager.getInstance().play("intro"); +``` + +## Subscribe pattern (GameManager only) + +```ts +private listeners = new Set<() => void>() + +subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) +} + +private emit(): void { + this.listeners.forEach((cb) => cb()) +} +``` + +Every `set*()` method calls `this.emit()` to notify subscribers. + +## React bridge hook + +```ts +// hooks/useGameState.ts +export function useGameState() { + const game = GameManager.getInstance(); + const [state, setState] = useState(game.getState()); + + useEffect(() => { + return game.subscribe(() => setState({ ...game.getState() })); + }, [game]); + + return state; +} +``` + +## Rules + +- Max 4 managers total +- Only `GameManager` holds durable state with `subscribe()` +- Other managers are side-effect handlers — they do not store persistent state +- Always call `destroy()` on cleanup (App unmount) +- Never create manager instances with `new` — always use `.getInstance()` diff --git a/.agent/skills/memory.md b/.agent/skills/memory.md new file mode 100644 index 0000000..4cb365c --- /dev/null +++ b/.agent/skills/memory.md @@ -0,0 +1,88 @@ +# Skill — GPU Memory Management + +## Principle + +Dispose only what you own. Never blindly traverse and dispose shared or cached assets. + +## What to dispose + +| Resource | When to dispose | +| ---------------------------------- | ------------------------------------ | +| Custom `THREE.ShaderMaterial` | When the component using it unmounts | +| `THREE.WebGLRenderTarget` | When the pass or effect is destroyed | +| Manually created `THREE.Geometry` | When no longer needed | +| Manually created `THREE.Texture` | When no longer needed | +| Cloned scenes with owned materials | When the clone is removed | + +## What NOT to dispose + +| Resource | Why | +| -------------------------------- | ----------------------------------------------- | +| GLTF scenes loaded via `useGLTF` | drei caches them — disposing breaks other users | +| Textures loaded via `useTexture` | drei caches them — same reason | +| Shared materials from a GLTF | Other instances may reference them | + +## Dispose utility + +```ts +// src/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 mat of materials) { + if (mat) this.material(mat); + } + } + + static renderTarget(rt: THREE.WebGLRenderTarget): void { + rt.texture.dispose(); + rt.dispose(); + } +} +``` + +## Usage in React components + +```tsx +useEffect(() => { + const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader }); + meshRef.current.material = material; + + return () => { + Dispose.material(material); + }; +}, []); +``` + +## Usage in managers + +```ts +destroy(): void { + if (this.renderTarget) { + Dispose.renderTarget(this.renderTarget) + this.renderTarget = null + } + SomeManager._instance = null +} +``` + +## Rules + +- Every `useEffect` that creates a GPU resource must return a cleanup that disposes it +- Every manager `destroy()` must dispose its owned GPU resources +- Never call `.dispose()` on assets returned by drei loaders (`useGLTF`, `useTexture`) +- When in doubt, don't dispose — a small leak is better than a crash from disposing shared resources diff --git a/.agent/skills/r3f.md b/.agent/skills/r3f.md new file mode 100644 index 0000000..50352a8 --- /dev/null +++ b/.agent/skills/r3f.md @@ -0,0 +1,89 @@ +# Skill — React Three Fiber + +## Component pattern + +Every 3D scene object is a React component. No class-based scene objects. + +```tsx +import { useRef } from "react"; +import * as THREE from "three"; +import { useFrame } from "@react-three/fiber"; +import { useGLTF } from "@react-three/drei"; + +export function MyObject() { + const ref = useRef(null); + const gltf = useGLTF("/models/my-object.glb"); + + useFrame((_, delta) => { + // per-frame logic here + }); + + return ; +} +``` + +## Rules + +- Scene components return JSX with Three.js elements (``, ``, ``) +- Use `useRef` for mutable per-frame values — never `useState` +- Use `useFrame` for animation loops — never `requestAnimationFrame` +- Use `useGLTF` / `useTexture` from drei for asset loading — they handle caching +- Clone scenes with `.clone()` when reusing a GLTF in multiple places +- Cleanup in `useEffect` return — stop AnimationMixers, dispose owned resources + +## Loading assets + +```tsx +// Models +const gltf = useGLTF("/models/workshop/ebike.glb"); + +// Textures +const [diffuse, normal] = useTexture([ + "/textures/wall_diffuse.jpg", + "/textures/wall_normal.jpg", +]); + +// Preload (call outside component) +useGLTF.preload("/models/map/base.glb"); +``` + +## Physics (Rapier) + +```tsx +import { RigidBody, CuboidCollider } from "@react-three/rapier"; + + + + + + + +; +``` + +- Wrap physics scene in `` component +- `type="fixed"` for static colliders (ground, walls) +- `type="dynamic"` for movable objects +- Player uses `type="dynamic"` with `lockRotations` + +## Postprocessing + +```tsx +import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing"; + + + + +; +``` + +- Always wrap in `` +- Keep effects minimal for performance +- Disable heavy effects on low-end devices via Debug panel + +## What NOT to do + +- Do not use `new THREE.Scene()` or `new THREE.WebGLRenderer()` — R3F handles this +- Do not use `requestAnimationFrame` — use `useFrame` +- Do not store per-frame values in `useState` — use `useRef` +- Do not manually append to DOM — everything goes through `` diff --git a/.agent/skills/three.md b/.agent/skills/three.md new file mode 100644 index 0000000..21e3f6e --- /dev/null +++ b/.agent/skills/three.md @@ -0,0 +1,96 @@ +# Skill — Three.js + +## AnimationMixer + +The standard way to play GLTF animations in this project: + +```tsx +import { useEffect, useRef } from "react"; +import * as THREE from "three"; +import { useFrame } from "@react-three/fiber"; +import { useGLTF, useAnimations } from "@react-three/drei"; + +export function AnimatedObject() { + const group = useRef(null); + const { scene, animations } = useGLTF("/models/object.glb"); + const { actions } = useAnimations(animations, group); + + useEffect(() => { + actions["Idle"]?.play(); + return () => { + actions["Idle"]?.stop(); + }; + }, [actions]); + + return ; +} +``` + +### Manual mixer (when drei's useAnimations is not enough) + +```tsx +const mixer = useRef(null); + +useEffect(() => { + mixer.current = new THREE.AnimationMixer(gltf.scene); + const clip = gltf.animations.find((a) => a.name === "Walk"); + if (clip) mixer.current.clipAction(clip).play(); + + return () => { + mixer.current?.stopAllAction(); + mixer.current = null; + }; +}, [gltf]); + +useFrame((_, delta) => { + mixer.current?.update(delta); +}); +``` + +## Materials + +- Prefer `meshStandardMaterial` for PBR +- Use `meshBasicMaterial` for unlit UI elements or hologram base +- Custom shaders go in `src/shaders/` as `.glsl` files + +```tsx + + + + +``` + +## Raycasting + +In R3F, raycasting is built into the event system: + +```tsx + { + e.stopPropagation() + // handle click on this mesh + }} + onPointerOver={() => setHovered(true)} + onPointerOut={() => setHovered(false)} +> +``` + +For custom raycasting, use `useThree` to access the raycaster: + +```tsx +const { raycaster, camera, scene } = useThree(); +``` + +## Rules + +- Never instantiate `THREE.WebGLRenderer` or `THREE.Scene` — R3F owns these +- Use `useThree()` to access renderer, camera, scene, gl, size +- Texture format: prefer `.jpg` for diffuse, `.png` for alpha, `.hdr`/`.exr` for HDRI +- Model format: always `.glb` (binary GLTF) — smaller and faster than `.gltf` +- Keep triangle count reasonable per zone: aim for < 100k tris visible at once diff --git a/src/utils/DebugPerf.tsx b/src/utils/DebugPerf.tsx index 55b3a91..7a3082e 100644 --- a/src/utils/DebugPerf.tsx +++ b/src/utils/DebugPerf.tsx @@ -1,11 +1,11 @@ -import { Suspense, lazy } from 'react' -const Perf = lazy(() => import('r3f-perf').then((m) => ({ default: m.Perf }))) +import { Suspense, lazy } from "react"; +const Perf = lazy(() => import("r3f-perf").then((m) => ({ default: m.Perf }))); export function DebugPerf() { - const debug = new URLSearchParams(window.location.search).has('debug') - if (!debug) return null + const debug = new URLSearchParams(window.location.search).has("debug"); + if (!debug) return null; return ( - ) -} \ No newline at end of file + ); +}