Merge pull request #10 from La-Fabrik-Durable/feat-animation
🔍 Lint / 🪄 Check lint (push) Waiting to run
🔍 Lint / 🎨 Check format (push) Waiting to run
🔍 Lint / 🔎 Typecheck (push) Waiting to run
🔍 Lint / 🏗 Build (push) Blocked by required conditions
📊 Quality / 🔒 Security Audit (push) Waiting to run
📊 Quality / 📋 Dependency Freshness (push) Waiting to run
📊 Quality / 📦 Bundle Size (push) Waiting to run

Feat/animation
This commit is contained in:
math-pixel
2026-04-29 11:50:07 +02:00
committed by GitHub
23 changed files with 798 additions and 34 deletions
+1
View File
@@ -42,3 +42,4 @@ Thumbs.db
# Temporaire # Temporaire
.backend/ .backend/
backend/ backend/
temp/
+338
View File
@@ -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";
<SimpleModel
modelPath="/models/elecsimple/model.gltf"
position={[0, 0, -5]}
rotation={[0, 45, 0]}
scale={1}
castShadow={true}
receiveShadow={true}
/>;
```
### 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
<AnimatedModel
modelPath="/models/elec/model.gltf"
defaultAnimation="Idle"
position={[0, 0, -5]}
rotation={[0, 0, 0]}
scale={0.01}
autoPlay={true}
speed={1}
fadeDuration={0.3}
/>;
```
### 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 (
<mesh onClick={() => play("Run", 0.5)}>
<boxGeometry />
</mesh>
);
}
// Usage
<AnimatedModel
modelPath="/models/elec/model.gltf"
defaultAnimation="Idle"
position={[0, 0, -5]}
scale={0.01}
>
<AnimationController />
</AnimatedModel>;
```
### 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 (
<group>
<mesh onClick={handleWalk} position={[-1, 0, 0]}>
<boxGeometry />
</mesh>
<mesh onClick={handleRun} position={[0, 0, 0]}>
<boxGeometry />
</mesh>
<mesh onClick={handleIdle} position={[1, 0, 0]}>
<boxGeometry />
</mesh>
</group>
);
}
```
### 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
<GrabbableObject position={[0, 1, 0]} colliders="cuboid">
<AnimatedModel
modelPath="/models/sword/model.gltf"
defaultAnimation="Idle"
position={[0, 0, 0]}
scale={0.02}
autoPlay={true}
/>
</GrabbableObject>;
```
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 (
<AnimatedModel
modelPath="/models/elec/model.gltf"
defaultAnimation="Idle"
position={[0, 0, 0]}
scale={0.01}
autoPlay={true}
/>
);
}
// When grabbed, play "Grab" animation
<GrabbableObject
position={[0, 1, 0]}
colliders="cuboid"
onGrab={() => {
// This would require a context or store to trigger
console.log("Object grabbed!");
}}
>
<AnimatedGrabber />
</GrabbableObject>;
```
**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";
<GrabbableObject position={[0, 1, 0]} colliders="cuboid">
<mesh>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="red" />
</mesh>
</GrabbableObject>;
```
### TriggerObject
Objects that trigger events when interacted with.
```tsx
import { TriggerObject } from "@/components/3d";
<TriggerObject
position={[0, 1, 0]}
soundPath="/sounds/click.mp3"
onTrigger={() => console.log("Triggered!")}
>
<mesh>
<sphereGeometry />
<meshStandardMaterial color="blue" />
</mesh>
</TriggerObject>;
```
### InteractableObject
Base object for interactions.
```tsx
import { InteractableObject } from "@/components/3d";
<InteractableObject
position={[0, 1, 0]}
onInteract={() => console.log("Interacted!")}
>
<mesh>
<cylinderGeometry />
<meshStandardMaterial color="green" />
</mesh>
</InteractableObject>;
```
---
## 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)
```
-30
View File
@@ -897,9 +897,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -917,9 +914,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -937,9 +931,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -957,9 +948,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -977,9 +965,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -997,9 +982,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3133,9 +3115,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3157,9 +3136,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3181,9 +3157,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3205,9 +3178,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+190
View File
@@ -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<AnimatedModelContextValue | null>(
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<THREE.Group>(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 (
<AnimatedModelContext.Provider value={contextValue}>
<primitive object={scene} />
{children}
</AnimatedModelContext.Provider>
);
}
+42
View File
@@ -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 (
<group position={position} rotation={rotation} scale={parsedScale}>
{children ?? (
<primitive
object={scene.clone()}
castShadow={castShadow}
receiveShadow={receiveShadow}
/>
)}
</group>
);
}
+8
View File
@@ -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";
+6
View File
@@ -55,6 +55,12 @@ export const docGroups: DocGroup[] = [
subtitle: "Editing workflow", subtitle: "Editing workflow",
meta: "06", meta: "06",
}, },
{
path: "/docs/animation",
title: "Animation & 3D Model System",
subtitle: "Components and usage",
meta: "07",
},
], ],
}, },
]; ];
+107
View File
@@ -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<THREE.Group | null>;
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<THREE.Group | null>(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,
};
}
+13
View File
@@ -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 (
<DocsDocument
content={animation}
frContent={animation}
meta="07"
title="Animation & 3D Model System"
/>
);
}
+2
View File
@@ -7,6 +7,7 @@ import {
import { HomePage } from "@/pages/page"; import { HomePage } from "@/pages/page";
import { EditorPage } from "@/pages/editor/page"; import { EditorPage } from "@/pages/editor/page";
import { import {
DocsAnimationRoute,
DocsArchitectureRoute, DocsArchitectureRoute,
DocsEditorRoute, DocsEditorRoute,
DocsFeaturesRoute, DocsFeaturesRoute,
@@ -45,6 +46,7 @@ const docsChildRoutes = [
{ path: "technical-editor", component: DocsTechnicalEditorRoute }, { path: "technical-editor", component: DocsTechnicalEditorRoute },
{ path: "features", component: DocsFeaturesRoute }, { path: "features", component: DocsFeaturesRoute },
{ path: "editor", component: DocsEditorRoute }, { path: "editor", component: DocsEditorRoute },
{ path: "animation", component: DocsAnimationRoute },
].map(({ path, component }) => ].map(({ path, component }) =>
createRoute({ createRoute({
getParentRoute: () => docsRoute, getParentRoute: () => docsRoute,
+14
View File
@@ -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 { export function DocsLayoutRoute(): React.JSX.Element {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
@@ -97,3 +103,11 @@ export function DocsEditorRoute(): React.JSX.Element {
</Suspense> </Suspense>
); );
} }
export function DocsAnimationRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsAnimationPage />
</Suspense>
);
}
+44 -3
View File
@@ -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 { useGLTF } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode"; import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
@@ -6,6 +8,42 @@ import { loadMapSceneData } from "@/utils/loadMapSceneData";
import type { OctreeReadyHandler } from "@/types/three"; import type { OctreeReadyHandler } from "@/types/three";
import type { MapNode } from "@/types/editor"; 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 { interface GameMapProps {
onOctreeReady: OctreeReadyHandler; onOctreeReady: OctreeReadyHandler;
} }
@@ -54,14 +92,17 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
<group ref={groupRef}> <group ref={groupRef}>
{!isLoading && {!isLoading &&
mapNodes.map((node, index) => ( mapNodes.map((node, index) => (
<ModelInstance key={index} node={node} /> <ModelErrorBoundary key={index}>
<ModelInstance node={node} />
</ModelErrorBoundary>
))} ))}
</group> </group>
); );
} }
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 modelPath = `/models/${node.name}/model.gltf`;
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(modelPath); const { scene } = useGLTF(modelPath);
const sceneInstance = useMemo(() => scene.clone(true), [scene]); const sceneInstance = useMemo(() => scene.clone(true), [scene]);
+9 -1
View File
@@ -1,8 +1,9 @@
import { useRef } from "react"; import { useRef } from "react";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import * as THREE from "three"; import * as THREE from "three";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { GrabbableObject } from "@/components/three/GrabbableObject"; import { GrabbableObject } from "@/components/three/GrabbableObject";
import { TriggerObject } from "@/components/three/TriggerObject"; import { TriggerObject } from "@/components/three/TriggerObject";
import { AnimatedModel } from "@/components/three/AnimatedModel";
import { import {
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS, TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
TEST_SCENE_FLOOR_POSITION, TEST_SCENE_FLOOR_POSITION,
@@ -85,6 +86,13 @@ export function TestScene({
</mesh> </mesh>
</TriggerObject> </TriggerObject>
</Physics> </Physics>
<AnimatedModel
modelPath="/models/elec/model.gltf"
defaultAnimation="Idle"
position={[0, 0, -5]}
scale={1}
/>
</> </>
); );
} }