diff --git a/src/App/App.component.tsx b/src/App/App.component.tsx
index eb8f5e4e..ce855dba 100644
--- a/src/App/App.component.tsx
+++ b/src/App/App.component.tsx
@@ -1,72 +1,11 @@
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
+import { AvatarDevelop } from './components/Develop';
+import { AvatarTest } from './components/Test';
+import { AvatarNova } from './components/Nova';
-import { Avatar, CAMERA } from 'src/components/Avatar';
-
-import { Sparkles, StatsGl } from '@react-three/drei';
-import { EnvironmentModel } from 'src/components/Models';
import styles from './App.module.scss';
-const AvatarDevelop: React.FC = () => (
-
-
-
-
localhost playground
-
- Drop your content in there to test the avatar props without shrinking the canvas
-
-
-
-
-
-);
-
-const AvatarTest: React.FC = () => {
- const urlParams = new URLSearchParams(window.location.search);
- const modelUrl = urlParams.get('modelUrl')
- ? decodeURIComponent(urlParams.get('modelUrl') || '')
- : 'https://models.readyplayer.me/64d61e9e17883fd73ebe5eb7.glb?morphTargets=ARKit,Eyes Extra&textureAtlas=none&lod=0';
- const zoomLevel = urlParams.get('zoomLevel')
- ? parseFloat(urlParams.get('zoomLevel') || '1')
- : CAMERA.CONTROLS.FULL_BODY.MAX_DISTANCE;
-
- return (
-
- );
-};
-
const router = createBrowserRouter([
{
path: '/',
@@ -75,9 +14,21 @@ const router = createBrowserRouter([
{
path: '/test',
element:
+ },
+ {
+ path: '/nova',
+ element:
}
]);
-const App: React.FC = () => ;
+const App: React.FC = () => (
+
+);
export default App;
diff --git a/src/App/App.module.scss b/src/App/App.module.scss
index 80326cab..ad3a34a5 100644
--- a/src/App/App.module.scss
+++ b/src/App/App.module.scss
@@ -23,44 +23,6 @@ body,
width: 100%;
}
-.settings {
- z-index: 2;
- position: fixed;
- top: 72px;
- right: 24px;
- width: 100%;
- max-width: 320px;
- background-color: rgba(243, 243, 243, 0.9);
- border: 1px solid #b9b9b9;
- border-radius: 4px;
- overflow: hidden;
-}
-
-.wrapper {
- position: relative;
- height: 100%;
- width: 100%;
-}
-
-.title {
- font-size: 18px;
- font-weight: 600;
- font-family: Arial, sans-serif;
- color: black;
- display: block;
- padding: 8px;
- margin: 0;
- border-bottom: 1px solid #b9b9b9;
-}
-
-.content {
- position: relative;
- padding: 8px;
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
.stats {
left: 80px !important;
}
diff --git a/src/App/components/Develop.tsx b/src/App/components/Develop.tsx
new file mode 100644
index 00000000..d4a4433e
--- /dev/null
+++ b/src/App/components/Develop.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { Sparkles, StatsGl } from '@react-three/drei';
+
+import { Avatar } from 'src/components/Avatar';
+import { EnvironmentModel } from 'src/components/Models';
+
+import { SettingsPanel } from './SettingsPanel';
+
+export const AvatarDevelop: React.FC = () => (
+ <>
+
+
+
+
+
+
+ >
+);
diff --git a/src/App/components/Nova.tsx b/src/App/components/Nova.tsx
new file mode 100644
index 00000000..e36c4dc6
--- /dev/null
+++ b/src/App/components/Nova.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { StatsGl } from '@react-three/drei';
+
+import { Avatar, CAMERA } from 'src/components/Avatar';
+import { emotions } from 'src/services/Stories.service';
+import { SettingsPanel } from './SettingsPanel';
+
+const idleUrl = 'https://readyplayerme-assets.s3.amazonaws.com/animations/nova-male-idle.glb';
+const victoryUrl = 'https://readyplayerme-assets.s3.amazonaws.com/animations/nova-victory-03.glb';
+const modelUrl =
+ 'https://api.readyplayer.dev/v3/avatars/66e2cecfbd5d3e60f8cdbde5.glb?meshCompression=true&textureQuality=medium&meshSimplify=0&morphTargetsGroup=Editor+combined';
+
+const animations: Record = {
+ idle: idleUrl,
+ victory: victoryUrl
+};
+
+export const AvatarNova: React.FC = () => {
+ const [activeAnimation, setActiveAnimation] = React.useState('idle');
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/App/components/SettingsPanel.module.scss b/src/App/components/SettingsPanel.module.scss
new file mode 100644
index 00000000..e97aee11
--- /dev/null
+++ b/src/App/components/SettingsPanel.module.scss
@@ -0,0 +1,37 @@
+.settings {
+ z-index: 2;
+ position: fixed;
+ top: 72px;
+ right: 24px;
+ width: 100%;
+ max-width: 320px;
+ background-color: rgba(243, 243, 243, 0.9);
+ border: 1px solid #b9b9b9;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.wrapper {
+ position: relative;
+ height: 100%;
+ width: 100%;
+}
+
+.title {
+ font-size: 18px;
+ font-weight: 600;
+ font-family: Arial, sans-serif;
+ color: black;
+ display: block;
+ padding: 8px;
+ margin: 0;
+ border-bottom: 1px solid #b9b9b9;
+}
+
+.content {
+ position: relative;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
diff --git a/src/App/components/SettingsPanel.tsx b/src/App/components/SettingsPanel.tsx
new file mode 100644
index 00000000..1ca5f2d7
--- /dev/null
+++ b/src/App/components/SettingsPanel.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import styles from './SettingsPanel.module.scss';
+
+export const SettingsPanel: React.FC = ({
+ children = 'Drop your content in there to test the avatar props without shrinking the canvas'
+}) => (
+
+
+
localhost playground
+
{children}
+
+
+);
diff --git a/src/App/components/Test.tsx b/src/App/components/Test.tsx
new file mode 100644
index 00000000..9ab4cbfc
--- /dev/null
+++ b/src/App/components/Test.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+import { Avatar, CAMERA } from 'src/components/Avatar';
+
+export const AvatarTest: React.FC = () => {
+ const urlParams = new URLSearchParams(window.location.search);
+ const modelUrl = urlParams.get('modelUrl')
+ ? decodeURIComponent(urlParams.get('modelUrl') || '')
+ : 'https://models.readyplayer.me/64d61e9e17883fd73ebe5eb7.glb?morphTargets=ARKit,Eyes Extra&textureAtlas=none&lod=0';
+ const zoomLevel = urlParams.get('zoomLevel')
+ ? parseFloat(urlParams.get('zoomLevel') || '1')
+ : CAMERA.CONTROLS.FULL_BODY.MAX_DISTANCE;
+
+ return (
+
+ );
+};
diff --git a/src/components/Avatar/Avatar.component.tsx b/src/components/Avatar/Avatar.component.tsx
index dc7720dc..d8c0d82e 100644
--- a/src/components/Avatar/Avatar.component.tsx
+++ b/src/components/Avatar/Avatar.component.tsx
@@ -17,7 +17,8 @@ import Loader from 'src/components/Loader';
import Bloom from 'src/components/Bloom/Bloom.component';
import { BlendFunction } from 'postprocessing';
import Lights from 'src/components/Lights/Lights.component';
-import { spawnState } from '../../state/spawnAtom';
+import { MultipleAnimationModel } from 'src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component';
+import { spawnState } from 'src/state/spawnAtom';
export const CAMERA = {
TARGET: {
@@ -145,6 +146,8 @@ export interface AvatarProps extends LightingProps, EnvironmentProps, Omit;
+ activeAnimation?: string;
}
/**
@@ -155,6 +158,8 @@ export interface AvatarProps extends LightingProps, EnvironmentProps, Omit = ({
modelSrc,
animationSrc = undefined,
+ animations = undefined,
+ activeAnimation = undefined,
poseSrc = undefined,
environment = 'soft',
halfBody = false,
@@ -199,6 +204,20 @@ const Avatar: FC = ({
return null;
}
+ if (!!activeAnimation && !halfBody && animations) {
+ return (
+
+ );
+ }
+
if (!!animationSrc && !halfBody && isValidFormat(animationSrc)) {
return (
= ({
return (
);
- }, [halfBody, animationSrc, modelSrc, scale, poseSrc, idleRotation, emotion, onLoaded, headMovement, effects?.bloom]);
+ }, [
+ modelSrc,
+ activeAnimation,
+ halfBody,
+ animations,
+ animationSrc,
+ poseSrc,
+ scale,
+ onLoaded,
+ emotion,
+ effects?.bloom,
+ idleRotation,
+ headMovement
+ ]);
useEffect(() => triggerCallback(onLoading), [modelSrc, animationSrc, onLoading]);
diff --git a/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx
new file mode 100644
index 00000000..3cbef69d
--- /dev/null
+++ b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx
@@ -0,0 +1,87 @@
+import React, { FC, useEffect, useRef, useState } from 'react';
+import { useFrame, useGraph } from '@react-three/fiber';
+import { AnimationAction, AnimationClip, AnimationMixer, Group } from 'three';
+import { GLTFLoader } from 'three-stdlib';
+
+import { Model } from 'src/components/Models/Model';
+import { BaseModelProps } from 'src/types';
+import { useEmotion, useGltfCachedLoader, useIdleExpression } from 'src/services';
+import { Emotion } from 'src/components/Avatar/Avatar.component';
+
+export interface MultipleAnimationModelProps extends BaseModelProps {
+ modelSrc: string | Blob;
+ animations: Record;
+ activeAnimation: string;
+ scale?: number;
+ emotion?: Emotion;
+}
+
+export const MultipleAnimationModel: FC = ({
+ modelSrc,
+ animations,
+ activeAnimation,
+ scale = 1,
+ onLoaded,
+ emotion,
+ bloom
+}) => {
+ const groupRef = useRef(null);
+ const mixerRef = useRef(null);
+ const [loadedAnimations, setLoadedAnimations] = useState>({});
+ const [activeAction, setActiveAction] = useState(null);
+
+ const { scene } = useGltfCachedLoader(modelSrc);
+ const { nodes } = useGraph(scene);
+
+ useEffect(() => {
+ if (scene && groupRef.current) {
+ groupRef.current.add(scene);
+
+ mixerRef.current = new AnimationMixer(scene);
+ }
+
+ return () => {
+ mixerRef.current?.stopAllAction();
+ };
+ }, [scene]);
+
+ useEffect(() => {
+ const loader = new GLTFLoader();
+ Object.keys(animations).forEach((name) => {
+ loader.load(animations[name], (gltf) => {
+ const newClip = gltf.animations[0];
+ setLoadedAnimations((prev) => ({
+ ...prev,
+ [name]: newClip
+ }));
+ });
+ });
+ }, [animations]);
+
+ useEffect(() => {
+ const mixer = mixerRef.current;
+ const newClip = loadedAnimations[activeAnimation];
+
+ if (!newClip || !mixer) return;
+
+ const newAction = mixer.clipAction(newClip);
+ const prevAction = activeAction;
+
+ if (prevAction) {
+ newAction.reset();
+ newAction.crossFadeFrom(prevAction, 0.5, true);
+ }
+
+ newAction.play();
+ setActiveAction(newAction);
+ }, [activeAnimation, loadedAnimations, activeAction, nodes.Armature]);
+
+ useFrame((state, delta) => {
+ mixerRef.current?.update(delta);
+ });
+
+ useEmotion(nodes, emotion);
+ useIdleExpression('blink', nodes);
+
+ return ;
+};
diff --git a/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.container.tsx b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.container.tsx
new file mode 100644
index 00000000..046477fc
--- /dev/null
+++ b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.container.tsx
@@ -0,0 +1,16 @@
+import React, { FC, Suspense, useState } from 'react';
+import { MultipleAnimationModel, MultipleAnimationModelProps } from './MultipleAnimationModel.component';
+
+/**
+ * Contains model to handle suspense fallback.
+ */
+export const MultipleAnimationModelContainer: FC = (props) => {
+ /* eslint-disable-next-line react/jsx-no-useless-fragment */
+ const [fallback, setFallback] = useState(<>>);
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/Models/MultipleAnimationModel/index.ts b/src/components/Models/MultipleAnimationModel/index.ts
new file mode 100644
index 00000000..83889092
--- /dev/null
+++ b/src/components/Models/MultipleAnimationModel/index.ts
@@ -0,0 +1 @@
+export { MultipleAnimationModelContainer as MultipleAnimationModel } from './MultipleAnimationModel.container';
diff --git a/src/services/Models.service.tsx b/src/services/Models.service.tsx
index 632fcbc1..6c20c8b9 100644
--- a/src/services/Models.service.tsx
+++ b/src/services/Models.service.tsx
@@ -101,6 +101,7 @@ interface UseHeadMovement {
rotationMargin?: Vector2;
enabled?: boolean;
}
+
/**
* Avatar head movement relative to cursor.
* When the model isn't a standard Ready Player Me avatar, the head movement won't take effect.
@@ -231,6 +232,26 @@ export const useGltfLoader = (source: Blob | string): GLTF =>
{ lifespan: 100 }
);
+export const useGltfCachedLoader = (source: Blob | string): GLTF => {
+ const cachedGltf = useRef