Merge pull request #4 from leo-combaret/codex/fix-perpetual-ambient-motion
fix(avatar-react): keep ambient motion running
This commit is contained in:
commit
93faab3690
@ -40,6 +40,21 @@ const runtimeEnvironment = () => ({
|
|||||||
reduceMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
reduceMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const shouldRunFrameLoop = (
|
||||||
|
definition: AvatarDefinition,
|
||||||
|
playback: Readonly<CorePlaybackState>,
|
||||||
|
environment: ReturnType<typeof runtimeEnvironment>
|
||||||
|
) => {
|
||||||
|
if (playback.status === 'playing') return true
|
||||||
|
const motion = definition.expressions[playback.activeExpression]?.motion
|
||||||
|
return (
|
||||||
|
playback.status === 'stopped' &&
|
||||||
|
!environment.reduceMotion &&
|
||||||
|
motion !== undefined &&
|
||||||
|
(motion.eyes !== 'none' || motion.body !== 'none')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const markAvatarDefinitionValidated = (definition: object) => {
|
export const markAvatarDefinitionValidated = (definition: object) => {
|
||||||
validatedDefinitions.add(definition)
|
validatedDefinitions.add(definition)
|
||||||
}
|
}
|
||||||
@ -331,7 +346,7 @@ export function Avatar({
|
|||||||
}, [playback.activeExpression, onExpressionChange])
|
}, [playback.activeExpression, onExpressionChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playback.status !== 'playing') return
|
if (!shouldRunFrameLoop(definition, playback, runtimeEnvironment())) return
|
||||||
let frame = 0
|
let frame = 0
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
const current = playbackRef.current
|
const current = playbackRef.current
|
||||||
@ -351,11 +366,13 @@ export function Avatar({
|
|||||||
}
|
}
|
||||||
const frameScene = renderPlaybackFrame(next, now, environment)
|
const frameScene = renderPlaybackFrame(next, now, environment)
|
||||||
paintScene(frameScene)
|
paintScene(frameScene)
|
||||||
if (next.status === 'playing') frame = requestAnimationFrame(tick)
|
if (shouldRunFrameLoop(definition, next, environment)) {
|
||||||
|
frame = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
frame = requestAnimationFrame(tick)
|
frame = requestAnimationFrame(tick)
|
||||||
return () => cancelAnimationFrame(frame)
|
return () => cancelAnimationFrame(frame)
|
||||||
}, [definition, playback.status, onAnimationEnd])
|
}, [definition, playback.activeExpression, playback.status, onAnimationEnd])
|
||||||
|
|
||||||
const controlled = animation !== undefined || expression !== undefined
|
const controlled = animation !== undefined || expression !== undefined
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
|
|||||||
@ -19,6 +19,11 @@ const expression = {
|
|||||||
motion: { eyes: 'none', body: 'none' },
|
motion: { eyes: 'none', body: 'none' },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
const ambientExpression = {
|
||||||
|
...expression,
|
||||||
|
motion: { eyes: 'none', body: 'shake' },
|
||||||
|
} as const
|
||||||
|
|
||||||
const definition: AvatarDefinition = {
|
const definition: AvatarDefinition = {
|
||||||
schema: 'bible-strong/avatar-definition',
|
schema: 'bible-strong/avatar-definition',
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@ -28,8 +33,12 @@ const definition: AvatarDefinition = {
|
|||||||
nodes: [],
|
nodes: [],
|
||||||
},
|
},
|
||||||
colors: { body: '#5b7fe5', eyes: '#111316' },
|
colors: { body: '#5b7fe5', eyes: '#111316' },
|
||||||
expressions: { neutral: expression, smile: { ...expression, head: { x: 0, y: 10, z: 0 } } },
|
expressions: {
|
||||||
expressionOrder: ['neutral', 'smile'],
|
neutral: expression,
|
||||||
|
smile: { ...expression, head: { x: 0, y: 10, z: 0 } },
|
||||||
|
restless: ambientExpression,
|
||||||
|
},
|
||||||
|
expressionOrder: ['neutral', 'smile', 'restless'],
|
||||||
animations: {
|
animations: {
|
||||||
greet: {
|
greet: {
|
||||||
playbackMode: 'loop',
|
playbackMode: 'loop',
|
||||||
@ -298,6 +307,33 @@ describe('@bible-strong/avatar-react', () => {
|
|||||||
cancel.mockRestore()
|
cancel.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps scheduling frames for a controlled expression with ambient motion', () => {
|
||||||
|
let nextFrame = 0
|
||||||
|
const frames = new Map<number, FrameRequestCallback>()
|
||||||
|
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||||
|
frames.set(++nextFrame, callback)
|
||||||
|
return nextFrame
|
||||||
|
})
|
||||||
|
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||||
|
frames.delete(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<Avatar definition={definition} expression="restless" />)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
for (let index = 1; index <= 3; index++) {
|
||||||
|
const callback = [...frames.values()].at(-1)
|
||||||
|
frames.clear()
|
||||||
|
callback?.(index * 1_000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledTimes(4)
|
||||||
|
expect(frames).toHaveLength(1)
|
||||||
|
request.mockRestore()
|
||||||
|
cancel.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
it('fires once-completion exactly once under Strict Mode', () => {
|
it('fires once-completion exactly once under Strict Mode', () => {
|
||||||
let nextFrame = 0
|
let nextFrame = 0
|
||||||
const frames = new Map<number, FrameRequestCallback>()
|
const frames = new Map<number, FrameRequestCallback>()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user