update: add agent.md + skills

This commit is contained in:
2026-04-14 09:20:30 +02:00
parent afd72b9f6c
commit 82c4b612bf
8 changed files with 672 additions and 6 deletions
+88
View File
@@ -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 <relative_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
+91
View File
@@ -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 (
<Suspense fallback={null}>
<Perf position="top-left" />
</Suspense>
);
}
```
Usage in Canvas:
```tsx
<Canvas>
{/* scene content */}
<DebugPerf />
</Canvas>
```
## 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
+111
View File
@@ -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<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(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 (
<>
<div
ref={topRef}
style={
{
/* black bar top */
}
}
/>
<div
ref={bottomRef}
style={
{
/* black bar bottom */
}
}
/>
</>
);
}
```
## 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
+103
View File
@@ -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()`
+88
View File
@@ -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
+89
View File
@@ -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<THREE.Group>(null);
const gltf = useGLTF("/models/my-object.glb");
useFrame((_, delta) => {
// per-frame logic here
});
return <primitive ref={ref} object={gltf.scene.clone()} />;
}
```
## Rules
- Scene components return JSX with Three.js elements (`<mesh>`, `<group>`, `<primitive>`)
- 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";
<RigidBody type="fixed">
<CuboidCollider args={[10, 0.1, 10]} />
<mesh>
<boxGeometry args={[20, 0.2, 20]} />
<meshStandardMaterial />
</mesh>
</RigidBody>;
```
- Wrap physics scene in `<Physics>` 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";
<EffectComposer>
<Bloom intensity={0.5} luminanceThreshold={0.9} />
<Vignette offset={0.3} darkness={0.5} />
</EffectComposer>;
```
- Always wrap in `<EffectComposer>`
- 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 `<Canvas>`
+96
View File
@@ -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<THREE.Group>(null);
const { scene, animations } = useGLTF("/models/object.glb");
const { actions } = useAnimations(animations, group);
useEffect(() => {
actions["Idle"]?.play();
return () => {
actions["Idle"]?.stop();
};
}, [actions]);
return <primitive ref={group} object={scene.clone()} />;
}
```
### Manual mixer (when drei's useAnimations is not enough)
```tsx
const mixer = useRef<THREE.AnimationMixer | null>(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
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial
color="#4a90d9"
roughness={0.4}
metalness={0.6}
map={diffuseTexture}
normalMap={normalTexture}
/>
</mesh>
```
## Raycasting
In R3F, raycasting is built into the event system:
```tsx
<mesh
onClick={(e) => {
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
+6 -6
View File
@@ -1,11 +1,11 @@
import { Suspense, lazy } from 'react' import { Suspense, lazy } from "react";
const Perf = lazy(() => import('r3f-perf').then((m) => ({ default: m.Perf }))) const Perf = lazy(() => import("r3f-perf").then((m) => ({ default: m.Perf })));
export function DebugPerf() { export function DebugPerf() {
const debug = new URLSearchParams(window.location.search).has('debug') const debug = new URLSearchParams(window.location.search).has("debug");
if (!debug) return null if (!debug) return null;
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<Perf position="top-left" /> <Perf position="top-left" />
</Suspense> </Suspense>
) );
} }