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({ + + ); }