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
🔍 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:
@@ -42,3 +42,4 @@ Thumbs.db
|
|||||||
# Temporaire
|
# Temporaire
|
||||||
.backend/
|
.backend/
|
||||||
backend/
|
backend/
|
||||||
|
temp/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
```
|
||||||
Generated
-30
@@ -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.
Binary file not shown.
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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]);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user