diff --git a/src/App/components/Nova.tsx b/src/App/components/Nova.tsx index 0f414439..e2c2d880 100644 --- a/src/App/components/Nova.tsx +++ b/src/App/components/Nova.tsx @@ -1,29 +1,56 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState, FC } 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 getAnimation = (name: string) => `https://readyplayerme-assets.s3.amazonaws.com/animations/${name}.glb`; +const getAnimationNexus = (name: string) => + `https://readyplayerme-assets.s3.amazonaws.com/nexus/animations/${name}.glb`; -const models: Record = { - one: 'https://avatars.readyplayer.me/673dc84d6874800e1db46095.glb?meshCompression=true&textureQuality=low&meshLOD=1&morphTargetsGroup=Basic expressions', - two: 'https://avatars.readyplayer.me/673f109553f9ed9312fafe70.glb?meshCompression=true&textureQuality=low&meshLOD=1&morphTargetsGroup=Basic expressions', - three: - 'https://avatars.readyplayer.me/6745ca78c6e2d65e7b99d8e1.glb?meshCompression=true&textureQuality=low&meshLOD=1&morphTargetsGroup=Basic expressions', - four: 'https://avatars.readyplayer.me/6745df2bd7ae3ba1d340d019.glb?meshCompression=true&textureQuality=low&meshLOD=1&morphTargetsGroup=Basic expressions' -}; +const modelSrc = 'https://avatars.readyplayer.dev/673b3cf27b275e2ca5be2800.glb'; -export const AvatarNova: React.FC = () => { - const [activeAnimation, setActiveAnimation] = React.useState('idle'); - const [modelSrc, setModelSrc] = React.useState(models.one); +export const AvatarNova: FC = () => { + const [activeAnimation, setActiveAnimation] = useState('idle'); const animations = useMemo( () => ({ - idle: idleUrl, - victory: victoryUrl + idle: { + source: getAnimation('nova-male-idle') + }, + victory: { + source: getAnimation('nova-victory-03'), + repeat: 1 + }, + idle5: { + source: getAnimationNexus('compressed_idle_nova_5'), + repeat: 1 + }, + idle53: { + source: getAnimationNexus('compressed_idle_nova_53'), + repeat: 1 + }, + idle7: { + source: getAnimationNexus('compressed_idle_nova_7'), + repeat: 1 + }, + idle80: { + source: getAnimationNexus('compressed_idle_nova_80'), + repeat: 1 + }, + top: { + source: getAnimation('novamale_changetopwear_v001'), + repeat: 1 + }, + bottom: { + source: getAnimation('novamale_changebottomwear_01_v001'), + repeat: 1 + }, + foot: { + source: getAnimation('NovaMale_ChangeFootwear_v002'), + repeat: 1 + } }), [] ); @@ -31,24 +58,11 @@ export const AvatarNova: React.FC = () => { return ( <> - - - - - - + {Object.keys(animations).map((name) => ( + + ))} { animations={animations} activeAnimation={activeAnimation} shadows + onAnimationEnd={() => setActiveAnimation('idle')} style={{ background: 'rgb(9,20,26)' }} fov={45} cameraInitialDistance={CAMERA.CONTROLS.FULL_BODY.MAX_DISTANCE} diff --git a/src/components/Avatar/Avatar.component.tsx b/src/components/Avatar/Avatar.component.tsx index 25aae099..ac5b8fb5 100644 --- a/src/components/Avatar/Avatar.component.tsx +++ b/src/components/Avatar/Avatar.component.tsx @@ -1,5 +1,5 @@ import React, { Suspense, FC, useMemo, CSSProperties, ReactNode, useEffect } from 'react'; -import { Vector3 } from 'three'; +import { AnimationAction, Vector3 } from 'three'; import { ContactShadows } from '@react-three/drei'; import { CameraControls } from 'src/components/Scene/CameraControls.component'; import { Environment } from 'src/components/Scene/Environment.component'; @@ -9,8 +9,8 @@ import { SpawnState, EffectConfiguration, LightingProps, - AnimationConfiguration, - MaterialConfiguration + MaterialConfiguration, + AnimationsT } from 'src/types'; import { BaseCanvas } from 'src/components/BaseCanvas'; import { AnimationModel, HalfBodyModel, StaticModel, PoseModel, MultipleAnimationModel } from 'src/components/Models'; @@ -153,16 +153,13 @@ export interface AvatarProps extends LightingProps, EnvironmentProps, Omit; + animations?: AnimationsT; activeAnimation?: string; - /** - * Control properties of animations. - */ - animationConfig?: AnimationConfiguration; /** * Control properties of materials. */ materialConfig?: MaterialConfiguration; + onAnimationEnd?: (action: AnimationAction) => void; } /** @@ -207,7 +204,7 @@ const Avatar: FC = ({ backLightPosition, lightTarget, fov = 50, - animationConfig, + onAnimationEnd, materialConfig }) => { const setSpawnState = useSetAtom(spawnState); @@ -231,7 +228,7 @@ const Avatar: FC = ({ scale={scale} onLoaded={onLoaded} bloom={effects?.bloom} - animationConfig={animationConfig} + onAnimationEnd={onAnimationEnd} materialConfig={materialConfig} /> ); @@ -303,10 +300,10 @@ const Avatar: FC = ({ onLoaded, emotion, effects?.bloom, + materialConfig, + onAnimationEnd, idleRotation, - headMovement, - animationConfig, - materialConfig + 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 index b6b605d1..1a9f241c 100644 --- a/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx +++ b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx @@ -1,20 +1,20 @@ import React, { FC, useEffect, useRef } from 'react'; import { useFrame } from '@react-three/fiber'; -import { AnimationAction, AnimationMixer } from 'three'; +import { AnimationAction, AnimationMixer, LoopOnce, LoopRepeat } from 'three'; import { Model } from 'src/components/Models/Model'; -import { AnimationConfiguration, BaseModelProps } from 'src/types'; +import { AnimationsT, BaseModelProps } from 'src/types'; import { useEmotion, useFallbackScene, useGltfCachedLoader, useIdleExpression } from 'src/services'; import { Emotion } from 'src/components/Avatar/Avatar.component'; import { useAnimations } from 'src/services/Animation.service'; export interface MultipleAnimationModelProps extends BaseModelProps { modelSrc: string | Blob; - animations: Record; + animations: AnimationsT; activeAnimation: string; scale?: number; emotion?: Emotion; - animationConfig?: AnimationConfiguration; + onAnimationEnd?: (action: AnimationAction) => void; } export const MultipleAnimationModel: FC = ({ @@ -26,8 +26,8 @@ export const MultipleAnimationModel: FC = ({ onLoaded, emotion, bloom, - animationConfig, - materialConfig + materialConfig, + onAnimationEnd }) => { const mixerRef = useRef(null); const activeActionRef = useRef(null); @@ -43,7 +43,6 @@ export const MultipleAnimationModel: FC = ({ if (activeActionRef.current) { const newAction = mixer.clipAction(activeActionRef.current.getClip()); - newAction.play(); mixer.update(animationTimeRef.current); @@ -62,21 +61,41 @@ export const MultipleAnimationModel: FC = ({ const mixer = mixerRef.current; const prevAction = activeActionRef.current; const newClip = loadedAnimations[activeAnimation]; + const animationConfig = animations[activeAnimation]; - if (!newClip || !mixer) return; + if (!newClip || !mixer || !animationConfig) return; if (prevAction && prevAction.getClip().name === newClip.name) return; const newAction = mixer.clipAction(newClip); - activeActionRef.current = newAction; + const loopCount = animationConfig.repeat ?? Infinity; + const fadeTime = animationConfig.fadeTime ?? 0.5; + + newAction.setLoop(loopCount === Infinity ? LoopRepeat : LoopOnce, loopCount); + newAction.clampWhenFinished = true; + + const handleAnimationEnd = (event: { action: AnimationAction }) => { + if (event.action === newAction) { + onAnimationEnd?.(newAction); + } + }; + + mixer.addEventListener('finished', handleAnimationEnd); if (prevAction) { + prevAction.fadeOut(fadeTime); + newAction.reset().fadeIn(fadeTime); + } else { newAction.reset(); - newAction.crossFadeFrom(prevAction, animationConfig?.crossfadeDuration ?? 0.5, true); } newAction.play(); - mixer.update(0); - }, [activeAnimation, loadedAnimations, animationConfig]); + activeActionRef.current = newAction; + + // eslint-disable-next-line consistent-return + return () => { + mixer.removeEventListener('finished', handleAnimationEnd); + }; + }, [activeAnimation, animations, loadedAnimations, onAnimationEnd]); useFrame((state, delta) => { mixerRef.current?.update(delta); diff --git a/src/services/Animation.service.ts b/src/services/Animation.service.ts index 06380686..d4ffa335 100644 --- a/src/services/Animation.service.ts +++ b/src/services/Animation.service.ts @@ -3,6 +3,7 @@ import { AnimationClip, Group } from 'three'; import { FBXLoader, GLTFLoader } from 'three-stdlib'; import { suspend } from 'suspend-react'; import { MeshoptDecoder } from './meshopt_decoder'; +import { AnimationsT } from '../types'; interface ClipWithType { group: Group; @@ -72,13 +73,13 @@ export const loadAnimationClip = async (source: Blob | string): Promise) => +export const useAnimations = (animations: AnimationsT) => suspend(async (): Promise> => { const clips: Record = {}; await Promise.all( Object.keys(animations).map(async (name) => { - const newClip = await loadAnimationClip(animations[name]); + const newClip = await loadAnimationClip(animations[name].source); clips[name] = newClip; }) ); diff --git a/src/types/index.ts b/src/types/index.ts index 15632148..87132087 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -194,3 +194,12 @@ export type MaterialConfiguration = { */ emissiveIntensity?: number; }; + +export type AnimationsT = Record< + string, + { + source: string; + repeat?: number; + fadeTime?: number; + } +>; diff --git a/test/functional/cypress-image-diff-screenshots/baseline/visual.cy.ts-avatar-zoom-0.5-side-[1].png b/test/functional/cypress-image-diff-screenshots/baseline/visual.cy.ts-avatar-zoom-0.5-side-[1].png index 3f5d942a..03d2b016 100644 Binary files a/test/functional/cypress-image-diff-screenshots/baseline/visual.cy.ts-avatar-zoom-0.5-side-[1].png and b/test/functional/cypress-image-diff-screenshots/baseline/visual.cy.ts-avatar-zoom-0.5-side-[1].png differ