update: add agent.md + skills
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()`
|
||||
@@ -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
|
||||
@@ -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>`
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user