Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions packages/core/src/animation/Animator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,10 @@ export class Animator extends Component {
const { state: destState } = destPlayData;
const transitionDuration = layerData.crossFadeTransition._getFixedDuration();

if (this._tryCrossFadeInterrupt(layerData, transitionDuration, destState, deltaTime, aniUpdate)) {
return;
}

const srcPlaySpeed = srcState.speed * speed;
const dstPlaySpeed = destState.speed * speed;
const dstPlayDeltaTime = dstPlaySpeed * deltaTime;
Expand Down Expand Up @@ -872,9 +876,12 @@ export class Animator extends Component {
) {
const { destPlayData } = layerData;
const { state } = destPlayData;

const transitionDuration = layerData.crossFadeTransition._getFixedDuration();

if (this._tryCrossFadeInterrupt(layerData, transitionDuration, state, deltaTime, aniUpdate)) {
return;
}

const playSpeed = state.speed * this.speed;
const playDeltaTime = playSpeed * deltaTime;

Expand Down Expand Up @@ -1081,7 +1088,7 @@ export class Animator extends Component {
const endTime = state.clipEndTime * clipDuration;

if (transitionCollection.noExitTimeCount) {
targetTransition = this._checkNoExitTimeTransition(layerData, transitionCollection, aniUpdate);
targetTransition = this._checkNoExitTimeTransitions(layerData, transitionCollection, aniUpdate);
if (targetTransition) {
return targetTransition;
}
Expand Down Expand Up @@ -1155,13 +1162,37 @@ export class Animator extends Component {
return targetTransition;
}

private _checkNoExitTimeTransition(
private _tryCrossFadeInterrupt(
layerData: AnimatorLayerData,
transitionCollection: AnimatorStateTransitionCollection,
transitionDuration: number,
currentDestState: AnimatorState,
deltaTime: number,
aniUpdate: boolean
): boolean {
if (transitionDuration > 0) {
const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine;
if (
anyStateTransitions.noExitTimeCount &&
this._checkNoExitTimeTransitions(layerData, anyStateTransitions, aniUpdate, currentDestState)
) {
this._updateState(layerData, deltaTime, aniUpdate);
return true;
}
}
return false;
}

private _checkNoExitTimeTransitions(
layerData: AnimatorLayerData,
transitionCollection: AnimatorStateTransitionCollection,
aniUpdate: boolean,
excludeDestState?: AnimatorState
): AnimatorStateTransition {
for (let i = 0, n = transitionCollection.count; i < n; ++i) {
for (let i = 0, n = transitionCollection.noExitTimeCount; i < n; ++i) {
const transition = transitionCollection.get(i);
// Skip if destination is same as current state (equivalent to Unity's canTransitionToSelf=false)
// TODO: Support canTransitionToSelf option on AnimatorStateTransition
if (excludeDestState && transition.destinationState === excludeDestState) continue;
if (
transition.mute ||
(transitionCollection.isSoloMode && !transition.solo) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export class AnimatorStateTransitionCollection {
updateTransitionsIndex(transition: AnimatorStateTransition, hasExitTime: boolean): void {
const transitions = this.transitions;
transitions.splice(transitions.indexOf(transition), 1);
if (hasExitTime) {
this.noExitTimeCount--;
}
this._addTransition(transition);
}

Expand All @@ -101,13 +104,14 @@ export class AnimatorStateTransitionCollection {
}

const { exitTime } = transition;
const { noExitTimeCount } = this;
const count = transitions.length;
const maxExitTime = count ? transitions[count - 1].exitTime : 0;
const maxExitTime = count > noExitTimeCount ? transitions[count - 1].exitTime : 0;
if (exitTime >= maxExitTime) {
transitions.push(transition);
} else {
let index = count;
while (--index >= 0 && exitTime < transitions[index].exitTime);
while (--index >= noExitTimeCount && exitTime < transitions[index].exitTime);
transitions.splice(index + 1, 0, transition);
}
}
Expand Down
139 changes: 138 additions & 1 deletion tests/src/core/Animator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ describe("Animator test", function () {
// @ts-ignore
animator._reset();
animator.animatorController.clearParameters();

// 清理 state machine transitions
const stateMachine = animator.animatorController.layers[0].stateMachine;
stateMachine.clearAnyStateTransitions();
stateMachine.clearEntryStateTransitions();

// 清理各状态的 transitions 并恢复默认属性
const stateNames = ["Survey", "Walk", "Run"];
for (const name of stateNames) {
const state = animator.findAnimatorState(name);
if (state) {
state.clearTransitions();
state.speed = 1;
state.clipStartTime = 0;
state.clipEndTime = 1;
state.wrapMode = WrapMode.Loop;
}
}
});
it("constructor", () => {
// Test default values
Expand Down Expand Up @@ -1017,5 +1035,124 @@ describe("Animator test", function () {

it("Clone", () => {
expect(animator.entity.clone().getComponent(Animator).animatorController).to.eq(animator.animatorController);
})
});

it("anyState transition interrupts crossFade", () => {
const { animatorController } = animator;
animatorController.addParameter("interrupt", false);
const stateMachine = animatorController.layers[0].stateMachine;
const idleState = animator.findAnimatorState("Survey");

// AnyState -> Idle (can interrupt)
const anyToIdle = stateMachine.addAnyStateTransition(idleState);
anyToIdle.hasExitTime = false;
anyToIdle.duration = 0.2;
anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true);

// Start crossFade using crossFade method
animator.play("Walk");
animator.crossFade("Run", 1.0);
// @ts-ignore
animator.engine.time._frameCount++;
animator.update(0.1);

// Get layerData after update (layerData is recreated after _reset in afterEach)
// @ts-ignore
const layerData = animator._getAnimatorLayerData(0);

// LayerState.CrossFading = 2
expect(layerData.layerState).to.eq(2);
expect(layerData.destPlayData.state.name).to.eq("Run");

// Trigger interrupt during crossFade
animator.setParameterValue("interrupt", true);
// @ts-ignore
animator.engine.time._frameCount++;
animator.update(0.1);

// Should have interrupted to Idle
expect(layerData.destPlayData.state.name).to.eq("Survey");
});

it("noExitTime transition scan should ignore exitTime transitions", () => {
const { animatorController } = animator;
animatorController.addParameter("goRun", true);
animatorController.addParameter("never", false);

const walkState = animator.findAnimatorState("Walk");
const runState = animator.findAnimatorState("Run");
const idleState = animator.findAnimatorState("Survey");

walkState.clipStartTime = 0;
walkState.clipEndTime = 1;
walkState.clearTransitions();

// A noExitTime transition that fails (ensures noExitTimeCount > 0).
const noExitFailTransition = walkState.addTransition(idleState);
noExitFailTransition.hasExitTime = false;
noExitFailTransition.duration = 0;
noExitFailTransition.addCondition("never", AnimatorConditionMode.If, true);

// A hasExitTime transition whose conditions are true, but should not fire until exitTime.
const exitTimeTransition = new AnimatorStateTransition();
exitTimeTransition.exitTime = 0.5;
exitTimeTransition.duration = 0;
exitTimeTransition.destinationState = runState;
exitTimeTransition.addCondition("goRun", AnimatorConditionMode.If, true);
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.clip.length * 0.25;
// @ts-ignore
animator.engine.time._frameCount++;
animator.update(preExitDeltaTime);
expect(layerData.srcPlayData.state.name).to.eq("Walk");
expect(layerData.destPlayData.state).to.be.undefined;

// Update past exitTime, should transition to Run.
// @ts-ignore
animator.engine.time._frameCount++;
animator.update(walkState.clip.length * 0.5);
expect(animator.getCurrentAnimatorState(0).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.clearTransitions();

// Add a noExitTime transition
const t1 = walkState.addTransition(runState);
t1.hasExitTime = false;

// Add a hasExitTime transition
const t2 = walkState.addTransition(idleState);
t2.hasExitTime = true;
t2.exitTime = 0.5;

// @ts-ignore
const collection = walkState._transitionCollection;
expect(collection.noExitTimeCount).to.eq(1);
expect(collection.count).to.eq(2);

// Toggle noExitTime -> hasExitTime
t1.hasExitTime = true;
t1.exitTime = 0.8;
expect(collection.noExitTimeCount).to.eq(0);
expect(collection.count).to.eq(2);

// Toggle hasExitTime -> noExitTime
t2.hasExitTime = false;
expect(collection.noExitTimeCount).to.eq(1);
expect(collection.count).to.eq(2);

// Verify array order: [t2(noExitTime), t1(exitTime=0.8)]
expect(collection.get(0)).to.eq(t2);
expect(collection.get(1)).to.eq(t1);
});
});
Loading