diff --git a/packages/core/src/animation/AnimationClip.ts b/packages/core/src/animation/AnimationClip.ts index adb0c07c91..ef51647577 100644 --- a/packages/core/src/animation/AnimationClip.ts +++ b/packages/core/src/animation/AnimationClip.ts @@ -205,12 +205,11 @@ export class AnimationClip extends EngineObject { } /** - * @internal * Samples an animation at a given time. * @param entity - The animated entity * @param time - The time to sample an animation */ - _sampleAnimation(entity: Entity, time: number): void { + sampleAnimation(entity: Entity, time: number): void { const { _curveBindings: curveBindings } = this; const components = AnimationCurveOwner._components; for (let i = curveBindings.length - 1; i >= 0; i--) { diff --git a/packages/core/src/animation/AnimationClipCurveBinding.ts b/packages/core/src/animation/AnimationClipCurveBinding.ts index 906c154087..e6e4066181 100644 --- a/packages/core/src/animation/AnimationClipCurveBinding.ts +++ b/packages/core/src/animation/AnimationClipCurveBinding.ts @@ -33,7 +33,7 @@ export class AnimationClipCurveBinding { /** The animation curve. */ curve: AnimationCurve; - private _tempCurveOwner: Record> = {}; + private _tempCurveOwner = new WeakMap>(); /** * @internal @@ -63,10 +63,11 @@ export class AnimationClipCurveBinding { * @internal */ _getTempCurveOwner(entity: Entity, component: Component): AnimationCurveOwner { - const { instanceId } = entity; - if (!this._tempCurveOwner[instanceId]) { - this._tempCurveOwner[instanceId] = this._createCurveOwner(entity, component); + let owner = this._tempCurveOwner.get(entity); + if (!owner) { + owner = this._createCurveOwner(entity, component); + this._tempCurveOwner.set(entity, owner); } - return this._tempCurveOwner[instanceId]; + return owner; } } diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 42fc2ee3fa..f58589955c 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -19,6 +19,7 @@ import { AnimatorCullingMode } from "./enums/AnimatorCullingMode"; import { AnimatorLayerBlendingMode } from "./enums/AnimatorLayerBlendingMode"; import { AnimatorStatePlayState } from "./enums/AnimatorStatePlayState"; import { LayerState } from "./enums/LayerState"; +import { WrapMode } from "./enums/WrapMode"; import { AnimationCurveLayerOwner } from "./internal/AnimationCurveLayerOwner"; import { AnimationEventHandler } from "./internal/AnimationEventHandler"; import { AnimatorLayerData } from "./internal/AnimatorLayerData"; @@ -38,6 +39,9 @@ export class Animator extends Component { /** The playback speed of the Animator, 1.0 is normal playback speed. */ @assignmentClone speed = 1.0; + /** Whether the Animator sends AnimationEvent callbacks. */ + @assignmentClone + fireEvents = true; /** @internal */ _playFrameCount = -1; @@ -54,7 +58,7 @@ export class Animator extends Component { @ignoreClone private _animatorLayersData = new Array(); @ignoreClone - private _curveOwnerPool: Record>> = Object.create(null); + private _curveOwnerPool: WeakMap>> = new WeakMap(); @ignoreClone private _parametersValueMap = >Object.create(null); @@ -316,17 +320,20 @@ export class Animator extends Component { * @internal */ _reset(): void { - const { _curveOwnerPool: animationCurveOwners } = this; - for (let instanceId in animationCurveOwners) { - const propertyOwners = animationCurveOwners[instanceId]; - for (let property in propertyOwners) { - const owner = propertyOwners[property]; - owner.revertDefaultValue(); + const layersData = this._animatorLayersData; + for (let i = 0, n = layersData.length; i < n; i++) { + const layerData = layersData[i]; + if (!layerData) continue; + for (const stateData of layerData.animatorStateDataMap.values()) { + const layerOwners = stateData.curveLayerOwner; + for (let k = 0, l = layerOwners.length; k < l; k++) { + layerOwners[k]?.curveOwner?.revertDefaultValue(); + } } } this._animatorLayersData.length = 0; - this._curveOwnerPool = Object.create(null); + this._curveOwnerPool = new WeakMap(); this._parametersValueMap = Object.create(null); if (this._controllerUpdateFlag) { @@ -353,6 +360,7 @@ export class Animator extends Component { protected override _onDestroy(): void { super._onDestroy(); + this._reset(); const controller = this._animatorController; if (controller) { this._addResourceReferCount(controller, -1); @@ -373,7 +381,9 @@ export class Animator extends Component { if (!state) { return; } - const { manuallyTransition } = this._getAnimatorLayerData(playLayerIndex); + + const animatorLayerData = this._getAnimatorLayerData(playLayerIndex); + const { manuallyTransition } = animatorLayerData; manuallyTransition.duration = duration; manuallyTransition.offset = normalizedTimeOffset; @@ -453,17 +463,23 @@ export class Animator extends Component { } const { property } = curve; - const { instanceId } = component; - // Get owner - const propertyOwners = (curveOwnerPool[instanceId] ||= >>( - Object.create(null) - )); - const owner = (propertyOwners[property] ||= curve._createCurveOwner(targetEntity, component)); + // Key owner lookup by Component identity instead of instanceId. + let propertyOwners = curveOwnerPool.get(component); + if (!propertyOwners) { + propertyOwners = >>Object.create(null); + curveOwnerPool.set(component, propertyOwners); + } + let owner = propertyOwners[property]; + if (!owner) { + owner = curve._createCurveOwner(targetEntity, component); + propertyOwners[property] = owner; + } - // Get layer owner - const layerPropertyOwners = (layerCurveOwnerPool[instanceId] ||= >( - Object.create(null) - )); + let layerPropertyOwners = layerCurveOwnerPool.get(component); + if (!layerPropertyOwners) { + layerPropertyOwners = >Object.create(null); + layerCurveOwnerPool.set(component, layerPropertyOwners); + } const layerOwner = (layerPropertyOwners[property] ||= curve._createCurveLayerOwner(owner)); if (mask && mask.pathMasks.length) { @@ -479,8 +495,6 @@ export class Animator extends Component { } private _ensureEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void { - // state._updateFlagManager dispatches on both clip-swap and clip-events-mutation, - // so its version covers every input that affects eventHandlers binding const stateVersion = state._updateFlagManager.version; const scriptsVersion = this._entity._scriptsVersion; if ( @@ -632,9 +646,9 @@ export class Animator extends Component { aniUpdate: boolean ): void { const { srcPlayData } = layerData; - const state = srcPlayData.instance._state; + const { state } = srcPlayData; - const playSpeed = srcPlayData.instance.speed * this.speed; + const playSpeed = srcPlayData.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; srcPlayData.updateOrientation(playDeltaTime); @@ -717,8 +731,7 @@ export class Animator extends Component { ); if (transition) { - // Remove speed factor, use actual cost time. Per-instance speed=0 means the source - // state is paused, so it consumes no time — pass deltaTime through to the destination. + // Remove speed factor, use actual cost time const remainDeltaTime = playSpeed === 0 ? deltaTime : deltaTime - playCostTime / playSpeed; remainDeltaTime > 0 && this._updateState(layerData, remainDeltaTime, aniUpdate); } @@ -730,7 +743,7 @@ export class Animator extends Component { additive: boolean, aniUpdate: boolean ): void { - const curveBindings = playData.instance.clip._curveBindings; + const curveBindings = playData.state.clip._curveBindings; const finished = playData.playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { @@ -764,16 +777,16 @@ export class Animator extends Component { ) { const { srcPlayData, destPlayData, layerIndex } = layerData; const { speed } = this; - const srcState = srcPlayData.instance._state; - const destState = destPlayData.instance._state; + const { state: srcState } = srcPlayData; + const { state: destState } = destPlayData; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, destState, deltaTime, aniUpdate)) { return; } - const srcPlaySpeed = srcPlayData.instance.speed * speed; - const dstPlaySpeed = destPlayData.instance.speed * speed; + const srcPlaySpeed = srcPlayData.speed * speed; + const dstPlaySpeed = destPlayData.speed * speed; const dstPlayDeltaTime = dstPlaySpeed * deltaTime; srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); @@ -855,8 +868,8 @@ export class Animator extends Component { aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const { _curveBindings: srcCurves } = srcPlayData.instance.clip; - const destState = destPlayData.instance._state; + const { _curveBindings: srcCurves } = srcPlayData.state.clip; + const { state: destState } = destPlayData; const { _curveBindings: destCurves } = destState.clip; const finished = destPlayData.playState === AnimatorStatePlayState.Finished; @@ -895,14 +908,14 @@ export class Animator extends Component { aniUpdate: boolean ) { const { destPlayData } = layerData; - const state = destPlayData.instance._state; + const { state } = destPlayData; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, state, deltaTime, aniUpdate)) { return; } - const playSpeed = destPlayData.instance.speed * this.speed; + const playSpeed = destPlayData.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; destPlayData.updateOrientation(playDeltaTime); @@ -969,7 +982,7 @@ export class Animator extends Component { aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const state = destPlayData.instance._state; + const { state } = destPlayData; const { _curveBindings: curveBindings } = state.clip; const { clipTime: destClipTime, playState } = destPlayData; @@ -987,7 +1000,7 @@ export class Animator extends Component { this._checkRevertOwner(owner, additive); - const value = layerOwner.curveOwner.crossFadeFromPoseAndApplyValue( + const value = owner.crossFadeFromPoseAndApplyValue( curveIndex >= 0 ? curveBindings[curveIndex].curve : null, destClipTime, crossWeight, @@ -1007,8 +1020,8 @@ export class Animator extends Component { aniUpdate: boolean ): void { const playData = layerData.srcPlayData; - const state = playData.instance._state; - const actualSpeed = playData.instance.speed * this.speed; + const { state } = playData; + const actualSpeed = playData.speed * this.speed; const actualDeltaTime = actualSpeed * deltaTime; playData.updateOrientation(actualDeltaTime); @@ -1049,7 +1062,7 @@ export class Animator extends Component { } const { curveLayerOwner } = playData.stateData; - const { _curveBindings: curveBindings } = playData.instance.clip; + const { _curveBindings: curveBindings } = playData.state.clip; for (let i = curveBindings.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; @@ -1070,13 +1083,15 @@ export class Animator extends Component { } else { layerData.layerState = LayerState.Playing; } - layerData.completeCrossFade(); + layerData.srcPlayData = destPlayData; + layerData.destPlayData = null; + layerData.crossFadeTransition = null; } private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorState): void { if (layerData.layerState === LayerState.Playing) { const srcPlayData = layerData.srcPlayData; - if (srcPlayData.instance._state !== playState) { + if (srcPlayData.state !== playState) { const { curveLayerOwner } = srcPlayData.stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { curveLayerOwner[i]?.curveOwner.revertDefaultValue(); @@ -1100,11 +1115,8 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ): AnimatorStateTransition { - const state = playData.instance._state; - const clipDuration = state.clip.length; + const { state } = playData; let targetTransition: AnimatorStateTransition = null; - const startTime = state.clipStartTime * clipDuration; - const endTime = state.clipEndTime * clipDuration; if (transitionCollection.noExitTimeCount) { targetTransition = this._checkNoExitTimeTransitions(layerData, transitionCollection, aniUpdate); @@ -1113,6 +1125,10 @@ export class Animator extends Component { } } + const clipDuration = state.clip.length; + const startTime = state.clipStartTime * clipDuration; + const endTime = state.clipEndTime * clipDuration; + if (isForward) { if (lastClipTime + deltaTime >= endTime) { targetTransition = this._checkSubTransition( @@ -1331,9 +1347,8 @@ export class Animator extends Component { const playData = animatorLayerData.getOrCreateInstance(state)._playData; playData.reset(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); animatorLayerData.srcPlayData = playData; - // Drop any dangling cross-fade slot from a previously-interrupted crossFade - // so a later crossFade(B) isn't wrongly no-op'd by the active-dest guard. - animatorLayerData.clearCrossFadeSlot(); + animatorLayerData.destPlayData = null; + animatorLayerData.crossFadeTransition = null; animatorLayerData.resetCurrentCheckIndex(); return true; @@ -1433,18 +1448,11 @@ export class Animator extends Component { } const animatorLayerData = this._getAnimatorLayerData(layerIndex); - - // Self/active-dest cross-fade is a no-op: each state has one persistent - // instance per layer, so a second concurrent fade has nowhere to live. - if ( - animatorLayerData.srcPlayData?.instance._state === crossState || - animatorLayerData.destPlayData?.instance._state === crossState - ) { + if (animatorLayerData.srcPlayData?.state === crossState || animatorLayerData.destPlayData?.state === crossState) { return false; } const animatorStateData = this._getAnimatorStateData(crossState, animatorLayerData, layerIndex); - const destPlayData = animatorLayerData.getOrCreateInstance(crossState)._playData; destPlayData.reset(animatorStateData, transition.offset * crossState._getClipActualEndTime()); animatorLayerData.destPlayData = destPlayData; @@ -1482,24 +1490,28 @@ export class Animator extends Component { lastClipTime: number, deltaTime: number ): void { - const { isForward, clipTime } = playData; - const state = playData.instance._state; + const { state, isForward, clipTime, wrapMode } = playData; const startTime = state._getClipActualStartTime(); const endTime = state._getClipActualEndTime(); + const canWrap = wrapMode === WrapMode.Loop; if (isForward) { if (lastClipTime + deltaTime >= endTime) { this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, endTime); - playData.currentEventIndex = 0; - this._fireSubAnimationEvents(playData, eventHandlers, startTime, clipTime); + if (canWrap) { + playData.currentEventIndex = 0; + this._fireSubAnimationEvents(playData, eventHandlers, startTime, clipTime); + } } else { this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); } } else { if (lastClipTime + deltaTime <= startTime) { this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, startTime); - playData.currentEventIndex = eventHandlers.length - 1; - this._fireBackwardSubAnimationEvents(playData, eventHandlers, endTime, clipTime); + if (canWrap) { + playData.currentEventIndex = eventHandlers.length - 1; + this._fireBackwardSubAnimationEvents(playData, eventHandlers, endTime, clipTime); + } } else { this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); } @@ -1523,8 +1535,10 @@ export class Animator extends Component { const { handlers } = eventHandler; if (time >= lastClipTime) { - for (let j = handlers.length - 1; j >= 0; j--) { - handlers[j](parameter); + if (this.fireEvents) { + for (let j = handlers.length - 1; j >= 0; j--) { + handlers[j](parameter); + } } playState.currentEventIndex = Math.min(eventIndex + 1, n - 1); } @@ -1548,8 +1562,10 @@ export class Animator extends Component { if (time <= lastClipTime) { const { handlers } = eventHandler; - for (let j = handlers.length - 1; j >= 0; j--) { - handlers[j](parameter); + if (this.fireEvents) { + for (let j = handlers.length - 1; j >= 0; j--) { + handlers[j](parameter); + } } playState.currentEventIndex = Math.max(eventIndex - 1, 0); } @@ -1595,9 +1611,6 @@ export class Animator extends Component { lastPlayState: AnimatorStatePlayState, deltaTime: number ) { - // Re-check whether the clip events/scripts changed since the last build — - // play()/crossFade() entry points already ensure on enter, but addEvent() - // or addComponent(Script) after play() must also flow through. this._ensureEventHandlers(state, playData.stateData); const { eventHandlers } = playData.stateData; eventHandlers.length && this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime); diff --git a/packages/core/src/animation/AnimatorController.ts b/packages/core/src/animation/AnimatorController.ts index 60bc439210..1f8e91a01c 100644 --- a/packages/core/src/animation/AnimatorController.ts +++ b/packages/core/src/animation/AnimatorController.ts @@ -19,6 +19,7 @@ export class AnimatorController extends ReferResource { _layersMap: Record = {}; private _updateFlagManager: UpdateFlagManager = new UpdateFlagManager(); + private _onStatesInvalidate = (): void => this._updateFlagManager.dispatch(); /** * The layers in the controller. @@ -112,6 +113,7 @@ export class AnimatorController extends ReferResource { addLayer(layer: AnimatorControllerLayer): void { this._layers.push(layer); this._layersMap[layer.name] = layer; + layer._setStatesInvalidateCallback(this._onStatesInvalidate); layer._setEngine(this._engine); this._updateFlagManager.dispatch(); } @@ -123,6 +125,7 @@ export class AnimatorController extends ReferResource { removeLayer(layerIndex: number): void { const theLayer = this.layers[layerIndex]; this._layers.splice(layerIndex, 1); + theLayer._setStatesInvalidateCallback(null); delete this._layersMap[theLayer.name]; this._updateFlagManager.dispatch(); } @@ -131,7 +134,12 @@ export class AnimatorController extends ReferResource { * Clear layers. */ clearLayers(): void { - this._layers.length = 0; + const { _layers: layers } = this; + for (let i = 0, n = layers.length; i < n; i++) { + layers[i]._setStatesInvalidateCallback(null); + } + layers.length = 0; + for (let name in this._layersMap) { delete this._layersMap[name]; } @@ -151,7 +159,9 @@ export class AnimatorController extends ReferResource { _setEngine(engine: Engine): void { const { _layers: layers } = this; for (let i = 0, n = layers.length; i < n; i++) { - layers[i]._setEngine(engine); + const layer = layers[i]; + layer._setStatesInvalidateCallback(this._onStatesInvalidate); + layer._setEngine(engine); } } diff --git a/packages/core/src/animation/AnimatorControllerLayer.ts b/packages/core/src/animation/AnimatorControllerLayer.ts index 135ac0204f..5b6c094da4 100644 --- a/packages/core/src/animation/AnimatorControllerLayer.ts +++ b/packages/core/src/animation/AnimatorControllerLayer.ts @@ -11,22 +11,53 @@ export class AnimatorControllerLayer { weight: number = 1.0; /** The blending mode used by the layer. It is not taken into account for the first layer. */ blendingMode: AnimatorLayerBlendingMode = AnimatorLayerBlendingMode.Override; - /** The state machine for the layer. */ - stateMachine: AnimatorStateMachine; /** The AnimatorLayerMask is used to mask out certain entities from being animated by an AnimatorLayer. */ mask: AnimatorLayerMask; + private _stateMachine: AnimatorStateMachine; + private _engine: Engine; + private _onStatesInvalidate: (() => void) | null = null; + + /** + * The state machine for the layer. + */ + get stateMachine(): AnimatorStateMachine { + return this._stateMachine; + } + + set stateMachine(value: AnimatorStateMachine) { + const lastStateMachine = this._stateMachine; + if (lastStateMachine === value) { + return; + } + + lastStateMachine._setStatesInvalidateCallback(null); + this._stateMachine = value; + value._setStatesInvalidateCallback(this._onStatesInvalidate); + this._engine && value._setEngine(this._engine); + this._onStatesInvalidate?.(); + } + /** * @param name - The layer's name */ constructor(public readonly name: string) { - this.stateMachine = new AnimatorStateMachine(); + this._stateMachine = new AnimatorStateMachine(); } /** * @internal */ _setEngine(engine: Engine): void { - this.stateMachine._setEngine(engine); + this._engine = engine; + this._stateMachine._setEngine(engine); + } + + /** + * @internal + */ + _setStatesInvalidateCallback(onStatesInvalidate: (() => void) | null): void { + this._onStatesInvalidate = onStatesInvalidate; + this._stateMachine._setStatesInvalidateCallback(onStatesInvalidate); } } diff --git a/packages/core/src/animation/AnimatorStateMachine.ts b/packages/core/src/animation/AnimatorStateMachine.ts index 3200aab5df..298e1c62c6 100644 --- a/packages/core/src/animation/AnimatorStateMachine.ts +++ b/packages/core/src/animation/AnimatorStateMachine.ts @@ -14,6 +14,7 @@ export class AnimatorStateMachine { readonly states: AnimatorState[] = []; private _engine: Engine; + private _onStatesInvalidate: (() => void) | null = null; /** * The state will be played automatically. @@ -72,6 +73,7 @@ export class AnimatorStateMachine { if (this.defaultState === state) { this.defaultState = null; } + this._onStatesInvalidate?.(); } } @@ -170,4 +172,11 @@ export class AnimatorStateMachine { states[i]._setEngine(engine); } } + + /** + * @internal + */ + _setStatesInvalidateCallback(onStatesInvalidate: (() => void) | null): void { + this._onStatesInvalidate = onStatesInvalidate; + } } diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index a3b2677c0b..cf35d198fe 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -1,3 +1,4 @@ +import { Component } from "../../Component"; import { AnimatorControllerLayer } from "../AnimatorControllerLayer"; import { AnimatorState } from "../AnimatorState"; import { AnimatorStateInstance } from "../AnimatorStateInstance"; @@ -13,8 +14,8 @@ import type { AnimatorStatePlayData } from "./AnimatorStatePlayData"; export class AnimatorLayerData { layerIndex: number; layer: AnimatorControllerLayer; - curveOwnerPool: Record> = Object.create(null); - animatorStateDataMap: WeakMap = new WeakMap(); + curveOwnerPool: WeakMap> = new WeakMap(); + animatorStateDataMap: Map = new Map(); instanceMap: WeakMap = new WeakMap(); srcPlayData: AnimatorStatePlayData | null = null; destPlayData: AnimatorStatePlayData | null = null; @@ -34,17 +35,6 @@ export class AnimatorLayerData { return instance; } - completeCrossFade(): void { - this.srcPlayData = this.destPlayData; - this.destPlayData = null; - this.crossFadeTransition = null; - } - - clearCrossFadeSlot(): void { - this.destPlayData = null; - this.crossFadeTransition = null; - } - resetCurrentCheckIndex(): void { this.layer.stateMachine._entryTransitionCollection.needResetCurrentCheckIndex = true; this.layer.stateMachine._anyStateTransitionCollection.needResetCurrentCheckIndex = true; diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index a4f2c80142..e303a711c8 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -1,4 +1,5 @@ import { AnimatorStateInstance } from "../AnimatorStateInstance"; +import { AnimatorState } from "../AnimatorState"; import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState"; import { WrapMode } from "../enums/WrapMode"; import { AnimatorStateData } from "./AnimatorStateData"; @@ -20,6 +21,18 @@ export class AnimatorStatePlayData { constructor(public readonly instance: AnimatorStateInstance) {} + get state(): AnimatorState { + return this.instance._state; + } + + get speed(): number { + return this.instance.speed; + } + + get wrapMode(): WrapMode { + return this.instance.wrapMode; + } + reset(stateData: AnimatorStateData, offsetFrameTime: number): void { const state = this.instance._state; this.stateData = stateData; @@ -48,20 +61,22 @@ export class AnimatorStatePlayData { this.playedTime += deltaTime; const instance = this.instance; const state = instance._state; + const clipLength = state.clip.length; + const clipStartTime = state.clipStartTime; let time = this.playedTime + this.offsetFrameTime; - const duration = state._getDuration(); + const duration = (state.clipEndTime - clipStartTime) * clipLength; this.playState = AnimatorStatePlayState.Playing; if (instance.wrapMode === WrapMode.Loop) { time = duration ? time % duration : 0; - } else { - if (Math.abs(time) >= duration) { - time = time < 0 ? -duration : duration; - this.playState = AnimatorStatePlayState.Finished; - } + } else if (time >= duration || time <= -duration) { + time = time < 0 ? -duration : duration; + this.playState = AnimatorStatePlayState.Finished; } - time < 0 && (time += duration); - this.clipTime = time + state.clipStartTime * state.clip.length; + if (time < 0) { + time += duration; + } + this.clipTime = time + clipStartTime * clipLength; if (this._changedOrientation) { !this.isForward && this._correctTime(); diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 9544a7591e..dd5dff2b98 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -20,7 +20,7 @@ import { } from "@galacean/engine-core"; import "@galacean/engine-loader"; import type { GLTFResource } from "@galacean/engine-loader"; -import { Quaternion, Vector3 } from "@galacean/engine-math"; +import { Quaternion } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { glbResource } from "./model/fox"; @@ -42,6 +42,40 @@ describe("Animator test", function () { let resource: GLTFResource; let engine: WebGLEngine; + const findSharedState = (stateName: string) => + animator.animatorController.layers[0].stateMachine.findStateByName(stateName); + const createLoopAnimator = () => { + const entity = new Entity(engine); + const localAnimator = entity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("Base Layer"); + controller.addLayer(layer); + + const state = layer.stateMachine.addState("loop"); + state.wrapMode = WrapMode.Loop; + + const clip = new AnimationClip("loop-clip"); + const curve = new AnimationFloatCurve(); + const start = new Keyframe(); + const end = new Keyframe(); + start.time = 0; + start.value = 0; + end.time = 1; + end.value = 1; + curve.addKey(start); + curve.addKey(end); + clip.addCurveBinding("", Transform, "position.x", curve); + state.clip = clip; + + localAnimator.animatorController = controller; + return { entity, animator: localAnimator, clip }; + }; + const updateAnimator = (target: Animator, deltaTime: number) => { + // @ts-ignore + target.engine.time._frameCount++; + target.update(deltaTime); + }; + beforeAll(async function () { engine = await WebGLEngine.create({ canvas: canvasDOM }); const scene = engine.sceneManager.activeScene; @@ -55,8 +89,8 @@ describe("Animator test", function () { }); afterAll(function () { - animator.destroy(); - engine.destroy(); + animator?.destroy(); + engine?.destroy(); }); afterEach(function () { @@ -70,18 +104,17 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); stateMachine.clearEntryStateTransitions(); - // 清理各状态的 transitions 并恢复默认属性 (mutate shared AnimatorState) + // 清理各状态的 transitions 并恢复默认属性 const stateNames = ["Survey", "Walk", "Run"]; for (const name of stateNames) { - const view = animator.findAnimatorState(name); - if (view) { - const def = (view as any)._state; - def.clearTransitions(); - def.speed = 1; - def.clipStartTime = 0; - def.clipEndTime = 1; - def.wrapMode = WrapMode.Loop; - def.clip?.clearEvents(); + const state = findSharedState(name); + if (state) { + state.clearTransitions(); + state.speed = 1; + state.clipStartTime = 0; + state.clipEndTime = 1; + state.wrapMode = WrapMode.Loop; + state.clip?.clearEvents(); } } }); @@ -198,29 +231,107 @@ describe("Animator test", function () { it("find animator state", () => { const stateName = "Survey"; const expectedStateName = "Run"; + const layerIndex = 0; - animator.play(stateName); - const layerIndex = animator["_tempAnimatorStateInfo"].layerIndex; + animator.play(stateName, layerIndex); const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); let animatorState = animator.findAnimatorState(stateName, layerIndex); expect(animatorState).to.eq(currentAnimatorState); - animator.play(expectedStateName); + animator.play(expectedStateName, layerIndex); animatorState = animator.findAnimatorState(expectedStateName, layerIndex); expect(animatorState).not.to.eq(currentAnimatorState); - expect(animatorState?.name).to.eq(expectedStateName); + expect(animatorState.name).to.eq(expectedStateName); }); - it("animation getCurrentAnimatorState", () => { - //get random animation element from gltf resource - const min = 0; - const max = resource.animations.length - 1; - const index = Math.floor(Math.random() * (max - min + 1)) + min; + it("findAnimatorState returns a stable per-state instance for states that are not currently playing", () => { + animator.play("Walk"); + const walkInstance = animator.getCurrentAnimatorState(0); + const runInstance = animator.findAnimatorState("Run", 0); + const sharedRunState = findSharedState("Run"); + + expect(runInstance).not.to.eq(null); + expect(runInstance).not.to.eq(walkInstance); + expect(runInstance.name).to.eq("Run"); + + runInstance.speed = 0.5; + + expect(sharedRunState.speed).to.eq(1); + + animator.play("Run"); + expect(animator.getCurrentAnimatorState(0)).to.eq(runInstance); + expect(animator.getCurrentAnimatorState(0).speed).to.eq(0.5); + }); + + it("findAnimatorState remains stable after crossFade play slots are reused", () => { + const walkState = findSharedState("Walk"); + const runState = findSharedState("Run"); + + animator.play("Walk"); + const walkInstance = animator.getCurrentAnimatorState(0); + const runInstance = animator.findAnimatorState("Run", 0); + walkInstance.speed = 2; + runInstance.speed = 0.5; + + animator.crossFade("Run", 0.1, 0); + updateAnimator(animator, runState._getDuration()); + + expect(animator.getCurrentAnimatorState(0)).to.eq(runInstance); + expect(animator.getCurrentAnimatorState(0).speed).to.eq(0.5); + expect(animator.findAnimatorState("Walk", 0)).to.eq(walkInstance); + expect(animator.findAnimatorState("Walk", 0).speed).to.eq(2); + expect(animator.findAnimatorState("Walk", 0)._state).to.eq(walkState); + }); + + it("crossFade to the active state is a no-op", () => { + animator.play("Walk"); + const currentInstance = animator.getCurrentAnimatorState(0); + updateAnimator(animator, 0.1); + const layerData = animator["_animatorLayersData"][0]; + const playedBefore = layerData.srcPlayData.playedTime; + + animator.crossFade("Walk", 0.1, 0); + + expect(layerData.destPlayData).to.eq(null); + expect(layerData.crossFadeTransition).to.eq(null); + expect(animator.getCurrentAnimatorState(0)).to.eq(currentInstance); + expect(layerData.srcPlayData.playedTime).to.eq(playedBefore); + }); + + it("crossFade to the active destination is a no-op", () => { + animator.play("Walk"); + animator.crossFade("Run", 0.5, 0); + + const layerData = animator["_animatorLayersData"][0]; + const destBefore = layerData.destPlayData; + const transitionBefore = layerData.crossFadeTransition; + const playedBefore = destBefore.playedTime; + + animator.crossFade("Run", 0.1, 0); + + expect(layerData.destPlayData).to.eq(destBefore); + expect(layerData.crossFadeTransition).to.eq(transitionBefore); + expect(layerData.destPlayData.playedTime).to.eq(playedBefore); + }); + + it("play, crossFade, and state lookup ignore out-of-range layers without throwing", () => { + animator.play("Walk"); + const before = animator.getCurrentAnimatorState(0); + + expect(() => animator.play("Run", 99)).not.to.throw(); + expect(() => animator.crossFade("Run", 0.1, 99)).not.to.throw(); + expect(animator.findAnimatorState("Run", 99)).to.eq(null); + expect(animator.findAnimatorState("Run", -2)).to.eq(null); + expect(animator.getCurrentAnimatorState(99)).to.eq(null); + expect(animator.getCurrentAnimatorState(0)).to.eq(before); + }); + + it("animation getCurrentAnimatorState", () => { //play animation and get current animator state - const expectedStateName = resource.animations[index].name; - animator.play(expectedStateName); - const layerIndex = animator["_tempAnimatorStateInfo"].layerIndex; + const layerIndex = 0; + const expectedStateName = resource.animations[0].name; + animator.play(expectedStateName, layerIndex); const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); expect(currentAnimatorState.name).to.eq(expectedStateName); }); @@ -240,8 +351,91 @@ describe("Animator test", function () { expect(layerState).to.eq(2); }); + it("crossFade advances with per-instance playData speed instead of shared AnimatorState speed", () => { + const sharedStates = animator.animatorController.layers[0].stateMachine.states; + const sharedWalkState = sharedStates.find((state) => state.name === "Walk"); + const sharedRunState = sharedStates.find((state) => state.name === "Run"); + const oldWalkSpeed = sharedWalkState.speed; + const oldRunSpeed = sharedRunState.speed; + + try { + animator.play("Walk"); + animator.crossFade("Run", 1.0, 0); + + const layerData = animator["_animatorLayersData"][0]; + layerData.srcPlayData.instance.speed = 0.25; + layerData.destPlayData.instance.speed = 0.25; + sharedWalkState.speed = 10; + sharedRunState.speed = 10; + + const srcPlayedTime = layerData.srcPlayData.playedTime; + const destPlayedTime = layerData.destPlayData.playedTime; + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.2); + + expect(layerData.srcPlayData.playedTime - srcPlayedTime).toBeCloseTo(0.05, 5); + expect(layerData.destPlayData.playedTime - destPlayedTime).toBeCloseTo(0.05, 5); + } finally { + sharedWalkState.speed = oldWalkSpeed; + sharedRunState.speed = oldRunSpeed; + } + }); + + it("playData wrapMode overrides shared AnimatorState wrapMode per instance", () => { + const sharedWalkState = animator.animatorController.layers[0].stateMachine.states.find( + (state) => state.name === "Walk" + ); + const oldWrapMode = sharedWalkState.wrapMode; + + try { + sharedWalkState.wrapMode = WrapMode.Loop; + animator.play("Walk"); + + const layerData = animator["_animatorLayersData"][0]; + const playData = layerData.srcPlayData; + playData.instance.wrapMode = WrapMode.Once; + + expect(sharedWalkState.wrapMode).to.eq(WrapMode.Loop); + + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(playData.state.clip.length + 0.1); + + expect(layerData.layerState).to.eq(LayerState.Finished); + } finally { + sharedWalkState.wrapMode = oldWrapMode; + } + }); + + it("playData wrapMode does not leak between animators sharing one controller", () => { + const sharedWalkState = animator.animatorController.layers[0].stateMachine.states.find( + (state) => state.name === "Walk" + ); + const oldWrapMode = sharedWalkState.wrapMode; + const otherEntity = new Entity(engine); + const otherAnimator = otherEntity.addComponent(Animator); + otherAnimator.animatorController = animator.animatorController; + + try { + sharedWalkState.wrapMode = WrapMode.Loop; + animator.play("Walk"); + otherAnimator.play("Walk"); + + const playData = animator["_animatorLayersData"][0].srcPlayData; + const otherPlayData = otherAnimator["_animatorLayersData"][0].srcPlayData; + playData.instance.wrapMode = WrapMode.Once; + + expect(otherPlayData.wrapMode).to.eq(WrapMode.Loop); + expect(sharedWalkState.wrapMode).to.eq(WrapMode.Loop); + } finally { + sharedWalkState.wrapMode = oldWrapMode; + otherEntity.destroy(); + } + }); + it("cross fade in fixed time", () => { - const runState = animator.findAnimatorState("Run"); + const runState = findSharedState("Run"); animator.play("Walk"); animator.crossFadeInFixedDuration("Run", 0.3, 0, 0.1); // @ts-ignore @@ -252,25 +446,25 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); const srcPlayData = layerData.srcPlayData; - expect(srcPlayData.instance.name).to.eq("Run"); + expect(srcPlayData.state.name).to.eq("Run"); expect(srcPlayData.playedTime).to.eq(0.3); // @ts-ignore - expect(srcPlayData.clipTime).to.eq(0.3 + 0.1 * (runState as any)._state._getDuration()); + expect(srcPlayData.clipTime).to.eq(0.3 + 0.1 * runState._getDuration()); }); it("animation cross fade by transition", () => { - const walkState = animator.findAnimatorState("Walk"); - const runState = animator.findAnimatorState("Run"); + const walkState = findSharedState("Walk"); + const runState = findSharedState("Run"); const transition = new AnimatorStateTransition(); - transition.destinationState = (runState as any)._state; + transition.destinationState = runState; transition.duration = 1; transition.exitTime = 1; - (walkState as any)._state.addTransition(transition); + walkState.addTransition(transition); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length - 0.1); + animator.update(walkState.clip.length - 0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); @@ -313,7 +507,7 @@ describe("Animator test", function () { additiveLayer.mask = mask; additiveLayer.blendingMode = AnimatorLayerBlendingMode.Additive; animatorController.addLayer(additiveLayer); - const clip = (animator.findAnimatorState("Run") as any)._state.clip; + const clip = findSharedState("Run").clip; const newState = animatorStateMachine.addState("Run"); newState.clipStartTime = 1; newState.clip = clip; @@ -330,10 +524,10 @@ describe("Animator test", function () { ); let layerData = animator["_animatorLayersData"][1]; - const layerCurveOwner = layerData.curveOwnerPool[targetEntity.transform.instanceId]["rotationQuaternion"]; - const parentLayerCurveOwner = layerData.curveOwnerPool[parentEntity.transform.instanceId]["rotationQuaternion"]; + const layerCurveOwner = layerData.curveOwnerPool.get(targetEntity.transform)["rotationQuaternion"]; + const parentLayerCurveOwner = layerData.curveOwnerPool.get(parentEntity.transform)["rotationQuaternion"]; - let childLayerCurveOwner = layerData.curveOwnerPool[childEntity.transform.instanceId]["rotationQuaternion"]; + let childLayerCurveOwner = layerData.curveOwnerPool.get(childEntity.transform)["rotationQuaternion"]; expect(layerCurveOwner.isActive).to.eq(false); expect(parentLayerCurveOwner.isActive).to.eq(true); @@ -344,7 +538,7 @@ describe("Animator test", function () { animator.animatorController.addLayer(additiveLayer); animator.play("Run", 1); layerData = animator["_animatorLayersData"][1]; - childLayerCurveOwner = layerData.curveOwnerPool[childEntity.transform.instanceId]["rotationQuaternion"]; + childLayerCurveOwner = layerData.curveOwnerPool.get(childEntity.transform)["rotationQuaternion"]; expect(childLayerCurveOwner.isActive).to.eq(true); }); @@ -362,83 +556,283 @@ describe("Animator test", function () { event0.functionName = "event0"; event0.time = 0; - const state = animator.findAnimatorState("Walk"); + const state = findSharedState("Walk"); state.clip.addEvent(event0); animator.update(10); expect(testScriptSpy).toHaveBeenCalledTimes(1); }); - it("eventHandlers rebuild when Script is added after play (no clip event mutation)", () => { - const event = new AnimationEvent(); - event.functionName = "event0"; - event.time = 0; - const state = animator.findAnimatorState("Walk")!; - state.clip.addEvent(event); // event exists before play + it("animation events bind scripts added after play", () => { + const state = findSharedState("Walk"); + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0; + state.clip.addEvent(event0); - animator.play("Walk"); // script not yet attached; first build sees zero scripts + animator.play("Walk"); class TestScript extends Script { event0(): void {} } - const script = animator.entity.addComponent(TestScript); // does NOT bump clip _version - const spy = vi.spyOn(script, "event0"); + + const testScript = animator.entity.addComponent(TestScript); + const testScriptSpy = vi.spyOn(testScript, "event0"); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(spy).toHaveBeenCalledTimes(1); - }); - it("eventHandlers rebuild when state.clip is swapped (state version covers clip swap)", () => { - const walkState = animator.animatorController.layers[0].stateMachine.findStateByName("Walk"); - const runState = animator.animatorController.layers[0].stateMachine.findStateByName("Run"); - const originalClip = walkState.clip; + expect(testScriptSpy).toHaveBeenCalledTimes(1); + }); - // Old clip: add an event matching event0 - const oldEvent = new AnimationEvent(); - oldEvent.functionName = "event0"; - oldEvent.time = 0; - originalClip.addEvent(oldEvent); + it("animation events added after play rebuild handlers lazily", () => { + const { entity, animator: loopAnimator, clip } = createLoopAnimator(); class TestScript extends Script { event0(): void {} } - const script = animator.entity.addComponent(TestScript); - const spy0 = vi.spyOn(script, "event0"); - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - expect(spy0).toHaveBeenCalledTimes(1); + const testScript = entity.addComponent(TestScript); + const testScriptSpy = vi.spyOn(testScript, "event0"); + + try { + loopAnimator.play("loop"); + updateAnimator(loopAnimator, 0.05); + expect(testScriptSpy).not.toHaveBeenCalled(); - // Swap to a different real clip (Run's) that has no event0; even if the new clip's - // _version happens to match the prior snapshot, the swap itself must invalidate the - // cached eventHandlers via state._updateFlagManager dispatch - walkState.clip = runState.clip; + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0.1; + clip.addEvent(event0); - spy0.mockClear(); + updateAnimator(loopAnimator, 0.1); + expect(testScriptSpy).toHaveBeenCalledTimes(1); + } finally { + entity.destroy(); + } + }); + + it("fireEvents gates AnimationEvent dispatch without consuming the event", () => { animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; + + class TestScript extends Script { + event0(): void {} + } + + const testScript = animator.entity.addComponent(TestScript); + const testScriptSpy = vi.spyOn(testScript, "event0"); + + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0; + + const state = findSharedState("Walk"); + state.clip.addEvent(event0); + + animator.fireEvents = false; + animator.update(0); + expect(testScriptSpy).not.toHaveBeenCalled(); + + animator.fireEvents = true; animator.update(0.1); - expect(spy0).toHaveBeenCalledTimes(0); // swapped clip has no event0 + expect(testScriptSpy).toHaveBeenCalledTimes(1); + }); + + it("fires animation events across forward loop wrap", () => { + const { entity, animator: loopAnimator, clip } = createLoopAnimator(); + + class TestScript extends Script { + event0(): void {} + event1(): void {} + } + + const testScript = entity.addComponent(TestScript); + const event0Spy = vi.spyOn(testScript, "event0"); + const event1Spy = vi.spyOn(testScript, "event1"); + + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0.1; + const event1 = new AnimationEvent(); + event1.functionName = "event1"; + event1.time = 0.75; + clip.addEvent(event0); + clip.addEvent(event1); + + try { + loopAnimator.play("loop"); + updateAnimator(loopAnimator, 1.25); + + expect(event0Spy).toHaveBeenCalledTimes(2); + expect(event1Spy).toHaveBeenCalledTimes(1); + } finally { + entity.destroy(); + } + }); + + it("keeps AnimationEvent cursor in sync when fireEvents is disabled across forward loop wrap", () => { + const { entity, animator: loopAnimator, clip } = createLoopAnimator(); + + class TestScript extends Script { + event0(): void {} + event1(): void {} + } + + const testScript = entity.addComponent(TestScript); + const event0Spy = vi.spyOn(testScript, "event0"); + const event1Spy = vi.spyOn(testScript, "event1"); + + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0.1; + const event1 = new AnimationEvent(); + event1.functionName = "event1"; + event1.time = 0.75; + clip.addEvent(event0); + clip.addEvent(event1); + + try { + loopAnimator.play("loop"); + updateAnimator(loopAnimator, 0.8); + expect(event0Spy).toHaveBeenCalledTimes(1); + expect(event1Spy).toHaveBeenCalledTimes(1); + + event0Spy.mockClear(); + event1Spy.mockClear(); + loopAnimator.fireEvents = false; + updateAnimator(loopAnimator, 0.25); + expect(event0Spy).not.toHaveBeenCalled(); + expect(event1Spy).not.toHaveBeenCalled(); + + loopAnimator.fireEvents = true; + updateAnimator(loopAnimator, 0.45); + expect(event0Spy).toHaveBeenCalledTimes(1); + expect(event1Spy).not.toHaveBeenCalled(); + } finally { + entity.destroy(); + } + }); + + it("fires animation events across backward loop wrap", () => { + const { entity, animator: loopAnimator, clip } = createLoopAnimator(); + + class TestScript extends Script { + event0(): void {} + event1(): void {} + event2(): void {} + } + + const testScript = entity.addComponent(TestScript); + const event0Spy = vi.spyOn(testScript, "event0"); + const event1Spy = vi.spyOn(testScript, "event1"); + const event2Spy = vi.spyOn(testScript, "event2"); + + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0.1; + const event2 = new AnimationEvent(); + event2.functionName = "event2"; + event2.time = 0.6; + const event1 = new AnimationEvent(); + event1.functionName = "event1"; + event1.time = 0.75; + clip.addEvent(event0); + clip.addEvent(event2); + clip.addEvent(event1); + + try { + loopAnimator.play("loop"); + updateAnimator(loopAnimator, 0.25); + expect(event0Spy).toHaveBeenCalledTimes(1); + expect(event1Spy).not.toHaveBeenCalled(); + expect(event2Spy).not.toHaveBeenCalled(); + + event0Spy.mockClear(); + event1Spy.mockClear(); + event2Spy.mockClear(); + loopAnimator.speed = -1; + updateAnimator(loopAnimator, 0.5); + + expect(event0Spy).toHaveBeenCalledTimes(1); + expect(event1Spy).toHaveBeenCalledTimes(1); + expect(event2Spy).not.toHaveBeenCalled(); + + event0Spy.mockClear(); + event1Spy.mockClear(); + event2Spy.mockClear(); + updateAnimator(loopAnimator, 0.25); + + expect(event0Spy).not.toHaveBeenCalled(); + expect(event1Spy).not.toHaveBeenCalled(); + expect(event2Spy).toHaveBeenCalledTimes(1); + } finally { + entity.destroy(); + } + }); + + it("does not refire animation events when a once clip reaches the end", () => { + const entity = new Entity(engine); + const onceAnimator = entity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("Base Layer"); + controller.addLayer(layer); - // restore for other tests - walkState.clip = originalClip; + const state = layer.stateMachine.addState("once"); + state.wrapMode = WrapMode.Once; + + const clip = new AnimationClip("once-clip"); + const curve = new AnimationFloatCurve(); + const start = new Keyframe(); + const end = new Keyframe(); + start.time = 0; + start.value = 0; + end.time = 1; + end.value = 1; + curve.addKey(start); + curve.addKey(end); + clip.addCurveBinding("", Transform, "position.x", curve); + + class TestScript extends Script { + event0(): void {} + } + + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0.5; + clip.addEvent(event0); + state.clip = clip; + onceAnimator.animatorController = controller; + + const testScript = entity.addComponent(TestScript); + const testScriptSpy = vi.spyOn(testScript, "event0"); + + try { + onceAnimator.play("once"); + // @ts-ignore + onceAnimator.engine.time._frameCount++; + onceAnimator.update(0.75); + expect(testScriptSpy).toHaveBeenCalledTimes(1); + + // @ts-ignore + onceAnimator.engine.time._frameCount++; + onceAnimator.update(0.5); + expect(testScriptSpy).toHaveBeenCalledTimes(1); + } finally { + entity.destroy(); + } }); it("stateMachine", () => { animator.animatorController.addParameter("playerSpeed", 1); const stateMachine = animator.animatorController.layers[0].stateMachine; - const idleState = animator.findAnimatorState("Survey"); + const idleState = findSharedState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - (idleState as any)._state.clearTransitions(); - const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); - const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); + idleState.clearTransitions(); + const walkState = findSharedState("Walk"); + walkState.clearTransitions(); + const runState = findSharedState("Run"); + runState.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -446,68 +840,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = (walkState as any)._state; + toWalkTransition.destinationState = walkState; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.9; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - (idleState as any)._state.addTransition(toWalkTransition); + idleState.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - (toWalkTransition.exitTime * (idleState as any)._state._getDuration()) / idleSpeed + + (toWalkTransition.exitTime * idleState._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * (walkState as any)._state._getDuration(); + toWalkTransition.duration * walkState._getDuration(); - const exitTransition = (idleState as any)._state.addExitTransition(); + const exitTransition = idleState.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = (runState as any)._state; + toRunTransition.destinationState = runState; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.9; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - (walkState as any)._state.addTransition(toRunTransition); + walkState.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._state._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + //@ts-ignore - toRunTransition.duration * (runState as any)._state._getDuration(); + toRunTransition.duration * runState._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = (idleState as any)._state; + toIdleTransition.destinationState = idleState; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.9; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - (walkState as any)._state.addTransition(toIdleTransition); + walkState.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._state._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + //@ts-ignore - (toIdleTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = (walkState as any)._state; + runToWalkTransition.destinationState = walkState; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.9; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - (runState as any)._state.addTransition(runToWalkTransition); + runState.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._state._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + //@ts-ignore - runToWalkTransition.duration * (walkState as any)._state._getDuration(); + runToWalkTransition.duration * walkState._getDuration(); - stateMachine.addEntryStateTransition((idleState as any)._state); + stateMachine.addEntryStateTransition(idleState); - const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyTransition = stateMachine.addAnyStateTransition(idleState); anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); anyTransition.duration = 0.3; anyTransition.hasExitTime = true; anyTransition.exitTime = 0.7; let anyToIdleTime = // @ts-ignore - (anyTransition.exitTime - toIdleTransition.duration) * (walkState as any)._state._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() + // @ts-ignore - (anyTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (anyTransition.duration * idleState._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -556,14 +950,14 @@ describe("Animator test", function () { stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); - const idleState = animator.findAnimatorState("Survey"); + const idleState = findSharedState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - (idleState as any)._state.clearTransitions(); - const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); - const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); + idleState.clearTransitions(); + const walkState = findSharedState("Walk"); + walkState.clearTransitions(); + const runState = findSharedState("Run"); + runState.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -571,68 +965,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = (walkState as any)._state; + toWalkTransition.destinationState = walkState; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.1; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - (idleState as any)._state.addTransition(toWalkTransition); + idleState.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - ((1 - toWalkTransition.exitTime) * (idleState as any)._state._getDuration()) / idleSpeed + + ((1 - toWalkTransition.exitTime) * idleState._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * (walkState as any)._state._getDuration(); + toWalkTransition.duration * walkState._getDuration(); - const exitTransition = (idleState as any)._state.addExitTransition(); + const exitTransition = idleState.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = (runState as any)._state; + toRunTransition.destinationState = runState; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.1; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - (walkState as any)._state.addTransition(toRunTransition); + walkState.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (1 - toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._state._getDuration() + + (1 - toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + //@ts-ignore - toRunTransition.duration * (runState as any)._state._getDuration(); + toRunTransition.duration * runState._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = (idleState as any)._state; + toIdleTransition.destinationState = idleState; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.1; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - (walkState as any)._state.addTransition(toIdleTransition); + walkState.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (1 - toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._state._getDuration() + + (1 - toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + //@ts-ignore - (toIdleTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = (walkState as any)._state; + runToWalkTransition.destinationState = walkState; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.1; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - (runState as any)._state.addTransition(runToWalkTransition); + runState.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (1 - runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._state._getDuration() + + (1 - runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + //@ts-ignore - runToWalkTransition.duration * (walkState as any)._state._getDuration(); + runToWalkTransition.duration * walkState._getDuration(); - stateMachine.addEntryStateTransition((idleState as any)._state); + stateMachine.addEntryStateTransition(idleState); - const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyTransition = stateMachine.addAnyStateTransition(idleState); anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); anyTransition.duration = 0.3; anyTransition.hasExitTime = true; anyTransition.exitTime = 0.3; let anyToIdleTime = // @ts-ignore - (1 - anyTransition.exitTime - toIdleTransition.duration) * (walkState as any)._state._getDuration() + + (1 - anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() + // @ts-ignore - (anyTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; + (anyTransition.duration * idleState._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -675,11 +1069,11 @@ describe("Animator test", function () { }); it("transitionOffset", () => { - const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); - const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); - const toRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + const walkState = findSharedState("Walk"); + walkState.clearTransitions(); + const runState = findSharedState("Run"); + runState.clearTransitions(); + const toRunTransition = walkState.addTransition(runState); toRunTransition.exitTime = 0; toRunTransition.duration = 1; toRunTransition.offset = 0.5; @@ -689,39 +1083,39 @@ describe("Animator test", function () { animator.update(0.01); const destPlayData = animator["_animatorLayersData"][0].destPlayData; - const destState = (destPlayData.instance as any)._state; + const destState = destPlayData.state; const transitionDuration = toRunTransition.duration * destState._getDuration(); const crossWeight = animator["_animatorLayersData"][0].destPlayData.playedTime / transitionDuration; expect(crossWeight).to.lessThan(0.01); }); it("clipStartTime crossFade", () => { - const walkState = animator.findAnimatorState("Walk"); + const walkState = findSharedState("Walk"); walkState.wrapMode = WrapMode.Once; - (walkState as any)._state.clipStartTime = 0.8; - (walkState as any)._state.clearTransitions(); - const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); - const toRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + walkState.clipStartTime = 0.8; + walkState.clearTransitions(); + const runState = findSharedState("Run"); + runState.clearTransitions(); + const toRunTransition = walkState.addTransition(runState); toRunTransition.exitTime = 0.5; toRunTransition.duration = 1; - (runState as any)._state.clipStartTime = 0.5; + runState.clipStartTime = 0.5; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); const destPlayData = animator["_animatorLayersData"][0].destPlayData; - expect(destPlayData.instance?.name).to.eq("Run"); + expect(destPlayData.state?.name).to.eq("Run"); }); it("transition to exit but no entry", () => { const animatorLayerData = animator["_animatorLayersData"]; - const walkState = animator.findAnimatorState("Walk"); + const walkState = findSharedState("Walk"); walkState.wrapMode = WrapMode.Once; - (walkState as any)._state.clearTransitions(); - (walkState as any)._state.addExitTransition(); + walkState.clearTransitions(); + walkState.addExitTransition(); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -886,17 +1280,17 @@ describe("Animator test", function () { const stateMachine = animatorController.layers[0].stateMachine; stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); - const walkState = animator.findAnimatorState("Run"); + const walkState = findSharedState("Run"); // For test clipStartTime is not 0 and transition duration is 0 - (walkState as any)._state.clipStartTime = 0.5; - (walkState as any)._state.addStateMachineScript( + walkState.clipStartTime = 0.5; + walkState.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator) { animator.setParameterValue("playRun", 0); } } ); - const transition = stateMachine.addAnyStateTransition((animator.findAnimatorState("Run") as any)._state); + const transition = stateMachine.addAnyStateTransition(findSharedState("Run")); transition.addCondition("playRun", AnimatorConditionMode.Equals, 1); // For test clipStartTime is not 0 and transition duration is 0 transition.duration = 0; @@ -907,9 +1301,9 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.5); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.state.name).to.eq("Run"); expect(layerData.srcPlayData.playedTime).to.eq(0.5); - expect(layerData.srcPlayData.clipTime).to.eq((walkState as any)._state.clip.length * 0.5 + 0.5); + expect(layerData.srcPlayData.clipTime).to.eq(walkState.clip.length * 0.5 + 0.5); }); it("hasExitTime", () => { @@ -920,15 +1314,15 @@ describe("Animator test", function () { const stateMachine = animatorController.layers[0].stateMachine; stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); - const idleState = animator.findAnimatorState("Survey"); + const idleState = findSharedState("Survey"); idleState.speed = 1; - (idleState as any)._state.clearTransitions(); - const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clipStartTime = 0; - (walkState as any)._state.clearTransitions(); - const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clearTransitions(); - const walkToRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + idleState.clearTransitions(); + const walkState = findSharedState("Walk"); + walkState.clipStartTime = 0; + walkState.clearTransitions(); + const runState = findSharedState("Run"); + runState.clearTransitions(); + const walkToRunTransition = walkState.addTransition(runState); walkToRunTransition.hasExitTime = true; walkToRunTransition.exitTime = 0.5; walkToRunTransition.duration = 0; @@ -936,10 +1330,10 @@ describe("Animator test", function () { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length * 0.5); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + animator.update(walkState.clip.length * 0.5); + expect(layerData.destPlayData.state.name).to.eq("Run"); expect(layerData.destPlayData.playedTime).to.eq(0); - const anyToIdleTransition = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdleTransition = stateMachine.addAnyStateTransition(idleState); anyToIdleTransition.hasExitTime = false; anyToIdleTransition.duration = 0.2; anyToIdleTransition.addCondition("triggerIdle", AnimatorConditionMode.If, true); @@ -947,13 +1341,13 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.state.name).to.eq("Run"); expect(layerData.srcPlayData.playedTime).to.eq(0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update((idleState as any)._state.clip.length * 0.2 - 0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Survey"); - expect(layerData.srcPlayData.clipTime).to.eq((idleState as any)._state.clip.length * 0.2); + animator.update(idleState.clip.length * 0.2 - 0.1); + expect(layerData.srcPlayData.state.name).to.eq("Survey"); + expect(layerData.srcPlayData.clipTime).to.eq(idleState.clip.length * 0.2); }); it("setTriggerParameter", () => { @@ -965,17 +1359,17 @@ describe("Animator test", function () { const stateMachine = animatorController.layers[0].stateMachine; stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); - const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); - const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clipStartTime = 0; - (runState as any)._state.clearTransitions(); - const walkToRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + const walkState = findSharedState("Walk"); + walkState.clearTransitions(); + const runState = findSharedState("Run"); + runState.clipStartTime = 0; + runState.clearTransitions(); + const walkToRunTransition = walkState.addTransition(runState); walkToRunTransition.hasExitTime = false; walkToRunTransition.duration = 0.1; walkToRunTransition.addCondition("triggerRun", AnimatorConditionMode.If, true); - const runToWalkTransition = (runState as any)._state.addTransition((walkState as any)._state); + const runToWalkTransition = runState.addTransition(walkState); runToWalkTransition.hasExitTime = true; runToWalkTransition.exitTime = 0.7; runToWalkTransition.duration = 0.3; @@ -987,28 +1381,28 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Walk"); + expect(layerData.srcPlayData.state.name).to.eq("Walk"); expect(layerData.srcPlayData.playedTime).to.eq(0.1); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); expect(layerData.destPlayData.playedTime).to.eq(0.1); expect(animator.getParameterValue("triggerRun")).to.eq(false); expect(animator.getParameterValue("triggerWalk")).to.eq(true); // @ts-ignore animator.engine.time._frameCount++; - animator.update((runState as any)._state.clip.length * 0.1 - 0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); - expect(layerData.srcPlayData.playedTime).to.eq((runState as any)._state.clip.length * 0.1); + animator.update(runState.clip.length * 0.1 - 0.1); + expect(layerData.srcPlayData.state.name).to.eq("Run"); + expect(layerData.srcPlayData.playedTime).to.eq(runState.clip.length * 0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update((runState as any)._state.clip.length * 0.6); - expect(layerData.destPlayData.instance.name).to.eq("Walk"); + animator.update(runState.clip.length * 0.6); + expect(layerData.destPlayData.state.name).to.eq("Walk"); expect(layerData.destPlayData.playedTime).to.eq(0); expect(animator.getParameterValue("triggerWalk")).to.eq(false); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length * 0.3); - expect(layerData.srcPlayData.instance.name).to.eq("Walk"); - expect(layerData.srcPlayData.playedTime).to.eq((walkState as any)._state.clip.length * 0.3); + animator.update(walkState.clip.length * 0.3); + expect(layerData.srcPlayData.state.name).to.eq("Walk"); + expect(layerData.srcPlayData.playedTime).to.eq(walkState.clip.length * 0.3); }); it("fixedDuration", () => { @@ -1017,12 +1411,12 @@ describe("Animator test", function () { animatorController.addTriggerParameter("triggerWalk"); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); - const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.clearTransitions(); - const runState = animator.findAnimatorState("Run"); - (runState as any)._state.clipStartTime = (runState as any)._state.clipEndTime = 0; - (runState as any)._state.clearTransitions(); - const walkToRunTransition = (walkState as any)._state.addTransition((runState as any)._state); + const walkState = findSharedState("Walk"); + walkState.clearTransitions(); + const runState = findSharedState("Run"); + runState.clipStartTime = runState.clipEndTime = 0; + runState.clearTransitions(); + const walkToRunTransition = walkState.addTransition(runState); walkToRunTransition.hasExitTime = false; walkToRunTransition.isFixedDuration = true; walkToRunTransition.duration = 0.1; @@ -1032,7 +1426,7 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.state.name).to.eq("Run"); expect(layerData.srcPlayData.playedTime).to.eq(0.1); expect(layerData.srcPlayData.clipTime).to.eq(0); }); @@ -1095,13 +1489,153 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.6); - expect(animatorLayerData[0]?.srcPlayData.instance.name).to.eq("state1"); + expect(animatorLayerData[0]?.srcPlayData.state.name).to.eq("state1"); transition2.mute = false; // @ts-ignore animator.engine.time._frameCount++; animator.update(0.3); - expect(animatorLayerData[0]?.srcPlayData.instance.name).to.eq("state2"); + expect(animatorLayerData[0]?.srcPlayData.state.name).to.eq("state2"); + }); + + it("removing a default state prevents it from being auto-played", () => { + const entity = new Entity(engine); + const localAnimator = entity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("layer"); + controller.addLayer(layer); + + const removedState = layer.stateMachine.addState("removed"); + const clip = new AnimationClip("removed-clip"); + const curve = new AnimationFloatCurve(); + const start = new Keyframe(); + const end = new Keyframe(); + start.time = 0; + start.value = 0; + end.time = 1; + end.value = 1; + curve.addKey(start); + curve.addKey(end); + clip.addCurveBinding("", Transform, "position.x", curve); + removedState.clip = clip; + layer.stateMachine.defaultState = removedState; + layer.stateMachine.removeState(removedState); + localAnimator.animatorController = controller; + + try { + localAnimator.update(0.1); + expect(localAnimator.getCurrentAnimatorState(0)).to.eq(null); + } finally { + entity.destroy(); + } + }); + + it("removing a state invalidates cached layer data before the state name is reused", () => { + const entity = new Entity(engine); + const localAnimator = entity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("layer"); + controller.addLayer(layer); + + const oldState = layer.stateMachine.addState("Temp"); + const oldClip = new AnimationClip("old-temp-clip"); + const oldCurve = new AnimationFloatCurve(); + const oldStart = new Keyframe(); + const oldEnd = new Keyframe(); + oldStart.time = 0; + oldStart.value = 0; + oldEnd.time = 1; + oldEnd.value = 1; + oldCurve.addKey(oldStart); + oldCurve.addKey(oldEnd); + oldClip.addCurveBinding("", Transform, "position.x", oldCurve); + oldState.clip = oldClip; + localAnimator.animatorController = controller; + + try { + localAnimator.play("Temp"); + let layerData = localAnimator["_animatorLayersData"][0]; + expect(layerData.animatorStateDataMap.has(oldState)).to.eq(true); + + layer.stateMachine.removeState(oldState); + const newState = layer.stateMachine.addState("Temp"); + const newClip = new AnimationClip("new-temp-clip"); + const newCurve = new AnimationFloatCurve(); + const newStart = new Keyframe(); + const newEnd = new Keyframe(); + newStart.time = 0; + newStart.value = 0; + newEnd.time = 1; + newEnd.value = 2; + newCurve.addKey(newStart); + newCurve.addKey(newEnd); + newClip.addCurveBinding("", Transform, "position.x", newCurve); + newState.clip = newClip; + + localAnimator.play("Temp"); + layerData = localAnimator["_animatorLayersData"][0]; + + expect(localAnimator.getCurrentAnimatorState(0)._state).to.eq(newState); + expect(layerData.animatorStateDataMap.has(oldState)).to.eq(false); + expect(layerData.animatorStateDataMap.has(newState)).to.eq(true); + } finally { + entity.destroy(); + } + }); + + it("replacing a layer state machine keeps state removal invalidation", () => { + const entity = new Entity(engine); + const localAnimator = entity.addComponent(Animator); + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("layer"); + controller.addLayer(layer); + + const stateMachine = new AnimatorStateMachine(); + layer.stateMachine = stateMachine; + + const oldState = stateMachine.addState("Temp"); + const oldClip = new AnimationClip("old-temp-clip"); + const oldCurve = new AnimationFloatCurve(); + const oldStart = new Keyframe(); + const oldEnd = new Keyframe(); + oldStart.time = 0; + oldStart.value = 0; + oldEnd.time = 1; + oldEnd.value = 1; + oldCurve.addKey(oldStart); + oldCurve.addKey(oldEnd); + oldClip.addCurveBinding("", Transform, "position.x", oldCurve); + oldState.clip = oldClip; + localAnimator.animatorController = controller; + + try { + localAnimator.play("Temp"); + let layerData = localAnimator["_animatorLayersData"][0]; + expect(layerData.animatorStateDataMap.has(oldState)).to.eq(true); + + stateMachine.removeState(oldState); + const newState = stateMachine.addState("Temp"); + const newClip = new AnimationClip("new-temp-clip"); + const newCurve = new AnimationFloatCurve(); + const newStart = new Keyframe(); + const newEnd = new Keyframe(); + newStart.time = 0; + newStart.value = 0; + newEnd.time = 1; + newEnd.value = 2; + newCurve.addKey(newStart); + newCurve.addKey(newEnd); + newClip.addCurveBinding("", Transform, "position.x", newCurve); + newState.clip = newClip; + + localAnimator.play("Temp"); + layerData = localAnimator["_animatorLayersData"][0]; + + expect(layerData.animatorStateDataMap.has(oldState)).to.eq(false); + expect(layerData.animatorStateDataMap.has(newState)).to.eq(true); + } finally { + entity.destroy(); + } }); it("Clone", () => { @@ -1142,22 +1676,55 @@ describe("Animator test", function () { expect(wrappedRoot.findByPath("mixamorig:Hips")).to.eq(hips); expect(wrappedRoot.findByPath("mixamorig:Hips/mixamorig:Spine")).to.eq(spine); - // @ts-ignore - clip._sampleAnimation(wrappedRoot, 0.1); + clip.sampleAnimation(wrappedRoot, 0.1); expect(wrappedRoot.transform.position.x).to.eq(0); expect(hips.transform.position.x).to.eq(1); expect(spine.transform.position.y).to.eq(1); }); + it("sampleAnimation samples clip curves without firing AnimationEvents", () => { + const entity = new Entity(engine, "sample-root"); + const clip = new AnimationClip("sample"); + const curve = new AnimationFloatCurve(); + const start = new Keyframe(); + const end = new Keyframe(); + start.time = 0; + start.value = 0; + end.time = 1; + end.value = 3; + curve.addKey(start); + curve.addKey(end); + clip.addCurveBinding("", Transform, "position.x", curve); + + class TestScript extends Script { + event0(): void {} + } + + const script = entity.addComponent(TestScript); + const eventSpy = vi.spyOn(script, "event0"); + const event0 = new AnimationEvent(); + event0.functionName = "event0"; + event0.time = 0; + clip.addEvent(event0); + + try { + clip.sampleAnimation(entity, 1); + expect(entity.transform.position.x).to.eq(3); + expect(eventSpy).not.toHaveBeenCalled(); + } finally { + entity.destroy(); + } + }); + it("anyState transition interrupts crossFade", () => { const { animatorController } = animator; animatorController.addParameter("interrupt", false); const stateMachine = animatorController.layers[0].stateMachine; - const idleState = animator.findAnimatorState("Survey"); + const idleState = findSharedState("Survey"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1174,7 +1741,7 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); // Trigger interrupt during crossFade animator.setParameterValue("interrupt", true); @@ -1183,7 +1750,7 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destPlayData.instance.name).to.eq("Survey"); + expect(layerData.destPlayData.state.name).to.eq("Survey"); }); it("noExitTime transition scan should ignore exitTime transitions", () => { @@ -1191,16 +1758,16 @@ describe("Animator test", function () { animatorController.addParameter("goRun", true); animatorController.addParameter("never", false); - const walkState = animator.findAnimatorState("Walk"); - const runState = animator.findAnimatorState("Run"); - const idleState = animator.findAnimatorState("Survey"); + const walkState = findSharedState("Walk"); + const runState = findSharedState("Run"); + const idleState = findSharedState("Survey"); - (walkState as any)._state.clipStartTime = 0; - (walkState as any)._state.clipEndTime = 1; - (walkState as any)._state.clearTransitions(); + walkState.clipStartTime = 0; + walkState.clipEndTime = 1; + walkState.clearTransitions(); // A noExitTime transition that fails (ensures noExitTimeCount > 0). - const noExitFailTransition = (walkState as any)._state.addTransition((idleState as any)._state); + const noExitFailTransition = walkState.addTransition(idleState); noExitFailTransition.hasExitTime = false; noExitFailTransition.duration = 0; noExitFailTransition.addCondition("never", AnimatorConditionMode.If, true); @@ -1209,26 +1776,26 @@ describe("Animator test", function () { const exitTimeTransition = new AnimatorStateTransition(); exitTimeTransition.exitTime = 0.5; exitTimeTransition.duration = 0; - exitTimeTransition.destinationState = (runState as any)._state; + exitTimeTransition.destinationState = runState; exitTimeTransition.addCondition("goRun", AnimatorConditionMode.If, true); - (walkState as any)._state.addTransition(exitTimeTransition); + walkState.addTransition(exitTimeTransition); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); animator.play("Walk"); // Update before exitTime, should still be in Walk and not start transitioning to Run. - const preExitDeltaTime = (walkState as any)._state.clip.length * 0.25; + const preExitDeltaTime = walkState.clip.length * 0.25; // @ts-ignore animator.engine.time._frameCount++; animator.update(preExitDeltaTime); - expect(layerData.srcPlayData.instance.name).to.eq("Walk"); + expect(layerData.srcPlayData.state.name).to.eq("Walk"); expect(layerData.destPlayData).to.be.null; // Update past exitTime, should transition to Run. // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length * 0.5); + animator.update(walkState.clip.length * 0.5); expect(animator.getCurrentAnimatorState(0).name).to.eq("Run"); }); @@ -1236,11 +1803,11 @@ describe("Animator test", function () { const { animatorController } = animator; animatorController.addParameter("interrupt", false); const stateMachine = animatorController.layers[0].stateMachine; - const idleState = animator.findAnimatorState("Survey"); - const walkState = animator.findAnimatorState("Walk"); + const idleState = findSharedState("Survey"); + const walkState = findSharedState("Walk"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1250,7 +1817,7 @@ describe("Animator test", function () { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._state.clip.length + 0.1); + animator.update(walkState.clip.length + 0.1); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); @@ -1264,7 +1831,7 @@ describe("Animator test", function () { animator.update(0.1); expect(layerData.layerState).to.eq(LayerState.FixedCrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); // Trigger interrupt during FixedCrossFading animator.setParameterValue("interrupt", true); @@ -1273,17 +1840,17 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destPlayData.instance.name).to.eq("Survey"); + expect(layerData.destPlayData.state.name).to.eq("Survey"); }); it("anyState interrupt should skip transition to same destination state", () => { const { animatorController } = animator; animatorController.addParameter("alwaysTrue", true); const stateMachine = animatorController.layers[0].stateMachine; - const runState = animator.findAnimatorState("Run"); + const runState = findSharedState("Run"); // AnyState -> Run (always true, noExitTime) - const anyToRun = stateMachine.addAnyStateTransition((runState as any)._state); + const anyToRun = stateMachine.addAnyStateTransition(runState); anyToRun.hasExitTime = false; anyToRun.duration = 0.2; anyToRun.addCondition("alwaysTrue", AnimatorConditionMode.If, true); @@ -1300,7 +1867,7 @@ describe("Animator test", function () { // Should be in CrossFading state, dest = Run expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); // Update again - anyState -> Run should be skipped because dest is already Run // @ts-ignore @@ -1309,17 +1876,17 @@ describe("Animator test", function () { // Should still be CrossFading to Run (not interrupted/reset) expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.state.name).to.eq("Run"); }); it("zero-duration crossFade should not be interrupted by anyState transition", () => { const { animatorController } = animator; animatorController.addParameter("interrupt", true); const stateMachine = animatorController.layers[0].stateMachine; - const idleState = animator.findAnimatorState("Survey"); + const idleState = findSharedState("Survey"); // AnyState -> Idle (always true, noExitTime) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1335,26 +1902,26 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); // Zero-duration crossFade completes instantly, should be Playing Run (not interrupted to Survey) - expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.state.name).to.eq("Run"); }); it("toggle hasExitTime should maintain correct noExitTimeCount", () => { - const walkState = animator.findAnimatorState("Walk"); - const runState = animator.findAnimatorState("Run"); - const idleState = animator.findAnimatorState("Survey"); - (walkState as any)._state.clearTransitions(); + const walkState = findSharedState("Walk"); + const runState = findSharedState("Run"); + const idleState = findSharedState("Survey"); + walkState.clearTransitions(); // Add a noExitTime transition - const t1 = (walkState as any)._state.addTransition((runState as any)._state); + const t1 = walkState.addTransition(runState); t1.hasExitTime = false; // Add a hasExitTime transition - const t2 = (walkState as any)._state.addTransition((idleState as any)._state); + const t2 = walkState.addTransition(idleState); t2.hasExitTime = true; t2.exitTime = 0.5; // @ts-ignore - const collection = (walkState as any)._state._transitionCollection; + const collection = walkState._transitionCollection; expect(collection.noExitTimeCount).to.eq(1); expect(collection.count).to.eq(2); @@ -1373,446 +1940,4 @@ describe("Animator test", function () { expect(collection.get(0)).to.eq(t2); expect(collection.get(1)).to.eq(t1); }); - - it("findAnimatorState lazy-creates handle for unplayed state", () => { - // Clone yields a fresh animator with no PlayData populated - const cloneEntity = animator.entity.clone(); - const cloneAnimator = cloneEntity.getComponent(Animator); - - const survey = cloneAnimator.findAnimatorState("Survey"); - expect(survey).to.not.eq(null); - expect(survey.name).to.eq("Survey"); - expect(survey.speed).to.eq((survey as any)._state.speed); // live-bound default - - // Same handle returned on subsequent calls (verifies caching) - expect(cloneAnimator.findAnimatorState("Survey")).to.eq(survey); - }); - - it("per-instance speed set before play applies on first play", () => { - const handle = animator.findAnimatorState("Survey"); - handle.speed = 0.5; - animator.play("Survey"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.001); - - // Same handle observed via getCurrentAnimatorState - expect(animator.getCurrentAnimatorState(0)).to.eq(handle); - expect(handle.speed).to.eq(0.5); - }); - - it("per-instance speed survives crossFade out and back", () => { - animator.findAnimatorState("Survey").speed = 0.5; - animator.play("Survey"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.001); - - // crossFade out (fixed 0.05s duration) - animator.crossFadeInFixedDuration("Walk", 0.05, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); // complete crossfade - // crossFade back - animator.crossFadeInFixedDuration("Survey", 0.05, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - // @ts-ignore - const srcPlayData = animator._animatorLayersData[0].srcPlayData; - expect(srcPlayData.instance.name).to.eq("Survey"); // ensure crossfade actually completed back to Survey - expect(animator.findAnimatorState("Survey").speed).to.eq(0.5); - expect(srcPlayData.instance.speed).to.eq(0.5); - }); - - it("per-instance speed is per-Animator (clone isolation)", () => { - const cloneEntity = animator.entity.clone(); - const cloneAnimator = cloneEntity.getComponent(Animator); - expect(cloneAnimator.animatorController).to.eq(animator.animatorController); - - animator.findAnimatorState("Survey").speed = 0.5; - - expect(animator.findAnimatorState("Survey").speed).to.eq(0.5); - expect(cloneAnimator.findAnimatorState("Survey").speed).to.eq(1); // shared default - // shared asset not mutated - const sharedSurvey = animator.animatorController.layers[0].stateMachine.findStateByName("Survey"); - expect(sharedSurvey.speed).to.eq(1); - }); - - it("crossFade phase honors per-instance speed for time progression", () => { - // Set high per-instance speed on src state - animator.findAnimatorState("Survey").speed = 4; - animator.play("Survey"); - // @ts-ignore — Animator.update short-circuits to dt=0 if _playFrameCount===frameCount - animator.engine.time._frameCount++; - animator.update(0.001); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - const srcPlayedBefore = layerData.srcPlayData.playedTime; - - // Start crossFade — during crossFade, src should still advance per per-instance speed=4 - animator.crossFade("Walk", 0.5, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); // 50ms of crossfade - - const srcPlayedAfter = layerData.srcPlayData.playedTime; - const advanced = srcPlayedAfter - srcPlayedBefore; - // With per-instance speed=4 and dt=0.05, expect ~0.2 (4 * 0.05). With shared state.speed=1 it'd be ~0.05. - expect(advanced).to.be.closeTo(0.2, 0.05); - }); - - it("findAnimatorState rebuilds handle when state identity changes (remove/re-add same name)", () => { - const sm = animator.animatorController.layers[0].stateMachine; - const oldSurvey = animator.findAnimatorState("Survey"); - expect(oldSurvey).not.to.eq(null); - const oldStateRef = (oldSurvey as any)._state; - const originalIndex = sm.states.indexOf(oldStateRef); - - // Simulate dynamic controller mutation: remove and re-add same-name state - sm.removeState(oldStateRef); - const newStateRef = sm.addState("Survey"); - expect(newStateRef).not.to.eq(oldStateRef); - - const newHandle = animator.findAnimatorState("Survey"); - expect(newHandle).not.to.eq(null); - expect((newHandle as any)._state).to.eq(newStateRef); - expect(newHandle).not.to.eq(oldSurvey); - - // Restore original Survey state so subsequent tests still see the - // clip-bound state. Drop the barebones replacement and reinsert the - // original at its previous index in the states list/map. - sm.removeState(newStateRef); - sm.states.splice(originalIndex, 0, oldStateRef); - // @ts-ignore — _statesMap is private but rebuild requires direct access - sm._statesMap["Survey"] = oldStateRef; - // Reset cached layer data so the next findAnimatorState rebuilds against - // the restored state. - // @ts-ignore - animator._reset(); - }); - - it("rebuilds curve owners and event handlers when state identity changes via remove/re-add", () => { - const localEntity = new Entity(engine); - const localAnimator = localEntity.addComponent(Animator); - const controller = new AnimatorController(engine); - const layer = new AnimatorControllerLayer("layer"); - controller.addLayer(layer); - - // Old state binds rotation.x: 0 → 90 over 1s - const oldState = layer.stateMachine.addState("X"); - const oldClip = new AnimationClip("oldClip"); - const rotationCurve = new AnimationFloatCurve(); - const rk1 = new Keyframe(); - rk1.time = 0; - rk1.value = 0; - const rk2 = new Keyframe(); - rk2.time = 1; - rk2.value = 90; - rotationCurve.addKey(rk1); - rotationCurve.addKey(rk2); - oldClip.addCurveBinding("", Transform, "rotation.x", rotationCurve); - oldState.clip = oldClip; - oldState.wrapMode = WrapMode.Loop; - - localAnimator.animatorController = controller; - localAnimator.play("X"); - // @ts-ignore - localAnimator.engine.time._frameCount++; - localAnimator.update(0.5); - - // First play populates stateData cache keyed by name "X" pointing at oldState's owners. - expect(localEntity.transform.rotation.x).to.be.closeTo(45, 1); - - // Remove + re-add same-name state with a clip targeting a *different* property. - layer.stateMachine.removeState(oldState); - const newState = layer.stateMachine.addState("X"); - const newClip = new AnimationClip("newClip"); - const positionCurve = new AnimationFloatCurve(); - const pk1 = new Keyframe(); - pk1.time = 0; - pk1.value = 0; - const pk2 = new Keyframe(); - pk2.time = 1; - pk2.value = 5; - positionCurve.addKey(pk1); - positionCurve.addKey(pk2); - newClip.addCurveBinding("", Transform, "position.x", positionCurve); - newState.clip = newClip; - newState.wrapMode = WrapMode.Loop; - - // Reset transform so any stale binding shows up as a wrong-property mutation. - localEntity.transform.position = new Vector3(0, 0, 0); - localEntity.transform.rotation = new Vector3(0, 0, 0); - - // @ts-ignore — internal layer data, verify cached state BEFORE second play to confirm stale. - const layerDataBeforeSecondPlay = localAnimator._animatorLayersData[0]; - const stateDataBefore = layerDataBeforeSecondPlay.animatorStateDataMap.get(oldState); - expect(stateDataBefore, "stateData should exist after first play").to.not.eq(undefined); - expect(stateDataBefore.state, "first-play stateData.state must be oldState").to.eq(oldState); - - localAnimator.play("X"); - - // @ts-ignore — internal layer data - const layerData = localAnimator._animatorLayersData[0]; - const stateData = layerData.animatorStateDataMap.get(newState); - // stateData must rebuild against newState identity, not stay aliased to oldState. - expect(stateData.state, "second-play stateData.state must be newState").to.eq(newState); - // The cached curveLayerOwner must point at position.x owner now, not the stale rotation.x owner. - const firstOwnerProp = (stateData.curveLayerOwner[0] as any)?.curveOwner?.property; - expect(firstOwnerProp).to.eq("position.x"); - - // @ts-ignore - localAnimator.engine.time._frameCount++; - localAnimator.update(0.5); - - // After rebuild: position.x ≈ 2.5, rotation.x stays at 0. - // Without rebuild (stale stateData): curveLayerOwner[0] still points at rotation.x owner, - // so position curve value would be applied to rotation.x and position.x would never change. - expect(localEntity.transform.position.x).to.be.closeTo(2.5, 0.5); - expect(localEntity.transform.rotation.x).to.eq(0); - - localEntity.destroy(); - }); - - it("findAnimatorState resets stale layer data after controller mutation", () => { - // Ensure layerData[0] is populated - const handle1 = animator.findAnimatorState("Survey"); - expect(handle1).not.to.eq(null); - - // Mutate the controller — this dispatches the update flag - const controller = animator.animatorController; - const dummyLayer = new AnimatorControllerLayer("__dummy__"); - controller.addLayer(dummyLayer); - - // findAnimatorState should reset stale layerData and rebuild - const handle2 = animator.findAnimatorState("Survey"); - expect(handle2).not.to.eq(null); - expect(handle2).not.to.eq(handle1); // fresh handle after reset - - // Cleanup - controller.removeLayer(controller.layers.indexOf(dummyLayer)); - }); - - it("eventHandlers rebuild lazily when state.clip events change", () => { - const survey = animator.findAnimatorState("Survey"); - expect(survey).not.to.eq(null); - const surveyState = (survey as any)._state; - animator.play("Survey"); - - // @ts-ignore — internal layerData / stateData - const layerData = animator._animatorLayersData[0]; - const stateData = layerData.animatorStateDataMap.get(surveyState); - expect(stateData, "stateData should exist after play").to.not.eq(undefined); - - const versionBefore = stateData.eventsBuiltVersion; - expect(versionBefore).to.be.greaterThanOrEqual(0); - - // Dispatching the clip flag bumps clip's version → next access invalidates eventsBuiltVersion. - surveyState.clip._updateFlagManager.dispatch(); - - // Next play / state-data access triggers _ensureEventHandlersUpToDate and rebuilds. - animator.play("Survey"); - // @ts-ignore - const layerDataAfter = animator._animatorLayersData[0]; - const stateDataAfter = layerDataAfter.animatorStateDataMap.get(surveyState); - expect(stateDataAfter.eventsBuiltVersion).to.be.greaterThan(versionBefore); - }); - - it("crossFade to current state is no-op (avoids src/dest PlayData alias)", () => { - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - const srcBefore = layerData.srcPlayData; - const playedBefore = srcBefore.playedTime; - - // crossFade to the same state — should be ignored - animator.crossFade("Walk", 0.3, 0, 0); - - expect(layerData.srcPlayData).to.eq(srcBefore); - expect(layerData.srcPlayData.playedTime).to.eq(playedBefore); - expect(layerData.destPlayData).to.eq(null); - }); - - it("crossFade to currently-fading dest state is no-op", () => { - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - animator.crossFade("Run", 0.5, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - const destBefore = layerData.destPlayData; - const destPlayedBefore = destBefore.playedTime; - - // crossFade to the in-flight dest state — should be ignored - animator.crossFade("Run", 0.3, 0, 0); - - expect(layerData.destPlayData).to.eq(destBefore); - expect(layerData.destPlayData.playedTime).to.eq(destPlayedBefore); - }); - - it("state-machine self-transition is also a no-op (alias-guard policy)", () => { - const walk = animator.findAnimatorState("Walk"); - (walk as any)._state.clearTransitions(); - animator.animatorController.addParameter("restart", false); - - const selfTransition = (walk as any)._state.addTransition((walk as any)._state); - selfTransition.hasExitTime = false; - selfTransition.duration = 0.1; - selfTransition.addCondition("restart", AnimatorConditionMode.If, true); - - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - const srcBefore = layerData.srcPlayData; - const playedBefore = srcBefore.playedTime; - - // Trigger the self-transition - animator.setParameterValue("restart", true); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); - - // Self-transition is intentionally a no-op (one persistent PlayData per state). - // src should keep advancing as if no transition happened, dest stays null. - expect(layerData.srcPlayData).to.eq(srcBefore); - expect(layerData.srcPlayData.instance.name).to.eq("Walk"); - expect(layerData.srcPlayData.playedTime).to.be.greaterThan(playedBefore); - expect(layerData.destPlayData).to.eq(null); - }); - - it("play during crossFade clears stale destPlayData", () => { - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - animator.crossFade("Run", 0.5, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); - - // Interrupt the in-flight crossFade with a play() - animator.play("Survey"); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - expect(layerData.destPlayData).to.eq(null); - expect(layerData.crossFadeTransition).to.eq(null); - - // A subsequent crossFade to the previously-fading state should now succeed — - // the stale dest slot must not block it via the alias guard. - animator.crossFade("Run", 0.3, 0, 0); - expect(layerData.destPlayData?.instance.name).to.eq("Run"); - }); - - it("crossFade to nonexistent state is a safe no-op", () => { - animator.play("Walk"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - const before = animator.getCurrentAnimatorState(0); - animator.crossFade("MissingState", 0.3, 0, 0); - const after = animator.getCurrentAnimatorState(0); - - expect(after).to.eq(before); - // @ts-ignore — verify no junk layerData was written at array index -1. - expect(animator._animatorLayersData[-1]).to.eq(undefined); - }); - - it("findAnimatorState with out-of-range layerIndex returns null", () => { - expect(animator.findAnimatorState("Survey", 99)).to.eq(null); - expect(animator.findAnimatorState("Survey", -2)).to.eq(null); - }); - - it("play / crossFade with out-of-range layerIndex are safe no-ops", () => { - animator.play("Survey"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.001); - - const stateBefore = animator.getCurrentAnimatorState(0); - - expect(() => animator.play("Walk", 99)).not.to.throw(); - expect(() => animator.crossFade("Run", 0.3, 99, 0)).not.to.throw(); - - expect(animator.getCurrentAnimatorState(0)).to.eq(stateBefore); - // @ts-ignore — verify no junk layerData created at index -1 / 99 - expect(animator._animatorLayersData[-1]).to.eq(undefined); - // @ts-ignore - expect(animator._animatorLayersData[99]).to.eq(undefined); - }); - - it("transition out of a state with per-instance speed 0 does not produce NaN", () => { - const survey = animator.findAnimatorState("Survey"); - survey.speed = 0; // pause this state per-instance - animator.play("Survey"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - // crossFade out — destination state should still progress despite src speed=0 - animator.crossFade("Walk", 0.3, 0, 0); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - expect(Number.isNaN(layerData.srcPlayData.playedTime)).to.eq(false); - expect(Number.isNaN(layerData.destPlayData?.playedTime ?? 0)).to.eq(false); - // Walk dest should have progressed - expect(layerData.destPlayData?.playedTime).to.be.greaterThan(0); - }); - - it("no-exit transition out of speed=0 source preserves remaining deltaTime and avoids NaN", () => { - const survey = animator.findAnimatorState("Survey"); - const walk = animator.findAnimatorState("Walk"); - (survey as any)._state.clearTransitions(); - (walk as any)._state.clearTransitions(); - animator.animatorController.addParameter("goWalk", false); - - survey.speed = 0; // pause source per-instance - - const transition = (survey as any)._state.addTransition((walk as any)._state); - transition.hasExitTime = false; - transition.duration = 0.3; - transition.addCondition("goWalk", AnimatorConditionMode.If, true); - - animator.play("Survey"); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.05); - - animator.setParameterValue("goWalk", true); - // @ts-ignore - animator.engine.time._frameCount++; - animator.update(0.1); - - // @ts-ignore - const layerData = animator._animatorLayersData[0]; - expect(Number.isNaN(layerData.srcPlayData.playedTime)).to.eq(false); - expect(Number.isNaN(layerData.destPlayData?.playedTime ?? 0)).to.eq(false); - expect(layerData.destPlayData?.instance.name).to.eq("Walk"); - // dest should have advanced from the remaining deltaTime that was - // preserved by the playSpeed===0 guard - expect(layerData.destPlayData?.playedTime).to.be.greaterThan(0); - }); }); diff --git a/tests/src/core/AnimatorHang.test.ts b/tests/src/core/AnimatorHang.test.ts new file mode 100644 index 0000000000..1d87711713 --- /dev/null +++ b/tests/src/core/AnimatorHang.test.ts @@ -0,0 +1,34 @@ +import { Animator, Camera } from "@galacean/engine-core"; +import "@galacean/engine-loader"; +import type { GLTFResource } from "@galacean/engine-loader"; +import { WebGLEngine } from "@galacean/engine"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { glbResource } from "./model/fox"; + +const canvasDOM = document.createElement("canvas"); +canvasDOM.width = 1024; +canvasDOM.height = 1024; + +describe("Animator GLTF load hang regression on 1024x1024 canvas", function () { + let animator: Animator; + let engine: WebGLEngine; + + beforeAll(async function () { + engine = await WebGLEngine.create({ canvas: canvasDOM }); + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + rootEntity.addComponent(Camera); + const resource = await engine.resourceManager.load(glbResource); + const defaultSceneRoot = resource.defaultSceneRoot; + rootEntity.addChild(defaultSceneRoot); + animator = defaultSceneRoot.getComponent(Animator); + }); + + afterAll(function () { + engine?.destroy(); + }); + + it("loads an Animator component without hanging", () => { + expect(animator).toBeInstanceOf(Animator); + }); +});