diff --git a/.gitignore b/.gitignore
index 9b04b43..7657786 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,3 +42,4 @@ Thumbs.db
# Temporaire
.backend/
backend/
+temp/
diff --git a/docs/technical/animation.md b/docs/technical/animation.md
new file mode 100644
index 0000000..3bbd2b2
--- /dev/null
+++ b/docs/technical/animation.md
@@ -0,0 +1,338 @@
+# Animation & 3D Model System
+
+This document describes how to use the 3D model components and animation system in La-Fabrik.
+
+## Table of Contents
+
+1. [Model Types Overview](#model-types-overview)
+2. [SimpleModel - Static Models](#simplemodel---static-models)
+3. [AnimatedModel - Animated Models](#animatedmodel---animated-models)
+4. [Animation Control](#animation-control)
+5. [Other 3D Components](#other-3d-components)
+6. [Technical Notes](#technical-notes)
+
+---
+
+## Model Types Overview
+
+The project provides three main types of model instantiation:
+
+| Type | Component | Use Case |
+| ----------- | -------------------------------------------------------- | -------------------------------------------- |
+| Static | `SimpleModel` | Props, decoration, objects without animation |
+| Animated | `AnimatedModel` | Characters, animated objects with skeleton |
+| Interactive | `GrabbableObject`, `TriggerObject`, `InteractableObject` | Objects player can interact with |
+
+---
+
+## SimpleModel - Static Models
+
+Use for GLTF models **without** skeleton/armature and no animations.
+
+```tsx
+import { SimpleModel } from "@/components/3d";
+
+;
+```
+
+### Props
+
+| Prop | Type | Default | Description |
+| --------------- | ------------------------ | ----------- | --------------------------------- |
+| `modelPath` | `string` | required | Path to GLTF file in `/public` |
+| `position` | `Vector3Tuple` | `[0, 0, 0]` | World position [x, y, z] |
+| `rotation` | `Vector3Tuple` | `[0, 0, 0]` | Rotation in degrees [x, y, z] |
+| `scale` | `number \| Vector3Tuple` | `1` | Scale factor or [x, y, z] |
+| `castShadow` | `boolean` | `true` | Enable shadow casting |
+| `receiveShadow` | `boolean` | `true` | Enable shadow receiving |
+| `children` | `ReactNode` | - | Child components to render inside |
+
+---
+
+## AnimatedModel - Animated Models
+
+Use for GLTF models **with** skeleton/armature and animations (like Mixamo characters).
+
+```tsx
+import { AnimatedModel, useAnimatedModel } from "@/components/3d";
+
+// Basic usage
+;
+```
+
+### Props
+
+| Prop | Type | Default | Description |
+| ------------------ | ------------------------ | ----------- | --------------------------------------------- |
+| `modelPath` | `string` | required | Path to GLTF file in `/public` |
+| `defaultAnimation` | `string` | `"Idle"` | Animation name to play by default |
+| `animations` | `string[]` | `[]` | List of animation names (optional) |
+| `position` | `Vector3Tuple` | `[0, 0, 0]` | World position [x, y, z] |
+| `rotation` | `Vector3Tuple` | `[0, 0, 0]` | Rotation in degrees [x, y, z] |
+| `scale` | `number \| Vector3Tuple` | `1` | Scale factor |
+| `autoPlay` | `boolean` | `true` | Auto-play default animation |
+| `speed` | `number` | `1` | Animation playback speed |
+| `fadeDuration` | `number` | `0.3` | Transition duration in seconds |
+| `onLoaded` | `() => void` | - | Callback when model loads |
+| `onAnimationEnd` | `(name: string) => void` | - | Callback when animation ends |
+| `children` | `ReactNode` | - | Child components (can use `useAnimatedModel`) |
+
+### Important: Scale
+
+Animated models (like Mixamo exports) often need a small scale (e.g., `0.01`) because they are exported in meters while Three.js uses different units. Adjust until the model appears at the right size.
+
+---
+
+## Animation Control
+
+To control animations from inside or outside the `AnimatedModel`, use the `useAnimatedModel` hook.
+
+### Basic Control
+
+```tsx
+import { AnimatedModel, useAnimatedModel } from "@/components/3d";
+
+// Create a controller component to use inside AnimatedModel
+function AnimationController() {
+ const { play, stop, fadeTo, currentAnimation, names, setSpeed, isReady } =
+ useAnimatedModel();
+
+ // names contains all available animation names
+ // currentAnimation is the name of the currently playing animation
+ // isReady is true when model and animations are loaded
+
+ return (
+ play("Run", 0.5)}>
+
+
+ );
+}
+
+// Usage
+
+
+;
+```
+
+### Available Methods
+
+| Method | Signature | Description |
+| ------------------ | --------------------------------------- | ------------------------------------ |
+| `play` | `(name: string, fade?: number) => void` | Play animation with optional fade |
+| `fadeTo` | `(name: string, fade?: number) => void` | Fade to another animation |
+| `stop` | `(fade?: number) => void` | Stop and return to default animation |
+| `setSpeed` | `(speed: number) => void` | Set animation speed |
+| `currentAnimation` | `string` | Current animation name (getter) |
+| `names` | `string[]` | Available animation names |
+| `isReady` | `boolean` | Whether model is loaded |
+
+### Transition Example
+
+```tsx
+function Character() {
+ const { play, fadeTo, currentAnimation } = useAnimatedModel();
+
+ const handleWalk = () => fadeTo("Walk", 0.5); // 0.5s fade
+ const handleRun = () => play("Run", 0.3); // 0.3s fade
+ const handleIdle = () => play("Idle", 0.5); // return to idle
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+### Combined: GrabbableObject with Animation
+
+You can combine `AnimatedModel` inside `GrabbableObject` to create animated objects that can be picked up:
+
+```tsx
+import { AnimatedModel, GrabbableObject } from "@/components/3d";
+
+// Animated weapon/tool that player can pick up
+
+
+;
+```
+
+Or create an animated character that can be grabbed:
+
+```tsx
+import {
+ AnimatedModel,
+ GrabbableObject,
+ useAnimatedModel,
+} from "@/components/3d";
+
+// Controller that triggers animations when grabbed
+function AnimatedGrabber() {
+ const { play, fadeTo } = useAnimatedModel();
+
+ return (
+
+ );
+}
+
+// When grabbed, play "Grab" animation
+ {
+ // This would require a context or store to trigger
+ console.log("Object grabbed!");
+ }}
+>
+
+;
+```
+
+**Note:** For complex interactions (like playing specific animations when grabbing), you'll need to connect the grab events to animation controls via a state manager or context.
+
+---
+
+## Other 3D Components
+
+### GrabbableObject
+
+Objects that can be picked up by the player.
+
+```tsx
+import { GrabbableObject } from "@/components/3d";
+
+
+
+
+
+
+;
+```
+
+### TriggerObject
+
+Objects that trigger events when interacted with.
+
+```tsx
+import { TriggerObject } from "@/components/3d";
+
+ console.log("Triggered!")}
+>
+
+
+
+
+;
+```
+
+### InteractableObject
+
+Base object for interactions.
+
+```tsx
+import { InteractableObject } from "@/components/3d";
+
+ console.log("Interacted!")}
+>
+
+
+
+
+;
+```
+
+---
+
+## Technical Notes
+
+### GLTF Models
+
+- Models should be placed in `/public/models/`
+- Supported formats: `.gltf`, `.glb`
+- Animated models must have an Armature/skeleton for animations to work
+
+### Model Scale Issue
+
+If animated models don't appear, they may be too small or too large. Try:
+
+- Scale `0.01` for Mixamo-exported models
+- Scale `1` for models in correct units
+
+### Cloning
+
+- `SimpleModel` uses `scene.clone()` for proper React lifecycle
+- `AnimatedModel` uses the original scene directly to preserve SkinnedMesh + Armature structure
+
+### Animation System
+
+The animation system uses:
+
+- `@react-three/drei`: `useGLTF` for loading, `useAnimations` for animation control
+- Three.js: `AnimationMixer` for playback
+
+### No State Machine
+
+This system intentionally avoids complex state machines (like Unity's Animator). For simple animation transitions, use the `play`, `fadeTo`, and `stop` methods directly.
+
+---
+
+## File Structure
+
+```
+src/
+├── components/3d/
+│ ├── AnimatedModel.tsx # Animated model component + context
+│ ├── SimpleModel.tsx # Static model component
+│ ├── GrabbableObject.tsx # Pickable object
+│ ├── TriggerObject.tsx # Trigger event object
+│ ├── InteractableObject.tsx
+│ └── index.ts # Central exports
+└── hooks/
+ └── useCharacterAnimation.ts # Animation hook (legacy)
+```
diff --git a/package-lock.json b/package-lock.json
index baae035..aa6d43a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -897,9 +897,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -917,9 +914,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -937,9 +931,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -957,9 +948,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -977,9 +965,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -997,9 +982,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3133,9 +3115,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3157,9 +3136,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3181,9 +3157,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3205,9 +3178,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
diff --git a/public/models/elec/Mat_baseColor.png b/public/models/elec/Mat_baseColor.png
new file mode 100644
index 0000000..ba0de35
--- /dev/null
+++ b/public/models/elec/Mat_baseColor.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:142be230a66ff6bebe321b373e4785283624c3bb5f3565114a6acca6e2d056f2
+size 691735
diff --git a/public/models/elec/Mat_normal.png b/public/models/elec/Mat_normal.png
new file mode 100644
index 0000000..c72e6e2
--- /dev/null
+++ b/public/models/elec/Mat_normal.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4f4580790707fc1fc505b3b725a523eac3e985353bc2e566a73ae2d983e87029
+size 1229760
diff --git a/public/models/elec/Mat_occlusionRoughnessMetallic.png b/public/models/elec/Mat_occlusionRoughnessMetallic.png
new file mode 100644
index 0000000..dc6530c
--- /dev/null
+++ b/public/models/elec/Mat_occlusionRoughnessMetallic.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2abdb28a5b27842d8958480f97357a3603b2c0ab46db9ff6bf08e474600c5d49
+size 650826
diff --git a/public/models/elec/model.bin b/public/models/elec/model.bin
new file mode 100644
index 0000000..96e13e8
Binary files /dev/null and b/public/models/elec/model.bin differ
diff --git a/public/models/elec/model.gltf b/public/models/elec/model.gltf
new file mode 100644
index 0000000..fc3c84f
--- /dev/null
+++ b/public/models/elec/model.gltf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:35129131d3f1d70b9648b5ea09d704ffecaab6b52aa03c0ab32b476349b25f92
+size 47180
diff --git a/public/models/elecsimple/Mat_baseColor.png b/public/models/elecsimple/Mat_baseColor.png
new file mode 100644
index 0000000..ba0de35
--- /dev/null
+++ b/public/models/elecsimple/Mat_baseColor.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:142be230a66ff6bebe321b373e4785283624c3bb5f3565114a6acca6e2d056f2
+size 691735
diff --git a/public/models/elecsimple/Mat_normal.png b/public/models/elecsimple/Mat_normal.png
new file mode 100644
index 0000000..66ca144
--- /dev/null
+++ b/public/models/elecsimple/Mat_normal.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:de14342c19b038a504840385d616c239851b90a289dac974fb6f93e7f3c03b99
+size 3374459
diff --git a/public/models/elecsimple/Mat_occlusionRoughnessMetallic.png b/public/models/elecsimple/Mat_occlusionRoughnessMetallic.png
new file mode 100644
index 0000000..dc6530c
--- /dev/null
+++ b/public/models/elecsimple/Mat_occlusionRoughnessMetallic.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2abdb28a5b27842d8958480f97357a3603b2c0ab46db9ff6bf08e474600c5d49
+size 650826
diff --git a/public/models/elecsimple/electricienne.bin b/public/models/elecsimple/electricienne.bin
new file mode 100644
index 0000000..4f743f0
Binary files /dev/null and b/public/models/elecsimple/electricienne.bin differ
diff --git a/public/models/elecsimple/model.gltf b/public/models/elecsimple/model.gltf
new file mode 100644
index 0000000..53ed71b
--- /dev/null
+++ b/public/models/elecsimple/model.gltf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:efaeba23122c987375bcbd9c920cea85b0eea02205fe3164e2dcc076e24fa71d
+size 3113
diff --git a/src/components/three/AnimatedModel.tsx b/src/components/three/AnimatedModel.tsx
new file mode 100644
index 0000000..f9c5991
--- /dev/null
+++ b/src/components/three/AnimatedModel.tsx
@@ -0,0 +1,190 @@
+/* eslint-disable react-hooks/immutability */
+import { createContext, useRef, useState, useEffect, useCallback } from "react";
+import { useGLTF, useAnimations } from "@react-three/drei";
+import type { AnimationAction } from "three";
+import * as THREE from "three";
+import type { Vector3Tuple } from "@/types/three";
+
+export interface AnimatedModelConfig {
+ modelPath: string;
+ animations?: string[];
+ defaultAnimation?: string;
+ position?: Vector3Tuple;
+ rotation?: Vector3Tuple;
+ scale?: Vector3Tuple | number;
+ fadeDuration?: number;
+ speed?: number;
+ autoPlay?: boolean;
+ onLoaded?: () => void;
+ onAnimationEnd?: (animationName: string) => void;
+}
+
+export interface AnimatedModelContextValue {
+ play: (name: string, fade?: number) => void;
+ stop: (fade?: number) => void;
+ fadeTo: (name: string, fade?: number) => void;
+ currentAnimation: string;
+ isReady: boolean;
+ setSpeed: (speed: number) => void;
+ names: string[];
+}
+
+const AnimatedModelContext = createContext(
+ null,
+);
+
+export { AnimatedModelContext };
+
+interface AnimatedModelProps extends AnimatedModelConfig {
+ children?: React.ReactNode;
+}
+
+export function AnimatedModel({
+ modelPath,
+ defaultAnimation = "Idle",
+ position = [0, 0, 0],
+ rotation = [0, 0, 0],
+ scale = 1,
+ fadeDuration = 0.3,
+ speed = 1,
+ autoPlay = true,
+ onLoaded,
+ onAnimationEnd,
+ children,
+}: AnimatedModelProps): React.JSX.Element {
+ const groupRef = useRef(null);
+
+ void groupRef;
+ const { scene, animations } = useGLTF(modelPath);
+ const { actions, names, mixer } = useAnimations(animations, scene);
+
+ const [currentAnim, setCurrentAnim] = useState(defaultAnimation);
+ const [isReady, setIsReady] = useState(false);
+
+ useEffect(() => {
+ if (mixer) {
+ mixer.timeScale = speed;
+ }
+ }, [mixer, speed]);
+
+ useEffect(() => {
+ const handleFinished = (e: { action: AnimationAction }) => {
+ const clipName = e.action.getClip().name;
+ onAnimationEnd?.(clipName);
+ };
+
+ if (mixer) {
+ mixer.addEventListener("finished", handleFinished);
+ return () => {
+ mixer.removeEventListener("finished", handleFinished);
+ };
+ }
+ }, [mixer, onAnimationEnd]);
+
+ const play = useCallback(
+ (name: string, fade = fadeDuration) => {
+ const action = actions[name];
+ if (action) {
+ Object.values(actions).forEach((a) => {
+ if (a && a !== action) a.fadeOut(fade);
+ });
+ action.reset().fadeIn(fade).play();
+ setCurrentAnim(name);
+ }
+ },
+ [actions, fadeDuration],
+ );
+
+ const stop = useCallback(
+ (fade = fadeDuration) => {
+ Object.values(actions).forEach((a) => a?.fadeOut(fade));
+ const defaultAction = actions[defaultAnimation];
+ if (defaultAction) {
+ defaultAction.reset().fadeIn(fade).play();
+ setCurrentAnim(defaultAnimation);
+ }
+ },
+ [actions, defaultAnimation, fadeDuration],
+ );
+
+ const fadeTo = useCallback(
+ (name: string, fade = fadeDuration) => {
+ const action = actions[name];
+ if (action) {
+ Object.values(actions).forEach((a) => {
+ if (a && a !== action) a.fadeOut(fade);
+ });
+ action.reset().fadeIn(fade).play();
+ setCurrentAnim(name);
+ }
+ },
+ [actions, fadeDuration],
+ );
+
+ const setSpeed = useCallback(
+ (newSpeed: number) => {
+ if (mixer) {
+ mixer.timeScale = newSpeed;
+ }
+ },
+ [mixer],
+ );
+
+ useEffect(() => {
+ if (!autoPlay || names.length === 0) {
+ console.log("[AnimatedModel] No animation found in model");
+ return;
+ }
+
+ console.log(`[AnimatedModel] Available animations: ${names.join(", ")}`);
+
+ let defaultAction = actions[defaultAnimation as string];
+
+ if (!defaultAction && names.length > 0) {
+ console.log(
+ `[AnimatedModel] "${defaultAnimation}" not found, using: ${names[0]}`,
+ );
+ defaultAction = actions[names[0] as string];
+ }
+
+ if (defaultAction) {
+ defaultAction.play();
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setIsReady(true);
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setCurrentAnim(defaultAction.getClip().name);
+ onLoaded?.();
+ } else {
+ console.log("[AnimatedModel] No available animation in actions");
+ }
+ }, [actions, defaultAnimation, names, autoPlay, onLoaded]);
+
+ const contextValue: AnimatedModelContextValue = {
+ play,
+ stop,
+ fadeTo,
+ currentAnimation: currentAnim,
+ isReady,
+ setSpeed,
+ names,
+ };
+
+ useEffect(() => {
+ scene.position.set(...position);
+ scene.rotation.set(
+ (rotation[0] * Math.PI) / 180,
+ (rotation[1] * Math.PI) / 180,
+ (rotation[2] * Math.PI) / 180,
+ );
+ const s =
+ typeof scale === "number" ? [scale, scale, scale] : (scale ?? [1, 1, 1]);
+ scene.scale.set(s[0] ?? 1, s[1] ?? 1, s[2] ?? 1);
+ }, [scene, position, rotation, scale]);
+
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/components/three/SimpleModel.tsx b/src/components/three/SimpleModel.tsx
new file mode 100644
index 0000000..cfa6e83
--- /dev/null
+++ b/src/components/three/SimpleModel.tsx
@@ -0,0 +1,42 @@
+import { useGLTF } from "@react-three/drei";
+import type { Vector3Tuple } from "@/types/3d";
+
+export interface SimpleModelConfig {
+ modelPath: string;
+ position?: Vector3Tuple;
+ rotation?: Vector3Tuple;
+ scale?: Vector3Tuple | number;
+ castShadow?: boolean;
+ receiveShadow?: boolean;
+}
+
+interface SimpleModelProps extends SimpleModelConfig {
+ children?: React.ReactNode;
+}
+
+export function SimpleModel({
+ modelPath,
+ position = [0, 0, 0],
+ rotation = [0, 0, 0],
+ scale = 1,
+ castShadow = true,
+ receiveShadow = true,
+ children,
+}: SimpleModelProps): React.JSX.Element {
+ const { scene } = useGLTF(modelPath);
+
+ const parsedScale =
+ typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
+
+ return (
+
+ {children ?? (
+
+ )}
+
+ );
+}
diff --git a/src/components/three/index.ts b/src/components/three/index.ts
new file mode 100644
index 0000000..d8516b8
--- /dev/null
+++ b/src/components/three/index.ts
@@ -0,0 +1,8 @@
+export { AnimatedModel, useAnimatedModel } from "./AnimatedModel";
+export type { AnimatedModelConfig } from "./AnimatedModel";
+
+export { SimpleModel } from "./SimpleModel";
+export type { SimpleModelConfig } from "./SimpleModel";
+
+export { useCharacterAnimation } from "@/hooks/useCharacterAnimation";
+export type { CharacterAnimationConfig } from "@/hooks/useCharacterAnimation";
diff --git a/src/data/docs/docsSections.ts b/src/data/docs/docsSections.ts
index 2fd4f9e..8c4aad6 100644
--- a/src/data/docs/docsSections.ts
+++ b/src/data/docs/docsSections.ts
@@ -55,6 +55,12 @@ export const docGroups: DocGroup[] = [
subtitle: "Editing workflow",
meta: "06",
},
+ {
+ path: "/docs/animation",
+ title: "Animation & 3D Model System",
+ subtitle: "Components and usage",
+ meta: "07",
+ },
],
},
];
diff --git a/src/hooks/useCharacterAnimation.ts b/src/hooks/useCharacterAnimation.ts
new file mode 100644
index 0000000..c9dcdb5
--- /dev/null
+++ b/src/hooks/useCharacterAnimation.ts
@@ -0,0 +1,107 @@
+/* eslint-disable react-hooks/immutability */
+import { useRef, useEffect, useState, useCallback } from "react";
+import { useGLTF, useAnimations } from "@react-three/drei";
+import type { AnimationAction, AnimationMixer } from "three";
+import * as THREE from "three";
+
+export interface CharacterAnimationConfig {
+ modelPath: string;
+ initialAnimation?: string;
+ fadeDuration?: number;
+}
+
+interface UseCharacterAnimationReturn {
+ scene: THREE.Group;
+ actions: { [key: string]: AnimationAction | null };
+ names: string[];
+ mixer: AnimationMixer;
+ groupRef: React.MutableRefObject;
+ currentAnimation: string;
+ play: (name: string) => void;
+ stop: () => void;
+ fadeTo: (name: string, duration?: number) => void;
+ setAnimationSpeed: (speed: number) => void;
+}
+
+const DEFAULT_FADE_DURATION = 0.3;
+
+export function useCharacterAnimation(
+ config: CharacterAnimationConfig,
+): UseCharacterAnimationReturn {
+ const {
+ modelPath,
+ initialAnimation = "Idle",
+ fadeDuration = DEFAULT_FADE_DURATION,
+ } = config;
+
+ const groupRef = useRef(null);
+ const { scene, animations } = useGLTF(modelPath);
+ const { actions, names, mixer } = useAnimations(animations, groupRef);
+ const [currentAnimation, setCurrentAnimation] = useState(initialAnimation);
+
+ const play = useCallback(
+ (name: string) => {
+ const action = actions[name];
+ if (action) {
+ Object.values(actions).forEach((a) => {
+ if (a && a !== action) a.fadeOut(fadeDuration);
+ });
+ action.reset().fadeIn(fadeDuration).play();
+ setCurrentAnimation(name);
+ }
+ },
+ [actions, fadeDuration],
+ );
+
+ const stop = useCallback(() => {
+ Object.values(actions).forEach((a) => a?.fadeOut(fadeDuration));
+ const defaultAction = actions[initialAnimation as string];
+ if (defaultAction) {
+ defaultAction.reset().fadeIn(fadeDuration).play();
+ setCurrentAnimation(initialAnimation);
+ }
+ }, [actions, initialAnimation, fadeDuration]);
+
+ const fadeTo = useCallback(
+ (name: string, duration = fadeDuration) => {
+ const targetAction = actions[name];
+ if (targetAction) {
+ Object.values(actions).forEach((a) => {
+ if (a && a !== targetAction) a.fadeOut(duration);
+ });
+ targetAction.reset().fadeIn(duration).play();
+ setCurrentAnimation(name);
+ }
+ },
+ [actions, fadeDuration],
+ );
+
+ const setAnimationSpeed = useCallback(
+ (speed: number) => {
+ if (mixer) {
+ mixer.timeScale = speed;
+ }
+ },
+ [mixer],
+ );
+
+ useEffect(() => {
+ const defaultAction = actions[initialAnimation as string];
+ if (defaultAction) {
+ defaultAction.play();
+ }
+ }, [actions, initialAnimation]);
+
+ return {
+ scene,
+ actions,
+ names,
+ mixer,
+ groupRef,
+ currentAnimation,
+ play,
+ stop,
+ fadeTo,
+ setAnimationSpeed,
+ };
+}
diff --git a/src/pages/docs/animation/page.tsx b/src/pages/docs/animation/page.tsx
new file mode 100644
index 0000000..93975ce
--- /dev/null
+++ b/src/pages/docs/animation/page.tsx
@@ -0,0 +1,13 @@
+import animation from "../../../../docs/technical/animation.md?raw";
+import { DocsDocument } from "@/components/docs/DocsDocument";
+
+export function DocsAnimationPage(): React.JSX.Element {
+ return (
+
+ );
+}
diff --git a/src/router.tsx b/src/router.tsx
index 4bb68a2..d17b5c1 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -7,6 +7,7 @@ import {
import { HomePage } from "@/pages/page";
import { EditorPage } from "@/pages/editor/page";
import {
+ DocsAnimationRoute,
DocsArchitectureRoute,
DocsEditorRoute,
DocsFeaturesRoute,
@@ -45,6 +46,7 @@ const docsChildRoutes = [
{ path: "technical-editor", component: DocsTechnicalEditorRoute },
{ path: "features", component: DocsFeaturesRoute },
{ path: "editor", component: DocsEditorRoute },
+ { path: "animation", component: DocsAnimationRoute },
].map(({ path, component }) =>
createRoute({
getParentRoute: () => docsRoute,
diff --git a/src/routes/docs/DocsRouteComponents.tsx b/src/routes/docs/DocsRouteComponents.tsx
index 01ef28c..51f96bd 100644
--- a/src/routes/docs/DocsRouteComponents.tsx
+++ b/src/routes/docs/DocsRouteComponents.tsx
@@ -42,6 +42,12 @@ const LazyDocsEditorPage = lazy(() =>
})),
);
+const LazyDocsAnimationPage = lazy(() =>
+ import("@/pages/docs/animation/page").then((module) => ({
+ default: module.DocsAnimationPage,
+ })),
+);
+
export function DocsLayoutRoute(): React.JSX.Element {
return (
@@ -97,3 +103,11 @@ export function DocsEditorRoute(): React.JSX.Element {
);
}
+
+export function DocsAnimationRoute(): React.JSX.Element {
+ return (
+
+
+
+ );
+}
diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx
index dd7faaa..929f93e 100644
--- a/src/world/GameMap.tsx
+++ b/src/world/GameMap.tsx
@@ -1,4 +1,6 @@
-import { useEffect, useMemo, useState, useRef } from "react";
+import type { ReactNode } from "react";
+import { Component } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
@@ -6,6 +8,42 @@ import { loadMapSceneData } from "@/utils/loadMapSceneData";
import type { OctreeReadyHandler } from "@/types/three";
import type { MapNode } from "@/types/editor";
+interface ErrorBoundaryProps {
+ children: ReactNode;
+ fallback?: ReactNode;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+}
+
+class ModelErrorBoundary extends Component<
+ ErrorBoundaryProps,
+ ErrorBoundaryState
+> {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ static getDerivedStateFromError(_error: Error): ErrorBoundaryState {
+ return { hasError: true };
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ componentDidCatch(_error: Error): void {
+ console.warn(`Failed to load model`);
+ }
+
+ render(): ReactNode {
+ if (this.state.hasError) {
+ return this.props.fallback ?? null;
+ }
+ return this.props.children;
+ }
+}
+
interface GameMapProps {
onOctreeReady: OctreeReadyHandler;
}
@@ -54,14 +92,17 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
{!isLoading &&
mapNodes.map((node, index) => (
-
+
+
+
))}
);
}
-function ModelInstance({ node }: { node: MapNode }): React.JSX.Element {
+function ModelInstance({ node }: { node: MapNode }): React.JSX.Element | null {
const modelPath = `/models/${node.name}/model.gltf`;
+
const groupRef = useRef(null);
const { scene } = useGLTF(modelPath);
const sceneInstance = useMemo(() => scene.clone(true), [scene]);
diff --git a/src/world/debug/TestScene.tsx b/src/world/debug/TestScene.tsx
index edf612f..c3bd528 100644
--- a/src/world/debug/TestScene.tsx
+++ b/src/world/debug/TestScene.tsx
@@ -1,8 +1,9 @@
import { useRef } from "react";
-import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import * as THREE from "three";
+import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { GrabbableObject } from "@/components/three/GrabbableObject";
import { TriggerObject } from "@/components/three/TriggerObject";
+import { AnimatedModel } from "@/components/three/AnimatedModel";
import {
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
TEST_SCENE_FLOOR_POSITION,
@@ -85,6 +86,13 @@ export function TestScene({
+
+
>
);
}