diff --git a/examples/react-vite-consumer/src/main.tsx b/examples/react-vite-consumer/src/main.tsx index 42adab9..d125c74 100644 --- a/examples/react-vite-consumer/src/main.tsx +++ b/examples/react-vite-consumer/src/main.tsx @@ -1,7 +1,7 @@ import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core' -import { Avatar, type AvatarController } from '@bible-strong/avatar-react' +import { Avatar } from '@bible-strong/avatar-react' import '@bible-strong/avatar-react/styles.css' -import { StrictMode, useRef, useState } from 'react' +import { StrictMode, useState } from 'react' import { createRoot } from 'react-dom/client' import definitionJson from './strobi.avatar.json' @@ -10,39 +10,79 @@ import './styles.css' const validation = validateAvatarDefinition(definitionJson) if (!validation.ok) throw new Error(validation.errors[0]?.message) const definition = validation.value as AvatarDefinition +const expressions = Object.keys(definition.expressions) +const horizontalPosition = (expression: string) => + expression.includes('left') ? 'left' : expression.includes('right') ? 'right' : 'center' +const verticalPosition = (expression: string) => + expression.includes('up') ? 'up' : expression.includes('down') ? 'down' : 'middle' +const expressionGroups = [ + ['up', 'left', 'Up left'], + ['up', 'center', 'Up'], + ['up', 'right', 'Up right'], + ['middle', 'left', 'Left'], + ['middle', 'center', 'Neutral'], + ['middle', 'right', 'Right'], + ['down', 'left', 'Down left'], + ['down', 'center', 'Down'], + ['down', 'right', 'Down right'], +].map(([vertical, horizontal, label]) => ({ + key: `${vertical}-${horizontal}`, + label, + expressions: expressions.filter( + expression => + verticalPosition(expression) === vertical && horizontalPosition(expression) === horizontal + ), +})) + +const formatExpressionName = (expression: string) => + expression.replaceAll('-', ' ').replace(/\b\w/g, letter => letter.toUpperCase()) function Demo() { - const controller = useRef(null) - const [status, setStatus] = useState('neutral') - const play = () => { - const result = controller.current?.play('idle') - setStatus(result?.ok ? 'idle' : (result?.error.code ?? 'unavailable')) - } - const setNeutral = () => { - const result = controller.current?.setExpression('neutral') - setStatus(result?.ok ? 'neutral' : (result?.error.code ?? 'unavailable')) - } + const [expression, setExpression] = useState('neutral') + return (

Avatar package consumer

- - - {status} +
+
+

Available expressions ({expressions.length})

+ Active: {formatExpressionName(expression)} +
+
+ {expressionGroups.map(group => ( +
+

{group.label}

+
+ {group.expressions.map(key => ( + + ))} +
+
+ ))} +
+
{ }) }) + it('interpolates and completes a direct expression transition', () => { + const state = { + ...createAvatarPlaybackState(), + activeExpression: 'curious-left', + status: 'playing' as const, + directTransition: { + from: 'neutral', + startedAt: 1_000, + durationMs: 400, + transition: 'smooth' as const, + }, + } + const start = renderAvatarFrame(definition, state, 1_000, { random: () => 0.5 }) + const midway = renderAvatarFrame(definition, state, 1_200, { random: () => 0.5 }) + const end = renderAvatarFrame(definition, state, 1_400, { random: () => 0.5 }) + + expect(midway.geometry).not.toEqual(start.geometry) + expect(midway.geometry).not.toEqual(end.geometry) + expect(advanceAvatarPlayback(definition, state, 1_400, { random: () => 0.5 })).toMatchObject({ + activeExpression: 'curious-left', + status: 'stopped', + }) + }) + it('generates the same geometry through the public definition adapter', () => { const scene = renderAvatarDefinition(definition, 'curious-left') const body = bodyFromDefinition(definition.body) diff --git a/packages/avatar-core/src/runtime.ts b/packages/avatar-core/src/runtime.ts index eade0e1..83f0354 100644 --- a/packages/avatar-core/src/runtime.ts +++ b/packages/avatar-core/src/runtime.ts @@ -74,6 +74,12 @@ export type AvatarPlaybackState = { pausedAt?: number blinkDueAt?: number blinkStartedAt?: number + directTransition?: { + from: ExpressionKey + startedAt: number + durationMs: number + transition: AvatarAnimationDefinition['steps'][number]['transition'] + } } export type AvatarRuntimeEnvironment = { @@ -141,6 +147,13 @@ export const advanceAvatarPlayback = ( now: number, environment: AvatarRuntimeEnvironment ): AvatarPlaybackState => { + if (state.directTransition) { + if (now < state.directTransition.startedAt + state.directTransition.durationMs) { + return { ...state } + } + const { directTransition: _directTransition, ...next } = state + return { ...next, status: 'stopped' } + } if (state.status !== 'playing' || !state.activeAnimation) return { ...state } const resolved = resolveAnimation(definition, state.activeAnimation) if (!resolved.ok || !resolved.value.steps.length) return { ...createAvatarPlaybackState() } @@ -241,7 +254,21 @@ export const renderAvatarFrame = ( ) let expression = expressionFromDefinition(state.activeExpression, targetDefinition) let blink = 1 - if (state.activeAnimation) { + if (state.directTransition && !environment.reduceMotion) { + const fromDefinition = definition.expressions[state.directTransition.from] + if (fromDefinition) { + const from = expressionFromDefinition(state.directTransition.from, fromDefinition) + const progress = easing( + state.directTransition.transition, + (now - state.directTransition.startedAt) / Math.max(state.directTransition.durationMs, 1) + ) + expression = interpolatePose( + poseFromExpression(from), + poseFromExpression(expression), + progress + ).expression + } + } else if (state.activeAnimation) { const resolved = resolveAnimation(definition, state.activeAnimation) if (resolved.ok) { const step = resolved.value.steps[state.stepIndex] diff --git a/packages/avatar-react/src/Avatar.tsx b/packages/avatar-react/src/Avatar.tsx index 1dda4cd..cd5bc53 100644 --- a/packages/avatar-react/src/Avatar.tsx +++ b/packages/avatar-react/src/Avatar.tsx @@ -31,6 +31,7 @@ import { createPortal } from 'react-dom' import './styles.css' const validatedDefinitions = new WeakSet() +const controlledExpressionTransitionMs = 420 const assertValidDefinition = (definition: AvatarDefinition) => { if (validatedDefinitions.has(definition)) return @@ -107,7 +108,11 @@ const samePlayback = (left: CorePlaybackState, right: CorePlaybackState) => left.phaseStartedAt === right.phaseStartedAt && left.transitionFrom === right.transitionFrom && left.blinkDueAt === right.blinkDueAt && - left.blinkStartedAt === right.blinkStartedAt + left.blinkStartedAt === right.blinkStartedAt && + left.directTransition?.from === right.directTransition?.from && + left.directTransition?.startedAt === right.directTransition?.startedAt && + left.directTransition?.durationMs === right.directTransition?.durationMs && + left.directTransition?.transition === right.directTransition?.transition const samePosition = (left: AvatarPosition, right: AvatarPosition) => left.x === right.x && left.y === right.y @@ -332,7 +337,24 @@ export function Avatar({ if (expression !== undefined) { const resolved = resolveExpression(definition, expression) if (resolved.ok) { - setPlayback({ ...createAvatarPlaybackState(), activeExpression: expression }) + const current = playbackRef.current + const next = { + ...createAvatarPlaybackState(), + activeExpression: expression, + ...(current.activeExpression === expression + ? {} + : { + status: 'playing' as const, + directTransition: { + from: current.activeExpression, + startedAt: performance.now(), + durationMs: controlledExpressionTransitionMs, + transition: 'smooth' as const, + }, + }), + } + playbackRef.current = next + setPlayback(next) } return } @@ -470,6 +492,12 @@ export function Avatar({ const startDrag = (event: ReactPointerEvent) => { if (!draggable || event.button !== 0) return + if ( + mode === 'floating' && + !(event.target instanceof Element && event.target.closest('.bs-avatar__drag-grip')) + ) { + return + } event.currentTarget.setPointerCapture(event.pointerId) drag.current = { pointerId: event.pointerId, @@ -545,7 +573,9 @@ export function Avatar({ role={draggable ? 'group' : 'img'} aria-label={ariaLabel} aria-description={ - draggable ? 'Use arrow keys or move controls to reposition the avatar.' : undefined + draggable + ? 'Use the drag handle, arrow keys or move controls to reposition the avatar.' + : undefined } tabIndex={draggable ? 0 : undefined} onPointerDown={startDrag} @@ -565,6 +595,7 @@ export function Avatar({ event.preventDefault() }} > + {draggable &&