From b5a0ca69fb1c62adbc1b6a19c629e1b020d56e2b Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 29 Mar 2026 20:56:45 +0800 Subject: [PATCH 01/92] fix(animation): normalize single-root clip binding paths --- packages/core/src/Entity.ts | 8 ++++ .../src/gltf/parser/GLTFAnimationParser.ts | 8 ++++ tests/src/core/Animator.test.ts | 42 +++++++++++++++++++ tests/src/core/Entity.test.ts | 15 ++++++- tests/src/loader/GLTFLoader.test.ts | 11 +++++ 5 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 9470d05817..cf0eead61d 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -377,6 +377,14 @@ export class Entity extends EngineObject { return this; } + // Some imported animation clips are normalized to include the single scene root + // name (for example "mixamorig:Hips/..."), while the Animator may already sit on + // that root entity. Accept a self-name prefix so wrapped model roots and + // standalone single-root clips resolve through the same path convention. + if (splits[0] === this.name) { + return splits.length === 1 ? this : Entity._findChildByName(this, 0, splits, 1); + } + return Entity._findChildByName(this, 0, splits, 0); } diff --git a/packages/loader/src/gltf/parser/GLTFAnimationParser.ts b/packages/loader/src/gltf/parser/GLTFAnimationParser.ts index d1d7400c2f..10714899e7 100644 --- a/packages/loader/src/gltf/parser/GLTFAnimationParser.ts +++ b/packages/loader/src/gltf/parser/GLTFAnimationParser.ts @@ -118,6 +118,14 @@ export class GLTFAnimationParser extends GLTFParser { continue; } + // For single-root scenes, the scene root IS the top-level node (e.g. mixamorig:Hips). + // When this clip is used on a multi-root model (which wraps nodes in a GLTF_ROOT container), + // the Animator sits on GLTF_ROOT and needs the root node name in the path. + // Include the scene root name for single-root scenes to ensure consistent bone paths. + const sceneNodes = context.glTF.scenes[context.glTF.scene ?? 0]?.nodes; + if (sceneNodes?.length === 1) { + relativePath = relativePath === "" ? entity.name : `${entity.name}/${relativePath}`; + } let ComponentType: ComponentConstructor; let propertyName: string; switch (target.path) { diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 044312c943..5a436b6aaf 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1046,6 +1046,48 @@ describe("Animator test", function () { expect(animator.entity.clone().getComponent(Animator).animatorController).to.eq(animator.animatorController); }); + it("samples self-name-prefixed curve paths on wrapped roots", () => { + const wrappedRoot = new Entity(engine, "GLTF_ROOT"); + const hips = new Entity(engine, "mixamorig:Hips"); + const spine = new Entity(engine, "mixamorig:Spine"); + hips.parent = wrappedRoot; + spine.parent = hips; + + const clip = new AnimationClip("idle"); + const hipsCurve = new AnimationFloatCurve(); + const spineCurve = new AnimationFloatCurve(); + const hipsStart = new Keyframe(); + const hipsEnd = new Keyframe(); + hipsStart.time = 0; + hipsStart.value = 0; + hipsEnd.time = 0.1; + hipsEnd.value = 1; + hipsCurve.addKey(hipsStart); + hipsCurve.addKey(hipsEnd); + + const spineStart = new Keyframe(); + const spineEnd = new Keyframe(); + spineStart.time = 0; + spineStart.value = 0; + spineEnd.time = 0.1; + spineEnd.value = 1; + spineCurve.addKey(spineStart); + spineCurve.addKey(spineEnd); + + clip.addCurveBinding("mixamorig:Hips", Transform, "position.x", hipsCurve); + clip.addCurveBinding("mixamorig:Hips/mixamorig:Spine", Transform, "position.y", spineCurve); + + 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); + + 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("anyState transition interrupts crossFade", () => { const { animatorController } = animator; animatorController.addParameter("interrupt", false); diff --git a/tests/src/core/Entity.test.ts b/tests/src/core/Entity.test.ts index 0a428f4b60..2debbb4b86 100644 --- a/tests/src/core/Entity.test.ts +++ b/tests/src/core/Entity.test.ts @@ -320,6 +320,19 @@ describe("Entity", async () => { expect(parent.findByPath("child/grandson")).eq(grandson2); }); + it("findByPath accepts self-name prefix", () => { + const parent = new Entity(engine, "parent"); + parent.parent = scene.getRootEntity(); + const child = new Entity(engine, "child"); + child.parent = parent; + const grandson = new Entity(engine, "grandson"); + grandson.parent = child; + + expect(parent.findByPath("parent")).eq(parent); + expect(parent.findByPath("parent/child")).eq(child); + expect(parent.findByPath("parent/child/grandson")).eq(grandson); + }); + it("clearChildren", () => { const parent = new Entity(engine, "parent"); @@ -871,4 +884,4 @@ describe("Entity", async () => { expect(order).toEqual(["C", "B", "A"]); }); }); -}); \ No newline at end of file +}); diff --git a/tests/src/loader/GLTFLoader.test.ts b/tests/src/loader/GLTFLoader.test.ts index 6ad52fc919..7f9a8ebaa5 100644 --- a/tests/src/loader/GLTFLoader.test.ts +++ b/tests/src/loader/GLTFLoader.test.ts @@ -481,6 +481,17 @@ describe("glTF Loader test", function () { expect(renderer).to.exist; expect(renderer.blendShapeWeights).to.deep.include([1, 1]); }); + + it("single-root animation root channel should bind to the root node path", async () => { + const glTFResource: GLTFResource = await engine.resourceManager.load({ + type: AssetType.GLTF, + url: "mock/path/testA.gltf" + }); + + const clip = glTFResource.animations?.[0]; + expect(clip).to.exist; + expect(clip.curveBindings[0].relativePath).to.equal("entity1"); + }); }); describe("glTF instance test", function () { From df4dab86942bb4aae4d22164235ba8878451fdef Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 30 Mar 2026 14:50:22 +0800 Subject: [PATCH 02/92] fix(animation): add per-instance speed to AnimatorStatePlayData AnimatorState.speed is part of the shared AnimatorController asset. Modifying it at runtime pollutes all Animator instances sharing the same controller, causing animation speed corruption after cloning. - Add speed field to AnimatorStatePlayData, initialized from AnimatorState.speed on reset - Add proxy properties (name/clip/wrapMode/transitions/addStateMachineScript) - Change speed calculation to playData.speed * animator.speed - findAnimatorState now returns per-instance AnimatorStatePlayData - Export AnimatorStatePlayData for consumer code --- packages/core/src/animation/Animator.ts | 28 +++++++++-- packages/core/src/animation/index.ts | 1 + .../internal/AnimatorStatePlayData.ts | 46 ++++++++++++++++++- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 180c2d1356..c25c30e3c8 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -218,13 +218,31 @@ export class Animator extends Component { * @param stateName - The state name * @param layerIndex - The layer index(default -1). If layer is -1, find the first state with the given state name */ - findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorState { - return this._getAnimatorStateInfo(stateName, layerIndex).state; + /** + * Find the per-instance play data for a state by name. + * The returned object's `speed` is per-instance and safe to modify without affecting other Animator instances. + * @param stateName - The state name + * @param layerIndex - The layer index (default -1, searches all layers) + * @returns Per-instance AnimatorStatePlayData, or null if not found + */ + findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData { + const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); + if (!state || foundLayer < 0) return null; + const layerData = this._animatorLayersData[foundLayer]; + if (!layerData) return null; + // Check srcPlayData and destPlayData for the matching state + if (layerData.srcPlayData.state === state) return layerData.srcPlayData; + if (layerData.destPlayData.state === state) return layerData.destPlayData; + // State exists in controller but not currently playing — return srcPlayData initialized with the state + return layerData.srcPlayData; } /** * Get the layer by name. * @param name - The layer's name. + * @todo Return per-instance layer data (like AnimatorStatePlayData for states) instead of shared asset. + * Currently returns the shared AnimatorControllerLayer — modifying `weight` affects all instances. + * Should follow Unity's pattern: Animator.SetLayerWeight/GetLayerWeight (per-instance). */ findLayerByName(name: string): AnimatorControllerLayer { return this._animatorController?._layersMap[name]; @@ -616,7 +634,7 @@ export class Animator extends Component { const { srcPlayData } = layerData; const { state } = srcPlayData; - const playSpeed = state.speed * this.speed; + const playSpeed = srcPlayData.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; srcPlayData.updateOrientation(playDeltaTime); @@ -883,7 +901,7 @@ export class Animator extends Component { return; } - const playSpeed = state.speed * this.speed; + const playSpeed = destPlayData.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; destPlayData.updateOrientation(playDeltaTime); @@ -989,7 +1007,7 @@ export class Animator extends Component { ): void { const playData = layerData.srcPlayData; const { state } = playData; - const actualSpeed = state.speed * this.speed; + const actualSpeed = playData.speed * this.speed; const actualDeltaTime = actualSpeed * deltaTime; playData.updateOrientation(actualDeltaTime); diff --git a/packages/core/src/animation/index.ts b/packages/core/src/animation/index.ts index b829ffde54..bd201a871f 100644 --- a/packages/core/src/animation/index.ts +++ b/packages/core/src/animation/index.ts @@ -11,6 +11,7 @@ export { Animator } from "./Animator"; export { AnimatorController } from "./AnimatorController"; export { AnimatorControllerLayer } from "./AnimatorControllerLayer"; export { AnimatorState } from "./AnimatorState"; +export { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; export { AnimatorStateMachine } from "./AnimatorStateMachine"; export { AnimatorStateTransition } from "./AnimatorStateTransition"; export { AnimatorConditionMode } from "./enums/AnimatorConditionMode"; diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index 7d10fc2324..5cfa2fd3eb 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -1,20 +1,63 @@ +import { AnimationClip } from "../AnimationClip"; import { AnimatorState } from "../AnimatorState"; +import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState"; import { WrapMode } from "../enums/WrapMode"; +import { StateMachineScript } from "../StateMachineScript"; import { AnimatorStateData } from "./AnimatorStateData"; /** - * @internal + * Per-instance runtime data for an AnimatorState. + * Proxies read-only properties from the shared AnimatorState asset, + * while providing per-instance mutable properties (e.g. speed). */ export class AnimatorStatePlayData { + /** @internal */ state: AnimatorState; + /** @internal */ stateData: AnimatorStateData; + /** @internal */ playedTime: number; playState: AnimatorStatePlayState; + /** @internal */ clipTime: number; + /** @internal */ currentEventIndex: number; + /** @internal */ isForward = true; + /** @internal */ offsetFrameTime: number; + /** Per-instance speed. Initialized from AnimatorState.speed, safe to modify without affecting other instances. */ + speed: number = 1.0; + + // ── Proxy properties from AnimatorState (read-only) ── + + /** The name of the state. */ + get name(): string { + return this.state.name; + } + + /** The clip played by this state. */ + get clip(): AnimationClip { + return this.state.clip; + } + + /** The wrap mode. */ + get wrapMode(): WrapMode { + return this.state.wrapMode; + } + + /** The transitions going out of this state. */ + get transitions(): Readonly { + return this.state.transitions; + } + + /** + * Add a state machine script to the underlying AnimatorState. + */ + addStateMachineScript(scriptType: new () => T): T { + return this.state.addStateMachineScript(scriptType); + } private _changedOrientation = false; @@ -27,6 +70,7 @@ export class AnimatorStatePlayData { this.clipTime = state.clipStartTime * state.clip.length; this.currentEventIndex = 0; this.isForward = true; + this.speed = state.speed; this.state._transitionCollection.needResetCurrentCheckIndex = true; } From 28f5e212d1128c539b95995ae403a4997b0ee9b3 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Wed, 15 Apr 2026 17:38:32 +0800 Subject: [PATCH 03/92] fix(loader): normalize gltf wrapper and skin roots --- .../src/gltf/parser/GLTFAnimationParser.ts | 8 --- .../src/gltf/parser/GLTFParserContext.ts | 4 +- .../loader/src/gltf/parser/GLTFSceneParser.ts | 1 + .../loader/src/gltf/parser/GLTFSkinParser.ts | 45 ++++++++++++- tests/src/loader/GLTFLoader.test.ts | 66 +++++++++++++++++++ 5 files changed, 113 insertions(+), 11 deletions(-) diff --git a/packages/loader/src/gltf/parser/GLTFAnimationParser.ts b/packages/loader/src/gltf/parser/GLTFAnimationParser.ts index 10714899e7..d1d7400c2f 100644 --- a/packages/loader/src/gltf/parser/GLTFAnimationParser.ts +++ b/packages/loader/src/gltf/parser/GLTFAnimationParser.ts @@ -118,14 +118,6 @@ export class GLTFAnimationParser extends GLTFParser { continue; } - // For single-root scenes, the scene root IS the top-level node (e.g. mixamorig:Hips). - // When this clip is used on a multi-root model (which wraps nodes in a GLTF_ROOT container), - // the Animator sits on GLTF_ROOT and needs the root node name in the path. - // Include the scene root name for single-root scenes to ensure consistent bone paths. - const sceneNodes = context.glTF.scenes[context.glTF.scene ?? 0]?.nodes; - if (sceneNodes?.length === 1) { - relativePath = relativePath === "" ? entity.name : `${entity.name}/${relativePath}`; - } let ComponentType: ComponentConstructor; let propertyName: string; switch (target.path) { diff --git a/packages/loader/src/gltf/parser/GLTFParserContext.ts b/packages/loader/src/gltf/parser/GLTFParserContext.ts index 83f9f65124..b549da5ca2 100644 --- a/packages/loader/src/gltf/parser/GLTFParserContext.ts +++ b/packages/loader/src/gltf/parser/GLTFParserContext.ts @@ -116,13 +116,13 @@ export class GLTFParserContext { return AssetPromise.all([ this.get(GLTFParserType.Validator), + this.get(GLTFParserType.Scene), this.get(GLTFParserType.Texture), this.get(GLTFParserType.Material), this.get(GLTFParserType.Mesh), this.get(GLTFParserType.Skin), this.get(GLTFParserType.Animation), - this.get(GLTFParserType.AnimatorController), - this.get(GLTFParserType.Scene) + this.get(GLTFParserType.AnimatorController) ]).then(() => { const glTFResource = this.glTFResource; const animatorController = glTFResource.animatorController; diff --git a/packages/loader/src/gltf/parser/GLTFSceneParser.ts b/packages/loader/src/gltf/parser/GLTFSceneParser.ts index 5ee981b1d3..b74424d2e6 100644 --- a/packages/loader/src/gltf/parser/GLTFSceneParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSceneParser.ts @@ -38,6 +38,7 @@ export class GLTFSceneParser extends GLTFParser { sceneRoot.addChild(context.get(GLTFParserType.Entity, sceneNodes[i])); } + (glTFResource._sceneRoots ||= [])[index] = sceneRoot; if (isDefaultScene) { glTFResource._defaultSceneRoot = sceneRoot; } diff --git a/packages/loader/src/gltf/parser/GLTFSkinParser.ts b/packages/loader/src/gltf/parser/GLTFSkinParser.ts index 7c1580e4ca..978dd4780e 100644 --- a/packages/loader/src/gltf/parser/GLTFSkinParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSkinParser.ts @@ -39,7 +39,7 @@ export class GLTFSkinParser extends GLTFParser { const rootBone = entities[skeleton]; skin.rootBone = rootBone; } else { - const rootBone = this._findSkeletonRootBone(joints, entities); + const rootBone = this._findSceneRootBone(context, joints, entities) ?? this._findSkeletonRootBone(joints, entities); if (rootBone) { skin.rootBone = rootBone; } else { @@ -53,6 +53,49 @@ export class GLTFSkinParser extends GLTFParser { return AssetPromise.resolve(skinPromise); } + private _findSceneRootBone(context: GLTFParserContext, joints: number[], entities: Entity[]): Entity | null { + const { glTF, glTFResource } = context; + const scenes = glTF.scenes; + const sceneRoots = glTFResource._sceneRoots; + + if (!scenes?.length || !sceneRoots?.length) { + return null; + } + + for (let i = 0, n = scenes.length; i < n; i++) { + const sceneNodes = scenes[i].nodes ?? []; + if (sceneNodes.length <= 1) { + continue; + } + + const sceneRoot = sceneRoots[i]; + if (!sceneRoot) { + continue; + } + + const sceneRootChildren = new Set(sceneNodes.map((nodeIndex) => entities[nodeIndex])); + let allJointsUnderSceneRoot = true; + + for (let j = 0, m = joints.length; j < m; j++) { + let entity = entities[joints[j]]; + while (entity?.parent) { + entity = entity.parent; + } + + if (!sceneRootChildren.has(entity)) { + allJointsUnderSceneRoot = false; + break; + } + } + + if (allJointsUnderSceneRoot) { + return sceneRoot; + } + } + + return null; + } + private _findSkeletonRootBone(joints: number[], entities: Entity[]): Entity { const paths = >{}; for (const index of joints) { diff --git a/tests/src/loader/GLTFLoader.test.ts b/tests/src/loader/GLTFLoader.test.ts index 7f9a8ebaa5..7e1bf162b7 100644 --- a/tests/src/loader/GLTFLoader.test.ts +++ b/tests/src/loader/GLTFLoader.test.ts @@ -39,6 +39,60 @@ beforeAll(async function () { @registerGLTFParser(GLTFParserType.Schema) class GLTFCustomJSONParser extends GLTFParser { parse(context: GLTFParserContext) { + if (context.glTFResource.url.endsWith("testSkinRoot.gltf")) { + context.buffers = [new ArrayBuffer(128)]; + return Promise.resolve({ + asset: { + version: "2.0" + }, + scene: 0, + scenes: [ + { + nodes: [0, 1] + } + ], + nodes: [ + { + name: "Character_Man" + }, + { + name: "mixamorig:Hips", + children: [2] + }, + { + name: "mixamorig:Spine" + } + ], + skins: [ + { + inverseBindMatrices: 0, + joints: [1, 2] + } + ], + accessors: [ + { + bufferView: 0, + byteOffset: 0, + componentType: 5126, + count: 2, + type: "MAT4" + } + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 128 + } + ], + buffers: [ + { + byteLength: 128 + } + ] + }); + } + const glTF = { buffers: [ { @@ -541,6 +595,18 @@ describe("glTF scene root structure", function () { expect(defaultSceneRoot.children.length).to.equal(1); expect(defaultSceneRoot.children[0].name).to.equal("entity1"); }); + + it("Multi-root skins without skeleton should use the scene wrapper as rootBone", async () => { + const glTFResource: GLTFResource = await engine.resourceManager.load({ + type: AssetType.GLTF, + url: "mock/path/testSkinRoot.gltf" + }); + const { defaultSceneRoot, skins } = glTFResource; + + expect(defaultSceneRoot.name).to.equal("GLTF_ROOT"); + expect(defaultSceneRoot.children.length).to.equal(2); + expect(skins[0].rootBone).to.equal(defaultSceneRoot); + }); }); describe("glTF instance test", function () { From 234164e7eef2ab80c700330bd000b513737b2bfe Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sat, 9 May 2026 21:32:58 +0800 Subject: [PATCH 04/92] refactor(animation): per-state PlayData handle with override-aware speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote AnimatorStatePlayData from a play-slot object to a per-Animator per-state persistent handle. Each AnimatorLayerData holds a state→PlayData map; srcPlayData/destPlayData become nullable references into the map. API: - findAnimatorState(name, layerIdx?) returns AnimatorStatePlayData|null, lazy-creating the handle on first access (works even when the state has never played) - playData.speed is a getter/setter backed by _speedOverride; reads fall back to state.speed (live binding); clearSpeedOverride() resumes tracking the shared default - playData.state.xxx for shared asset access (no proxy properties) - resetForPlay() resets runtime fields only; user overrides survive transitions Bugs fixed: - _updateCrossFadeState now multiplies by playData.speed (was state.speed), so per-instance speed applies during cross-fade - findAnimatorState no longer returns the wrong state's playData when the queried state isn't currently playing (was: fell back to srcPlayData) Lifecycle changes: - AnimatorLayerData.statePlayDataMap caches per-state handles - switchPlayData() replaced by promoteDest() (src ← dest, dest = null) - _preparePlay/_prepareCrossFade get-or-create from the map and assign references rather than reset slot objects Cleanup: - Remove AnimatorStatePlayData proxy properties (name/clip/wrapMode/ transitions/addStateMachineScript) — use playData.state.xxx instead - Drop @todo on findLayerByName and duplicate JSDoc on findAnimatorState --- packages/core/src/animation/Animator.ts | 48 ++++------ .../animation/internal/AnimatorLayerData.ts | 28 ++++-- .../internal/AnimatorStatePlayData.ts | 87 ++++++++++--------- 3 files changed, 86 insertions(+), 77 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index c25c30e3c8..e7fa375f09 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -210,39 +210,27 @@ export class Animator extends Component { * @param layerIndex - The layer index */ getCurrentAnimatorState(layerIndex: number): AnimatorState { - return this._animatorLayersData[layerIndex]?.srcPlayData?.state; + return this._animatorLayersData[layerIndex]?.srcPlayData?.state ?? null; } /** - * Get the state by name. - * @param stateName - The state name - * @param layerIndex - The layer index(default -1). If layer is -1, find the first state with the given state name - */ - /** - * Find the per-instance play data for a state by name. - * The returned object's `speed` is per-instance and safe to modify without affecting other Animator instances. + * Get the per-instance play data handle for a state by name. + * The returned handle persists for the layer's lifetime; modifications to + * `playData.speed` survive state transitions. + * * @param stateName - The state name * @param layerIndex - The layer index (default -1, searches all layers) - * @returns Per-instance AnimatorStatePlayData, or null if not found + * @returns Per-instance AnimatorStatePlayData, or null if no state matches */ findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData { const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); if (!state || foundLayer < 0) return null; - const layerData = this._animatorLayersData[foundLayer]; - if (!layerData) return null; - // Check srcPlayData and destPlayData for the matching state - if (layerData.srcPlayData.state === state) return layerData.srcPlayData; - if (layerData.destPlayData.state === state) return layerData.destPlayData; - // State exists in controller but not currently playing — return srcPlayData initialized with the state - return layerData.srcPlayData; + return this._getAnimatorLayerData(foundLayer).getOrCreatePlayData(state); } /** * Get the layer by name. * @param name - The layer's name. - * @todo Return per-instance layer data (like AnimatorStatePlayData for states) instead of shared asset. - * Currently returns the shared AnimatorControllerLayer — modifying `weight` affects all instances. - * Should follow Unity's pattern: Animator.SetLayerWeight/GetLayerWeight (per-instance). */ findLayerByName(name: string): AnimatorControllerLayer { return this._animatorController?._layersMap[name]; @@ -539,8 +527,8 @@ export class Animator extends Component { } private _prepareStandbyCrossFading(animatorLayerData: AnimatorLayerData): void { - // Standby have two sub state, one is never play, one is finished, never play srcPlayData.state is null - animatorLayerData.srcPlayData.state && this._prepareSrcCrossData(animatorLayerData, true); + // Standby have two sub state, one is never play (srcPlayData is null), one is finished (srcPlayData is non-null) + animatorLayerData.srcPlayData && this._prepareSrcCrossData(animatorLayerData, true); // Add dest cross curve data this._prepareDestCrossData(animatorLayerData, true); } @@ -771,8 +759,8 @@ export class Animator extends Component { return; } - const srcPlaySpeed = srcState.speed * speed; - const dstPlaySpeed = destState.speed * speed; + const srcPlaySpeed = srcPlayData.speed * speed; + const dstPlaySpeed = destPlayData.speed * speed; const dstPlayDeltaTime = dstPlaySpeed * deltaTime; srcPlayData && srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); @@ -1069,7 +1057,7 @@ export class Animator extends Component { } else { layerData.layerState = LayerState.Playing; } - layerData.switchPlayData(); + layerData.promoteDest(); layerData.crossFadeTransition = null; } @@ -1329,7 +1317,9 @@ export class Animator extends Component { this._preparePlayOwner(animatorLayerData, state); animatorLayerData.layerState = LayerState.Playing; - animatorLayerData.srcPlayData.reset(state, animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); + const playData = animatorLayerData.getOrCreatePlayData(state); + playData.resetForPlay(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); + animatorLayerData.srcPlayData = playData; animatorLayerData.resetCurrentCheckIndex(); return true; @@ -1431,11 +1421,9 @@ export class Animator extends Component { const animatorLayerData = this._getAnimatorLayerData(layerIndex); const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex); - animatorLayerData.destPlayData.reset( - crossState, - animatorStateData, - transition.offset * crossState._getClipActualEndTime() - ); + const destPlayData = animatorLayerData.getOrCreatePlayData(crossState); + destPlayData.resetForPlay(animatorStateData, transition.offset * crossState._getClipActualEndTime()); + animatorLayerData.destPlayData = destPlayData; animatorLayerData.resetCurrentCheckIndex(); switch (animatorLayerData.layerState) { diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index a7bd04f1a5..da38df0661 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -1,4 +1,5 @@ import { AnimatorControllerLayer } from "../AnimatorControllerLayer"; +import { AnimatorState } from "../AnimatorState"; import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { LayerState } from "../enums/LayerState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; @@ -13,19 +14,32 @@ export class AnimatorLayerData { layer: AnimatorControllerLayer; curveOwnerPool: Record> = Object.create(null); animatorStateDataMap: Record = {}; - srcPlayData: AnimatorStatePlayData = new AnimatorStatePlayData(); - destPlayData: AnimatorStatePlayData = new AnimatorStatePlayData(); + /** state → 持久 per-state PlayData handle. Lazy populated. */ + statePlayDataMap = new Map(); + /** Reference to the currently playing state's PlayData. Null when standby. */ + srcPlayData: AnimatorStatePlayData | null = null; + /** Reference to the cross-fade target state's PlayData. Null when not cross-fading. */ + destPlayData: AnimatorStatePlayData | null = null; layerState: LayerState = LayerState.Standby; crossCurveMark: number = 0; manuallyTransition: AnimatorStateTransition = new AnimatorStateTransition(); crossFadeTransition: AnimatorStateTransition; crossLayerOwnerCollection: AnimationCurveLayerOwner[] = []; - switchPlayData(): void { - const srcPlayData = this.destPlayData; - const switchTemp = this.srcPlayData; - this.srcPlayData = srcPlayData; - this.destPlayData = switchTemp; + /** Get or lazily create the persistent PlayData for a state. */ + getOrCreatePlayData(state: AnimatorState): AnimatorStatePlayData { + let playData = this.statePlayDataMap.get(state); + if (!playData) { + playData = new AnimatorStatePlayData(state); + this.statePlayDataMap.set(state, playData); + } + return playData; + } + + /** After cross-fade completes, promote destPlayData to srcPlayData. */ + promoteDest(): void { + this.srcPlayData = this.destPlayData; + this.destPlayData = null; } resetCurrentCheckIndex(): void { diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index 5cfa2fd3eb..b228508555 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -1,79 +1,85 @@ -import { AnimationClip } from "../AnimationClip"; import { AnimatorState } from "../AnimatorState"; -import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState"; import { WrapMode } from "../enums/WrapMode"; -import { StateMachineScript } from "../StateMachineScript"; import { AnimatorStateData } from "./AnimatorStateData"; /** - * Per-instance runtime data for an AnimatorState. - * Proxies read-only properties from the shared AnimatorState asset, - * while providing per-instance mutable properties (e.g. speed). + * Per-Animator per-state runtime handle. + * + * Lifecycle: created lazily by AnimatorLayerData.getOrCreatePlayData on first access + * (either via Animator.findAnimatorState or when the state begins playing). Persists + * for the layer's lifetime, so per-instance overrides (e.g. speed) survive transitions + * out of and back into the state. + * + * Use `playData.state.xxx` to access shared AnimatorState configuration (clip, transitions, etc.). + * Use `playData.speed` for per-instance speed override (live-bound to state.speed when not overridden). + * Engine-managed runtime fields (playState, clipTime, ...) are read-only by user convention. */ export class AnimatorStatePlayData { - /** @internal */ - state: AnimatorState; + /** The shared AnimatorState asset. Read-only reference. */ + readonly state: AnimatorState; + /** @internal */ stateData: AnimatorStateData; /** @internal */ - playedTime: number; - playState: AnimatorStatePlayState; + playedTime: number = 0; + /** Current playback state. Engine-managed. */ + playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted; /** @internal */ clipTime: number; /** @internal */ - currentEventIndex: number; + currentEventIndex: number = 0; /** @internal */ isForward = true; /** @internal */ - offsetFrameTime: number; - /** Per-instance speed. Initialized from AnimatorState.speed, safe to modify without affecting other instances. */ - speed: number = 1.0; + offsetFrameTime: number = 0; - // ── Proxy properties from AnimatorState (read-only) ── + private _speedOverride: number | undefined; + private _changedOrientation = false; - /** The name of the state. */ - get name(): string { - return this.state.name; + /** + * Per-instance playback speed for this state. + * + * - Read: returns the override if set; otherwise live-reads `state.speed`. + * - Write: sets the override. Subsequent changes to `state.speed` no longer affect this instance until `clearSpeedOverride()`. + * + * Override persists across state transitions. + */ + get speed(): number { + return this._speedOverride ?? this.state.speed; } - /** The clip played by this state. */ - get clip(): AnimationClip { - return this.state.clip; + set speed(value: number) { + this._speedOverride = value; } - /** The wrap mode. */ - get wrapMode(): WrapMode { - return this.state.wrapMode; + /** Clear the per-instance speed override; resume tracking shared `state.speed`. */ + clearSpeedOverride(): void { + this._speedOverride = undefined; } - /** The transitions going out of this state. */ - get transitions(): Readonly { - return this.state.transitions; + /** @internal */ + constructor(state: AnimatorState) { + this.state = state; + this.clipTime = state.clipStartTime * state.clip.length; } /** - * Add a state machine script to the underlying AnimatorState. + * @internal + * Reset runtime fields when (re-)entering this state. Does NOT touch user overrides. */ - addStateMachineScript(scriptType: new () => T): T { - return this.state.addStateMachineScript(scriptType); - } - - private _changedOrientation = false; - - reset(state: AnimatorState, stateData: AnimatorStateData, offsetFrameTime: number): void { - this.state = state; - this.playedTime = 0; - this.offsetFrameTime = offsetFrameTime; + resetForPlay(stateData: AnimatorStateData, offsetFrameTime: number): void { this.stateData = stateData; + this.offsetFrameTime = offsetFrameTime; + this.playedTime = 0; this.playState = AnimatorStatePlayState.UnStarted; - this.clipTime = state.clipStartTime * state.clip.length; + this.clipTime = this.state.clipStartTime * this.state.clip.length; this.currentEventIndex = 0; this.isForward = true; - this.speed = state.speed; this.state._transitionCollection.needResetCurrentCheckIndex = true; } + /** @internal */ updateOrientation(deltaTime: number): void { if (deltaTime !== 0) { const lastIsForward = this.isForward; @@ -85,6 +91,7 @@ export class AnimatorStatePlayData { } } + /** @internal */ update(deltaTime: number): void { this.playedTime += deltaTime; const state = this.state; From 1cc59e83dc0c850db15f16c707dbac0c9fa2d839 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sat, 9 May 2026 21:47:50 +0800 Subject: [PATCH 05/92] refactor(animation): clipless-state safety + move PlayData out of internal/ Address code quality review on 57da59aa9: - AnimatorStatePlayData constructor no longer reads state.clip; clipTime defers to resetForPlay so findAnimatorState doesn't crash for states with no clip yet - Move AnimatorStatePlayData from internal/ to animation/ root since it is now public API returned by findAnimatorState; update imports - Annotate findAnimatorState and getCurrentAnimatorState return types as | null to match runtime behavior - Remove dead && guards in _updateCrossFadeState (layerState guarantees non-null entry) - Tighten AnimatorLayerData field comments --- packages/core/src/animation/Animator.ts | 10 +++++----- .../animation/{internal => }/AnimatorStatePlayData.ts | 11 +++++------ packages/core/src/animation/index.ts | 2 +- .../core/src/animation/internal/AnimatorLayerData.ts | 8 ++++---- 4 files changed, 15 insertions(+), 16 deletions(-) rename packages/core/src/animation/{internal => }/AnimatorStatePlayData.ts (92%) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index e7fa375f09..4104bf6d79 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -23,7 +23,7 @@ import { AnimationCurveLayerOwner } from "./internal/AnimationCurveLayerOwner"; import { AnimationEventHandler } from "./internal/AnimationEventHandler"; import { AnimatorLayerData } from "./internal/AnimatorLayerData"; import { AnimatorStateData } from "./internal/AnimatorStateData"; -import { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; +import { AnimatorStatePlayData } from "./AnimatorStatePlayData"; import { AnimationCurveOwner } from "./internal/animationCurveOwner/AnimationCurveOwner"; /** @@ -209,7 +209,7 @@ export class Animator extends Component { * Get the playing state from the target layerIndex. * @param layerIndex - The layer index */ - getCurrentAnimatorState(layerIndex: number): AnimatorState { + getCurrentAnimatorState(layerIndex: number): AnimatorState | null { return this._animatorLayersData[layerIndex]?.srcPlayData?.state ?? null; } @@ -222,7 +222,7 @@ export class Animator extends Component { * @param layerIndex - The layer index (default -1, searches all layers) * @returns Per-instance AnimatorStatePlayData, or null if no state matches */ - findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData { + findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData | null { const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); if (!state || foundLayer < 0) return null; return this._getAnimatorLayerData(foundLayer).getOrCreatePlayData(state); @@ -763,8 +763,8 @@ export class Animator extends Component { const dstPlaySpeed = destPlayData.speed * speed; const dstPlayDeltaTime = dstPlaySpeed * deltaTime; - srcPlayData && srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); - destPlayData && destPlayData.updateOrientation(dstPlayDeltaTime); + srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); + destPlayData.updateOrientation(dstPlayDeltaTime); const { clipTime: lastSrcClipTime, playState: lastSrcPlayState } = srcPlayData; const { clipTime: lastDestClipTime, playState: lastDstPlayState } = destPlayData; diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/AnimatorStatePlayData.ts similarity index 92% rename from packages/core/src/animation/internal/AnimatorStatePlayData.ts rename to packages/core/src/animation/AnimatorStatePlayData.ts index b228508555..ba4f327b71 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/AnimatorStatePlayData.ts @@ -1,7 +1,7 @@ -import { AnimatorState } from "../AnimatorState"; -import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState"; -import { WrapMode } from "../enums/WrapMode"; -import { AnimatorStateData } from "./AnimatorStateData"; +import { AnimatorState } from "./AnimatorState"; +import { AnimatorStatePlayState } from "./enums/AnimatorStatePlayState"; +import { WrapMode } from "./enums/WrapMode"; +import { AnimatorStateData } from "./internal/AnimatorStateData"; /** * Per-Animator per-state runtime handle. @@ -26,7 +26,7 @@ export class AnimatorStatePlayData { /** Current playback state. Engine-managed. */ playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted; /** @internal */ - clipTime: number; + clipTime: number = 0; /** @internal */ currentEventIndex: number = 0; /** @internal */ @@ -61,7 +61,6 @@ export class AnimatorStatePlayData { /** @internal */ constructor(state: AnimatorState) { this.state = state; - this.clipTime = state.clipStartTime * state.clip.length; } /** diff --git a/packages/core/src/animation/index.ts b/packages/core/src/animation/index.ts index bd201a871f..307ac5bdd0 100644 --- a/packages/core/src/animation/index.ts +++ b/packages/core/src/animation/index.ts @@ -11,7 +11,7 @@ export { Animator } from "./Animator"; export { AnimatorController } from "./AnimatorController"; export { AnimatorControllerLayer } from "./AnimatorControllerLayer"; export { AnimatorState } from "./AnimatorState"; -export { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; +export { AnimatorStatePlayData } from "./AnimatorStatePlayData"; export { AnimatorStateMachine } from "./AnimatorStateMachine"; export { AnimatorStateTransition } from "./AnimatorStateTransition"; export { AnimatorConditionMode } from "./enums/AnimatorConditionMode"; diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index da38df0661..5e4e56d87d 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -4,7 +4,7 @@ import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { LayerState } from "../enums/LayerState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimatorStateData } from "./AnimatorStateData"; -import { AnimatorStatePlayData } from "./AnimatorStatePlayData"; +import { AnimatorStatePlayData } from "../AnimatorStatePlayData"; /** * @internal @@ -14,11 +14,11 @@ export class AnimatorLayerData { layer: AnimatorControllerLayer; curveOwnerPool: Record> = Object.create(null); animatorStateDataMap: Record = {}; - /** state → 持久 per-state PlayData handle. Lazy populated. */ + /** Per-state PlayData handles. Lazy populated. */ statePlayDataMap = new Map(); - /** Reference to the currently playing state's PlayData. Null when standby. */ + /** Currently playing state's PlayData; null when standby. */ srcPlayData: AnimatorStatePlayData | null = null; - /** Reference to the cross-fade target state's PlayData. Null when not cross-fading. */ + /** Cross-fade target state's PlayData; null when not cross-fading. */ destPlayData: AnimatorStatePlayData | null = null; layerState: LayerState = LayerState.Standby; crossCurveMark: number = 0; From 296e5b9eda1458c2269bb9d7ea879d0e18fbcfbf Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sat, 9 May 2026 22:09:39 +0800 Subject: [PATCH 06/92] test(animation): per-state PlayData handle and per-instance speed Add 6 regression tests covering the new findAnimatorState handle: - lazy create on first access (state never played) - speed override set before play applies on first play - override survives crossFade out and back - override is per-Animator (clone isolation, shared asset unmutated) - crossFade phase uses playData.speed (was state.speed before fix) - clearSpeedOverride resumes live tracking of state.speed Fix existing call sites broken by proxy removal: tests that accessed state.clip / state.clearTransitions / state.clipStartTime etc. now go through state.state.xxx (the shared AnimatorState). state.speed reads and writes remain on the per-instance handle. --- tests/src/core/Animator.test.ts | 333 +++++++++++++++++++++----------- 1 file changed, 215 insertions(+), 118 deletions(-) diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 5a436b6aaf..1e57fc9b2a 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -70,11 +70,12 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); stateMachine.clearEntryStateTransitions(); - // 清理各状态的 transitions 并恢复默认属性 + // 清理各状态的 transitions 并恢复默认属性 (mutate shared AnimatorState) const stateNames = ["Survey", "Walk", "Run"]; for (const name of stateNames) { - const state = animator.findAnimatorState(name); - if (state) { + const playData = animator.findAnimatorState(name); + if (playData) { + const state = playData.state; state.clearTransitions(); state.speed = 1; state.clipStartTime = 0; @@ -201,12 +202,12 @@ describe("Animator test", function () { animator.play(stateName); const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); let animatorState = animator.findAnimatorState(stateName, layerIndex); - expect(animatorState).to.eq(currentAnimatorState); + expect(animatorState.state).to.eq(currentAnimatorState); animator.play(expectedStateName); animatorState = animator.findAnimatorState(expectedStateName, layerIndex); - expect(animatorState).not.to.eq(currentAnimatorState); - expect(animatorState.name).to.eq(expectedStateName); + expect(animatorState.state).not.to.eq(currentAnimatorState); + expect(animatorState.state.name).to.eq(expectedStateName); }); it("animation getCurrentAnimatorState", () => { @@ -253,22 +254,22 @@ describe("Animator test", function () { 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._getDuration()); + expect(srcPlayData.clipTime).to.eq(0.3 + 0.1 * runState.state._getDuration()); }); it("animation cross fade by transition", () => { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const transition = new AnimatorStateTransition(); - transition.destinationState = runState; + transition.destinationState = runState.state; transition.duration = 1; transition.exitTime = 1; - walkState.addTransition(transition); + walkState.state.addTransition(transition); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.clip.length - 0.1); + animator.update(walkState.state.clip.length - 0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); @@ -311,7 +312,7 @@ describe("Animator test", function () { additiveLayer.mask = mask; additiveLayer.blendingMode = AnimatorLayerBlendingMode.Additive; animatorController.addLayer(additiveLayer); - const clip = animator.findAnimatorState("Run").clip; + const clip = animator.findAnimatorState("Run").state.clip; const newState = animatorStateMachine.addState("Run"); newState.clipStartTime = 1; newState.clip = clip; @@ -361,7 +362,7 @@ describe("Animator test", function () { event0.time = 0; const state = animator.findAnimatorState("Walk"); - state.clip.addEvent(event0); + state.state.clip.addEvent(event0); animator.update(10); expect(testScriptSpy).toHaveBeenCalledTimes(1); }); @@ -372,11 +373,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - idleState.clearTransitions(); + idleState.state.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clearTransitions(); + runState.state.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -384,68 +385,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState; + toWalkTransition.destinationState = walkState.state; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.9; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.addTransition(toWalkTransition); + idleState.state.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - (toWalkTransition.exitTime * idleState._getDuration()) / idleSpeed + + (toWalkTransition.exitTime * idleState.state._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * walkState._getDuration(); + toWalkTransition.duration * walkState.state._getDuration(); - const exitTransition = idleState.addExitTransition(); + const exitTransition = idleState.state.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState; + toRunTransition.destinationState = runState.state; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.9; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.addTransition(toRunTransition); + walkState.state.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * walkState.state._getDuration() + //@ts-ignore - toRunTransition.duration * runState._getDuration(); + toRunTransition.duration * runState.state._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState; + toIdleTransition.destinationState = idleState.state; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.9; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.addTransition(toIdleTransition); + walkState.state.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * walkState.state._getDuration() + //@ts-ignore - (toIdleTransition.duration * idleState._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState.state._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = walkState; + runToWalkTransition.destinationState = walkState.state; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.9; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.addTransition(runToWalkTransition); + runState.state.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() + //@ts-ignore - runToWalkTransition.duration * walkState._getDuration(); + runToWalkTransition.duration * walkState.state._getDuration(); - stateMachine.addEntryStateTransition(idleState); + stateMachine.addEntryStateTransition(idleState.state); - const anyTransition = stateMachine.addAnyStateTransition(idleState); + const anyTransition = stateMachine.addAnyStateTransition(idleState.state); 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._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * walkState.state._getDuration() + // @ts-ignore - (anyTransition.duration * idleState._getDuration()) / idleSpeed; + (anyTransition.duration * idleState.state._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -497,11 +498,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - idleState.clearTransitions(); + idleState.state.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clearTransitions(); + runState.state.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -509,68 +510,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState; + toWalkTransition.destinationState = walkState.state; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.1; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.addTransition(toWalkTransition); + idleState.state.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - ((1 - toWalkTransition.exitTime) * idleState._getDuration()) / idleSpeed + + ((1 - toWalkTransition.exitTime) * idleState.state._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * walkState._getDuration(); + toWalkTransition.duration * walkState.state._getDuration(); - const exitTransition = idleState.addExitTransition(); + const exitTransition = idleState.state.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState; + toRunTransition.destinationState = runState.state; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.1; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.addTransition(toRunTransition); + walkState.state.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (1 - toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + + (1 - toRunTransition.exitTime - toWalkTransition.duration) * walkState.state._getDuration() + //@ts-ignore - toRunTransition.duration * runState._getDuration(); + toRunTransition.duration * runState.state._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState; + toIdleTransition.destinationState = idleState.state; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.1; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.addTransition(toIdleTransition); + walkState.state.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (1 - toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + + (1 - toIdleTransition.exitTime - toRunTransition.duration) * walkState.state._getDuration() + //@ts-ignore - (toIdleTransition.duration * idleState._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState.state._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = walkState; + runToWalkTransition.destinationState = walkState.state; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.1; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.addTransition(runToWalkTransition); + runState.state.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (1 - runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + + (1 - runToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() + //@ts-ignore - runToWalkTransition.duration * walkState._getDuration(); + runToWalkTransition.duration * walkState.state._getDuration(); - stateMachine.addEntryStateTransition(idleState); + stateMachine.addEntryStateTransition(idleState.state); - const anyTransition = stateMachine.addAnyStateTransition(idleState); + const anyTransition = stateMachine.addAnyStateTransition(idleState.state); 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._getDuration() + + (1 - anyTransition.exitTime - toIdleTransition.duration) * walkState.state._getDuration() + // @ts-ignore - (anyTransition.duration * idleState._getDuration()) / idleSpeed; + (anyTransition.duration * idleState.state._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -614,10 +615,10 @@ describe("Animator test", function () { it("transitionOffset", () => { const walkState = animator.findAnimatorState("Walk"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clearTransitions(); - const toRunTransition = walkState.addTransition(runState); + runState.state.clearTransitions(); + const toRunTransition = walkState.state.addTransition(runState.state); toRunTransition.exitTime = 0; toRunTransition.duration = 1; toRunTransition.offset = 0.5; @@ -635,15 +636,15 @@ describe("Animator test", function () { it("clipStartTime crossFade", () => { const walkState = animator.findAnimatorState("Walk"); - walkState.wrapMode = WrapMode.Once; - walkState.clipStartTime = 0.8; - walkState.clearTransitions(); + walkState.state.wrapMode = WrapMode.Once; + walkState.state.clipStartTime = 0.8; + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clearTransitions(); - const toRunTransition = walkState.addTransition(runState); + runState.state.clearTransitions(); + const toRunTransition = walkState.state.addTransition(runState.state); toRunTransition.exitTime = 0.5; toRunTransition.duration = 1; - runState.clipStartTime = 0.5; + runState.state.clipStartTime = 0.5; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -657,9 +658,9 @@ describe("Animator test", function () { const animatorLayerData = animator["_animatorLayersData"]; const walkState = animator.findAnimatorState("Walk"); - walkState.wrapMode = WrapMode.Once; - walkState.clearTransitions(); - walkState.addExitTransition(); + walkState.state.wrapMode = WrapMode.Once; + walkState.state.clearTransitions(); + walkState.state.addExitTransition(); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -826,15 +827,15 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Run"); // For test clipStartTime is not 0 and transition duration is 0 - walkState.clipStartTime = 0.5; - walkState.addStateMachineScript( + walkState.state.clipStartTime = 0.5; + walkState.state.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator) { animator.setParameterValue("playRun", 0); } } ); - const transition = stateMachine.addAnyStateTransition(animator.findAnimatorState("Run")); + const transition = stateMachine.addAnyStateTransition(animator.findAnimatorState("Run").state); transition.addCondition("playRun", AnimatorConditionMode.Equals, 1); // For test clipStartTime is not 0 and transition duration is 0 transition.duration = 0; @@ -847,7 +848,7 @@ describe("Animator test", function () { expect(layerData.srcPlayData.state.name).to.eq("Run"); expect(layerData.srcPlayData.playedTime).to.eq(0.5); - expect(layerData.srcPlayData.clipTime).to.eq(walkState.clip.length * 0.5 + 0.5); + expect(layerData.srcPlayData.clipTime).to.eq(walkState.state.clip.length * 0.5 + 0.5); }); it("hasExitTime", () => { @@ -860,13 +861,13 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const idleState = animator.findAnimatorState("Survey"); idleState.speed = 1; - idleState.clearTransitions(); + idleState.state.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.clipStartTime = 0; - walkState.clearTransitions(); + walkState.state.clipStartTime = 0; + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clearTransitions(); - const walkToRunTransition = walkState.addTransition(runState); + runState.state.clearTransitions(); + const walkToRunTransition = walkState.state.addTransition(runState.state); walkToRunTransition.hasExitTime = true; walkToRunTransition.exitTime = 0.5; walkToRunTransition.duration = 0; @@ -874,10 +875,10 @@ describe("Animator test", function () { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.clip.length * 0.5); + animator.update(walkState.state.clip.length * 0.5); expect(layerData.destPlayData.state.name).to.eq("Run"); expect(layerData.destPlayData.playedTime).to.eq(0); - const anyToIdleTransition = stateMachine.addAnyStateTransition(idleState); + const anyToIdleTransition = stateMachine.addAnyStateTransition(idleState.state); anyToIdleTransition.hasExitTime = false; anyToIdleTransition.duration = 0.2; anyToIdleTransition.addCondition("triggerIdle", AnimatorConditionMode.If, true); @@ -889,9 +890,9 @@ describe("Animator test", function () { expect(layerData.srcPlayData.playedTime).to.eq(0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update(idleState.clip.length * 0.2 - 0.1); + animator.update(idleState.state.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); + expect(layerData.srcPlayData.clipTime).to.eq(idleState.state.clip.length * 0.2); }); it("setTriggerParameter", () => { @@ -904,16 +905,16 @@ describe("Animator test", function () { stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clipStartTime = 0; - runState.clearTransitions(); - const walkToRunTransition = walkState.addTransition(runState); + runState.state.clipStartTime = 0; + runState.state.clearTransitions(); + const walkToRunTransition = walkState.state.addTransition(runState.state); walkToRunTransition.hasExitTime = false; walkToRunTransition.duration = 0.1; walkToRunTransition.addCondition("triggerRun", AnimatorConditionMode.If, true); - const runToWalkTransition = runState.addTransition(walkState); + const runToWalkTransition = runState.state.addTransition(walkState.state); runToWalkTransition.hasExitTime = true; runToWalkTransition.exitTime = 0.7; runToWalkTransition.duration = 0.3; @@ -933,20 +934,20 @@ describe("Animator test", function () { expect(animator.getParameterValue("triggerWalk")).to.eq(true); // @ts-ignore animator.engine.time._frameCount++; - animator.update(runState.clip.length * 0.1 - 0.1); + animator.update(runState.state.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); + expect(layerData.srcPlayData.playedTime).to.eq(runState.state.clip.length * 0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update(runState.clip.length * 0.6); + animator.update(runState.state.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.clip.length * 0.3); + animator.update(walkState.state.clip.length * 0.3); expect(layerData.srcPlayData.state.name).to.eq("Walk"); - expect(layerData.srcPlayData.playedTime).to.eq(walkState.clip.length * 0.3); + expect(layerData.srcPlayData.playedTime).to.eq(walkState.state.clip.length * 0.3); }); it("fixedDuration", () => { @@ -956,11 +957,11 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); const walkState = animator.findAnimatorState("Walk"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.clipStartTime = runState.clipEndTime = 0; - runState.clearTransitions(); - const walkToRunTransition = walkState.addTransition(runState); + runState.state.clipStartTime = runState.state.clipEndTime = 0; + runState.state.clearTransitions(); + const walkToRunTransition = walkState.state.addTransition(runState.state); walkToRunTransition.hasExitTime = false; walkToRunTransition.isFixedDuration = true; walkToRunTransition.duration = 0.1; @@ -1095,7 +1096,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition(idleState); + const anyToIdle = stateMachine.addAnyStateTransition(idleState.state); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1133,12 +1134,12 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - walkState.clipStartTime = 0; - walkState.clipEndTime = 1; - walkState.clearTransitions(); + walkState.state.clipStartTime = 0; + walkState.state.clipEndTime = 1; + walkState.state.clearTransitions(); // A noExitTime transition that fails (ensures noExitTimeCount > 0). - const noExitFailTransition = walkState.addTransition(idleState); + const noExitFailTransition = walkState.state.addTransition(idleState.state); noExitFailTransition.hasExitTime = false; noExitFailTransition.duration = 0; noExitFailTransition.addCondition("never", AnimatorConditionMode.If, true); @@ -1147,26 +1148,26 @@ describe("Animator test", function () { const exitTimeTransition = new AnimatorStateTransition(); exitTimeTransition.exitTime = 0.5; exitTimeTransition.duration = 0; - exitTimeTransition.destinationState = runState; + exitTimeTransition.destinationState = runState.state; exitTimeTransition.addCondition("goRun", AnimatorConditionMode.If, true); - walkState.addTransition(exitTimeTransition); + walkState.state.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; + const preExitDeltaTime = walkState.state.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; + expect(layerData.destPlayData).to.be.null; // Update past exitTime, should transition to Run. // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.clip.length * 0.5); + animator.update(walkState.state.clip.length * 0.5); expect(animator.getCurrentAnimatorState(0).name).to.eq("Run"); }); @@ -1178,17 +1179,17 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition(idleState); + const anyToIdle = stateMachine.addAnyStateTransition(idleState.state); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); // Play Walk with Once mode, let it finish to reach Finished state - walkState.wrapMode = WrapMode.Once; + walkState.state.wrapMode = WrapMode.Once; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.clip.length + 0.1); + animator.update(walkState.state.clip.length + 0.1); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); @@ -1221,7 +1222,7 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); // AnyState -> Run (always true, noExitTime) - const anyToRun = stateMachine.addAnyStateTransition(runState); + const anyToRun = stateMachine.addAnyStateTransition(runState.state); anyToRun.hasExitTime = false; anyToRun.duration = 0.2; anyToRun.addCondition("alwaysTrue", AnimatorConditionMode.If, true); @@ -1257,7 +1258,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (always true, noExitTime) - const anyToIdle = stateMachine.addAnyStateTransition(idleState); + const anyToIdle = stateMachine.addAnyStateTransition(idleState.state); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1280,19 +1281,19 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - walkState.clearTransitions(); + walkState.state.clearTransitions(); // Add a noExitTime transition - const t1 = walkState.addTransition(runState); + const t1 = walkState.state.addTransition(runState.state); t1.hasExitTime = false; // Add a hasExitTime transition - const t2 = walkState.addTransition(idleState); + const t2 = walkState.state.addTransition(idleState.state); t2.hasExitTime = true; t2.exitTime = 0.5; // @ts-ignore - const collection = walkState._transitionCollection; + const collection = walkState.state._transitionCollection; expect(collection.noExitTimeCount).to.eq(1); expect(collection.count).to.eq(2); @@ -1311,4 +1312,100 @@ describe("Animator test", function () { expect(collection.get(0)).to.eq(t2); expect(collection.get(1)).to.eq(t1); }); + + it("findAnimatorState returns handle even when state has never played", () => { + const survey = animator.findAnimatorState("Survey"); + expect(survey).not.eq(null); + expect(survey.state.name).eq("Survey"); + expect(survey.speed).eq(survey.state.speed); // live-bound default + }); + + it("speed override set before play applies on first play", () => { + animator.findAnimatorState("Survey").speed = 0.5; + animator.play("Survey"); + // @ts-ignore + animator.engine.time._frameCount++; + animator.update(0.001); + // @ts-ignore — internal layer data + const layerData = animator._animatorLayersData[0]; + expect(layerData.srcPlayData.speed).eq(0.5); + expect(layerData.srcPlayData.state.name).eq("Survey"); + }); + + it("speed override 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.state.name).eq("Survey"); // ensure crossfade actually completed back to Survey + expect(animator.findAnimatorState("Survey").speed).eq(0.5); + expect(srcPlayData.speed).eq(0.5); + }); + + it("speed override is per-Animator (clone isolation)", () => { + const cloneEntity = animator.entity.clone(); + const cloneAnimator = cloneEntity.getComponent(Animator); + expect(cloneAnimator.animatorController).eq(animator.animatorController); + + animator.findAnimatorState("Survey").speed = 0.5; + + expect(animator.findAnimatorState("Survey").speed).eq(0.5); + expect(cloneAnimator.findAnimatorState("Survey").speed).eq(1); // shared default + // shared asset not mutated + const sharedSurvey = animator.animatorController.layers[0].stateMachine.findStateByName("Survey"); + expect(sharedSurvey.speed).eq(1); + }); + + it("crossFade phase uses playData.speed for time progression", () => { + // Set high override 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 playData.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 playData.speed=4 and dt=0.05, expect ~0.2 (4 * 0.05). With state.speed=1 it'd be ~0.05. + expect(advanced).to.be.greaterThan(0.1); + }); + + it("clearSpeedOverride resumes shared state.speed", () => { + const survey = animator.findAnimatorState("Survey"); + survey.speed = 0.5; + expect(survey.speed).eq(0.5); + + survey.state.speed = 3; + expect(survey.speed).eq(0.5); // override still wins + + survey.clearSpeedOverride(); + expect(survey.speed).eq(3); // now follows asset + + survey.state.speed = 1; // restore + }); }); From fa4ce1ba05c01c58db8463d9495c70e67707097d Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sat, 9 May 2026 22:23:56 +0800 Subject: [PATCH 07/92] test(animation): refine per-instance speed regression tests Address code quality review: - Test #1 now uses a cloned animator (no afterEach pre-population) so it actually verifies lazy PlayData creation; rename to match intent - Test #2 drops @ts-ignore on _animatorLayersData by reading the override through the same handle returned by findAnimatorState - Test #5 tightens >0.1 threshold to closeTo(0.2, 0.05) so a regression reducing the multiplier wouldn't slip past - Align .eq/.greaterThan calls with the file's .to.eq/.to.be convention --- tests/src/core/Animator.test.ts | 50 +++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 1e57fc9b2a..f25d47bb2b 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1313,23 +1313,31 @@ describe("Animator test", function () { expect(collection.get(1)).to.eq(t1); }); - it("findAnimatorState returns handle even when state has never played", () => { - const survey = animator.findAnimatorState("Survey"); - expect(survey).not.eq(null); - expect(survey.state.name).eq("Survey"); - expect(survey.speed).eq(survey.state.speed); // live-bound default + 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.state.name).to.eq("Survey"); + expect(survey.speed).to.eq(survey.state.speed); // live-bound default + + // Same handle returned on subsequent calls (verifies caching) + expect(cloneAnimator.findAnimatorState("Survey")).to.eq(survey); }); it("speed override set before play applies on first play", () => { - animator.findAnimatorState("Survey").speed = 0.5; + const handle = animator.findAnimatorState("Survey"); + handle.speed = 0.5; animator.play("Survey"); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.001); - // @ts-ignore — internal layer data - const layerData = animator._animatorLayersData[0]; - expect(layerData.srcPlayData.speed).eq(0.5); - expect(layerData.srcPlayData.state.name).eq("Survey"); + + // Same handle observed via getCurrentAnimatorState + expect(animator.getCurrentAnimatorState(0)).to.eq(handle.state); + expect(handle.speed).to.eq(0.5); }); it("speed override survives crossFade out and back", () => { @@ -1352,23 +1360,23 @@ describe("Animator test", function () { // @ts-ignore const srcPlayData = animator._animatorLayersData[0].srcPlayData; - expect(srcPlayData.state.name).eq("Survey"); // ensure crossfade actually completed back to Survey - expect(animator.findAnimatorState("Survey").speed).eq(0.5); - expect(srcPlayData.speed).eq(0.5); + expect(srcPlayData.state.name).to.eq("Survey"); // ensure crossfade actually completed back to Survey + expect(animator.findAnimatorState("Survey").speed).to.eq(0.5); + expect(srcPlayData.speed).to.eq(0.5); }); it("speed override is per-Animator (clone isolation)", () => { const cloneEntity = animator.entity.clone(); const cloneAnimator = cloneEntity.getComponent(Animator); - expect(cloneAnimator.animatorController).eq(animator.animatorController); + expect(cloneAnimator.animatorController).to.eq(animator.animatorController); animator.findAnimatorState("Survey").speed = 0.5; - expect(animator.findAnimatorState("Survey").speed).eq(0.5); - expect(cloneAnimator.findAnimatorState("Survey").speed).eq(1); // shared default + 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).eq(1); + expect(sharedSurvey.speed).to.eq(1); }); it("crossFade phase uses playData.speed for time progression", () => { @@ -1392,19 +1400,19 @@ describe("Animator test", function () { const srcPlayedAfter = layerData.srcPlayData.playedTime; const advanced = srcPlayedAfter - srcPlayedBefore; // With playData.speed=4 and dt=0.05, expect ~0.2 (4 * 0.05). With state.speed=1 it'd be ~0.05. - expect(advanced).to.be.greaterThan(0.1); + expect(advanced).to.be.closeTo(0.2, 0.05); }); it("clearSpeedOverride resumes shared state.speed", () => { const survey = animator.findAnimatorState("Survey"); survey.speed = 0.5; - expect(survey.speed).eq(0.5); + expect(survey.speed).to.eq(0.5); survey.state.speed = 3; - expect(survey.speed).eq(0.5); // override still wins + expect(survey.speed).to.eq(0.5); // override still wins survey.clearSpeedOverride(); - expect(survey.speed).eq(3); // now follows asset + expect(survey.speed).to.eq(3); // now follows asset survey.state.speed = 1; // restore }); From 8825d6b19c40db68de22532d76f9bb5a4045af3f Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sat, 9 May 2026 22:26:08 +0800 Subject: [PATCH 08/92] fix(loader): _findSceneRootBone walk-up termination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously walk-up went all the way to GLTF_ROOT (the wrapper, no parent), but sceneRootChildren contains GLTF_ROOT's direct children — never GLTF_ROOT itself. Result: function always returned null, making multi-root skin wrapper detection a no-op. Stop the walk as soon as the entity is a direct child of the scene root. The final check then succeeds for joints under any sceneNode, returning the wrapper sceneRoot as rootBone. Verified via standalone reproduction matching the test fixture. --- packages/loader/src/gltf/parser/GLTFSkinParser.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/loader/src/gltf/parser/GLTFSkinParser.ts b/packages/loader/src/gltf/parser/GLTFSkinParser.ts index 978dd4780e..a6e9299193 100644 --- a/packages/loader/src/gltf/parser/GLTFSkinParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSkinParser.ts @@ -39,7 +39,8 @@ export class GLTFSkinParser extends GLTFParser { const rootBone = entities[skeleton]; skin.rootBone = rootBone; } else { - const rootBone = this._findSceneRootBone(context, joints, entities) ?? this._findSkeletonRootBone(joints, entities); + const rootBone = + this._findSceneRootBone(context, joints, entities) ?? this._findSkeletonRootBone(joints, entities); if (rootBone) { skin.rootBone = rootBone; } else { @@ -78,7 +79,7 @@ export class GLTFSkinParser extends GLTFParser { for (let j = 0, m = joints.length; j < m; j++) { let entity = entities[joints[j]]; - while (entity?.parent) { + while (entity?.parent && !sceneRootChildren.has(entity)) { entity = entity.parent; } From f58640addcdd9523562db6442f91da2783a395a1 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sat, 9 May 2026 22:34:04 +0800 Subject: [PATCH 09/92] fix(entity): findByPath prefers same-name child over self-prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When entity X had a child also named X, findByPath("X") short-circuited to return self due to the GLTF self-name prefix branch — making the same-name child unreachable. Try direct child lookup first; fall back to the self-name prefix only when the child path doesn't match. Both the GLTF normalized-prefix case and the same-name child case work correctly. --- packages/core/src/Entity.ts | 15 ++++++++++----- tests/src/core/Entity.test.ts | 13 +++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index cf0eead61d..2e7b3eaa26 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -377,15 +377,20 @@ export class Entity extends EngineObject { return this; } - // Some imported animation clips are normalized to include the single scene root - // name (for example "mixamorig:Hips/..."), while the Animator may already sit on - // that root entity. Accept a self-name prefix so wrapped model roots and - // standalone single-root clips resolve through the same path convention. + // Prefer descending into a same-name child (normal path semantics). + const childMatch = Entity._findChildByName(this, 0, splits, 0); + if (childMatch) { + return childMatch; + } + + // Fallback: accept a self-name prefix. Some imported animation clips are normalized + // to include the single scene root name (e.g. "mixamorig:Hips/...") even when the + // Animator already sits on that root entity. if (splits[0] === this.name) { return splits.length === 1 ? this : Entity._findChildByName(this, 0, splits, 1); } - return Entity._findChildByName(this, 0, splits, 0); + return null; } /** diff --git a/tests/src/core/Entity.test.ts b/tests/src/core/Entity.test.ts index 2debbb4b86..2c9bd44149 100644 --- a/tests/src/core/Entity.test.ts +++ b/tests/src/core/Entity.test.ts @@ -333,6 +333,19 @@ describe("Entity", async () => { expect(parent.findByPath("parent/child/grandson")).eq(grandson); }); + it("findByPath prefers same-name child over self", () => { + const parent = new Entity(engine, "shared"); + parent.parent = scene.getRootEntity(); + const sameNameChild = new Entity(engine, "shared"); + sameNameChild.parent = parent; + const grandson = new Entity(engine, "leaf"); + grandson.parent = sameNameChild; + + // Should walk into the child named "shared", not return self + expect(parent.findByPath("shared")).to.eq(sameNameChild); + expect(parent.findByPath("shared/leaf")).to.eq(grandson); + }); + it("clearChildren", () => { const parent = new Entity(engine, "parent"); From f2cbd10ee27cefa8f30c90ee60fd8d2379e47f40 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sat, 9 May 2026 23:12:14 +0800 Subject: [PATCH 10/92] fix(e2e): update findAnimatorState callers for new return type PR #2984 changed Animator.findAnimatorState() to return AnimatorStatePlayData instead of AnimatorState. Unit tests were already updated to access shared-asset members via `.state.xxx`; e2e cases were missed and would TypeError at runtime when playwright loaded them. Convert each shared-asset access on findAnimatorState() results: - .clip -> .state.clip (animator-event, animator-additive) - .addTransition / .addExitTransition / ._getDuration -> .state.xxx (animator-stateMachine) - .addStateMachineScript -> .state.addStateMachineScript (animator-stateMachineScript) .speed reads/writes are intentionally preserved on the per-instance handle (the whole point of the API change). --- e2e/case/animator-additive.ts | 2 +- e2e/case/animator-event.ts | 2 +- e2e/case/animator-stateMachine.ts | 42 +++++++++++++------------ e2e/case/animator-stateMachineScript.ts | 2 +- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/e2e/case/animator-additive.ts b/e2e/case/animator-additive.ts index b30f2bf983..37d6e47e2c 100644 --- a/e2e/case/animator-additive.ts +++ b/e2e/case/animator-additive.ts @@ -56,7 +56,7 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const additivePoseNames = animations.filter((clip) => clip.name.includes("pose")).map((clip) => clip.name); additivePoseNames.forEach((name) => { - const clip = animator.findAnimatorState(name).clip; + const clip = animator.findAnimatorState(name).state.clip; const newState = animatorStateMachine.addState(name); newState.clipStartTime = 1; newState.clip = clip; diff --git a/e2e/case/animator-event.ts b/e2e/case/animator-event.ts index 430b260dc2..c1d21b757b 100644 --- a/e2e/case/animator-event.ts +++ b/e2e/case/animator-event.ts @@ -53,7 +53,7 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const animator = defaultSceneRoot.getComponent(Animator); const state = animator.findAnimatorState("walk"); - const clip = state.clip; + const clip = state.state.clip; const event0 = new AnimationEvent(); event0.functionName = "event0"; diff --git a/e2e/case/animator-stateMachine.ts b/e2e/case/animator-stateMachine.ts index 08fb9e6d39..b8f8bca0be 100644 --- a/e2e/case/animator-stateMachine.ts +++ b/e2e/case/animator-stateMachine.ts @@ -64,60 +64,62 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState; + toWalkTransition.destinationState = walkState.state; toWalkTransition.duration = 0.2; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.addTransition(toWalkTransition); + idleState.state.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - toWalkTransition.exitTime * idleState._getDuration() + toWalkTransition.duration * walkState._getDuration(); + toWalkTransition.exitTime * idleState.state._getDuration() + + //@ts-ignore + toWalkTransition.duration * walkState.state._getDuration(); - const exitTransition = idleState.addExitTransition(); + const exitTransition = idleState.state.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState; + toRunTransition.destinationState = runState.state; toRunTransition.duration = 0.3; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.addTransition(toRunTransition); + walkState.state.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * walkState.state._getDuration() + //@ts-ignore - toRunTransition.duration * runState._getDuration(); + toRunTransition.duration * runState.state._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState; + toIdleTransition.destinationState = idleState.state; toIdleTransition.duration = 0.3; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.addTransition(toIdleTransition); + walkState.state.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * walkState.state._getDuration() + //@ts-ignore - toIdleTransition.duration * idleState._getDuration(); + toIdleTransition.duration * idleState.state._getDuration(); // to run state const RunToWalkTransition = new AnimatorStateTransition(); - RunToWalkTransition.destinationState = walkState; + RunToWalkTransition.destinationState = walkState.state; RunToWalkTransition.duration = 0.3; RunToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.addTransition(RunToWalkTransition); + runState.state.addTransition(RunToWalkTransition); runToWalkTime = //@ts-ignore - (RunToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + + (RunToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() + //@ts-ignore - RunToWalkTransition.duration * walkState._getDuration(); + RunToWalkTransition.duration * walkState.state._getDuration(); - stateMachine.addEntryStateTransition(idleState); + stateMachine.addEntryStateTransition(idleState.state); - const anyTransition = stateMachine.addAnyStateTransition(idleState); + const anyTransition = stateMachine.addAnyStateTransition(idleState.state); anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); anyTransition.duration = 0.3; let anyToIdleTime = // @ts-ignore - (anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * walkState.state._getDuration() + // @ts-ignore - anyTransition.duration * idleState._getDuration(); + anyTransition.duration * idleState.state._getDuration(); engine.time.maximumDeltaTime = 10000; updateForE2E(engine, (idleToWalkTime + walkToRunTime) * 1000, 1); diff --git a/e2e/case/animator-stateMachineScript.ts b/e2e/case/animator-stateMachineScript.ts index 76e5247b3a..20b4194f45 100644 --- a/e2e/case/animator-stateMachineScript.ts +++ b/e2e/case/animator-stateMachineScript.ts @@ -57,7 +57,7 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const animator = defaultSceneRoot.getComponent(Animator); const state = animator.findAnimatorState("walk"); - state.addStateMachineScript( + state.state.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator: Animator, animatorState: AnimatorState, layerIndex: number): void { textRenderer.text = "0"; From f297fe8a236c733c3396128571a5b8979b5abbff Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sat, 9 May 2026 23:47:07 +0800 Subject: [PATCH 11/92] fix(animation): forbid crossFade to active state and reset orientation flag - _prepareCrossFadeByTransition guards against crossFade to current src or dest state, since statePlayDataMap holds a single PlayData per AnimatorState; without the guard, dest aliases to src, resetForPlay clobbers the active runtime, and _updateCrossFadeState updates the same object twice - AnimatorStatePlayData.resetForPlay also resets _changedOrientation so re-entering a state doesn't carry the previous track's orientation flag into the new playback window True self-crossfade support requires splitting persistent override fields from transient src/dest runtime tracks; out of scope for this PR. --- packages/core/src/animation/Animator.ts | 13 ++++++ .../src/animation/AnimatorStatePlayData.ts | 1 + tests/src/core/Animator.test.ts | 42 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 4104bf6d79..308170735c 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -1419,6 +1419,19 @@ export class Animator extends Component { } const animatorLayerData = this._getAnimatorLayerData(layerIndex); + + // Guard against crossFade-to-self/current-dest alias. + // statePlayDataMap holds one PlayData per AnimatorState; if the requested cross + // target is already on src or dest, getOrCreatePlayData would return the same + // instance and resetForPlay would corrupt the active runtime track. Treat as + // no-op until lifecycle splits persistent override from transient src/dest tracks. + if ( + animatorLayerData.srcPlayData?.state === crossState || + animatorLayerData.destPlayData?.state === crossState + ) { + return false; + } + const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex); const destPlayData = animatorLayerData.getOrCreatePlayData(crossState); diff --git a/packages/core/src/animation/AnimatorStatePlayData.ts b/packages/core/src/animation/AnimatorStatePlayData.ts index ba4f327b71..39ead9c9c3 100644 --- a/packages/core/src/animation/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/AnimatorStatePlayData.ts @@ -75,6 +75,7 @@ export class AnimatorStatePlayData { this.clipTime = this.state.clipStartTime * this.state.clip.length; this.currentEventIndex = 0; this.isForward = true; + this._changedOrientation = false; this.state._transitionCollection.needResetCurrentCheckIndex = true; } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index f25d47bb2b..bac7915f90 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1416,4 +1416,46 @@ describe("Animator test", function () { survey.state.speed = 1; // restore }); + + 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); + }); }); From cb5cfd54ec43c4f1bf81f24b7143b6a905b47d4b Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sat, 9 May 2026 23:47:53 +0800 Subject: [PATCH 12/92] fix(entity): tighten findByPath self-prefix fallback When the entity has a child with the same name as splits[0], findByPath must not fallback to the self-prefix interpretation: the user clearly intends to descend into the child, and a deeper-path miss should return null rather than silently re-resolve the path against the entity itself. --- packages/core/src/Entity.ts | 13 +++++++++---- tests/src/core/Entity.test.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 2e7b3eaa26..edbf10c042 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -383,11 +383,16 @@ export class Entity extends EngineObject { return childMatch; } - // Fallback: accept a self-name prefix. Some imported animation clips are normalized - // to include the single scene root name (e.g. "mixamorig:Hips/...") even when the - // Animator already sits on that root entity. + // Fallback to self-name prefix only when there's no child by splits[0]. + // Some imported animation clips are normalized to include the single scene + // root name (e.g. "mixamorig:Hips/...") even when the Animator already sits + // on that root entity. But if the entity has a child with that name and the + // deeper path simply misses, that's a real not-found, not a self-prefix. if (splits[0] === this.name) { - return splits.length === 1 ? this : Entity._findChildByName(this, 0, splits, 1); + const hasFirstSegmentChild = this._children.some((child) => child.name === splits[0]); + if (!hasFirstSegmentChild) { + return splits.length === 1 ? this : Entity._findChildByName(this, 0, splits, 1); + } } return null; diff --git a/tests/src/core/Entity.test.ts b/tests/src/core/Entity.test.ts index 2c9bd44149..b480a38dc6 100644 --- a/tests/src/core/Entity.test.ts +++ b/tests/src/core/Entity.test.ts @@ -346,6 +346,19 @@ describe("Entity", async () => { expect(parent.findByPath("shared/leaf")).to.eq(grandson); }); + it("findByPath does not fallback to self-prefix when same-name child exists but deeper path misses", () => { + const parent = new Entity(engine, "shared"); + parent.parent = scene.getRootEntity(); + const sameNameChild = new Entity(engine, "shared"); + sameNameChild.parent = parent; + const sibling = new Entity(engine, "other"); + sibling.parent = parent; + + // Same-name child exists but doesn't have an "other" descendant + // → must NOT fallback to self-prefix and return parent/other + expect(parent.findByPath("shared/other")).to.eq(null); + }); + it("clearChildren", () => { const parent = new Entity(engine, "parent"); From d53b7224d23bb21a1a884624277a82a4effe366b Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sat, 9 May 2026 23:52:16 +0800 Subject: [PATCH 13/92] fix(animation): prettier formatting for crossFade alias guard --- packages/core/src/animation/Animator.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 308170735c..b9bf37e400 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -1425,10 +1425,7 @@ export class Animator extends Component { // target is already on src or dest, getOrCreatePlayData would return the same // instance and resetForPlay would corrupt the active runtime track. Treat as // no-op until lifecycle splits persistent override from transient src/dest tracks. - if ( - animatorLayerData.srcPlayData?.state === crossState || - animatorLayerData.destPlayData?.state === crossState - ) { + if (animatorLayerData.srcPlayData?.state === crossState || animatorLayerData.destPlayData?.state === crossState) { return false; } From bcf961172830403ea1b79c43c3e7be9cc00af99b Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 00:19:53 +0800 Subject: [PATCH 14/92] docs(animation): update findAnimatorState examples for new API PR #2984 changed findAnimatorState to return AnimatorStatePlayData | null. Update both EN and ZH docs to reflect: - Per-instance speed override (playData.speed) - Shared asset access (playData.state.xxx) - Nullable return guard - clearSpeedOverride() to resume live binding to state.speed --- docs/en/animation/animator.mdx | 33 +++++++++++++++++++++++++-------- docs/zh/animation/animator.mdx | 33 +++++++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/en/animation/animator.mdx b/docs/en/animation/animator.mdx index d4cf4f3d8d..cbed0d394c 100644 --- a/docs/en/animation/animator.mdx +++ b/docs/en/animation/animator.mdx @@ -116,8 +116,16 @@ animator.speed = 1; If you only want to pause a specific `AnimatorState` , you can do so by setting its speed to 0. ```typescript -const state = animator.findAnimatorState("xxx"); -state.speed = 0; +const playData = animator.findAnimatorState("xxx"); +if (!playData) { + // State not found in any layer + return; +} + +// Per-instance playback speed override (only affects this Animator instance). +playData.speed = 0; +// Restore live binding to the shared state.speed. +playData.clearSpeedOverride(); ``` ### Transition to Specified Animation State @@ -142,14 +150,23 @@ currentState.wrapMode = WrapMode.Loop; ### Get Animation State -You can use the [findAnimatorState](/apis/core/#Animator-findAnimatorState) method to get the specified `AnimatorState` . After obtaining it, you can set the properties of the `AnimatorState` , such as changing the default loop playback to play once. +You can use the [findAnimatorState](/apis/core/#Animator-findAnimatorState) method to get the playback data for a specified `AnimatorState`. The return type is `AnimatorStatePlayData | null` (`null` when no layer contains a state with that name). + +The returned `AnimatorStatePlayData` exposes two distinct surfaces: + +- `playData.speed` — per-Animator playback speed override. Reading and writing it only affects this `Animator` instance and won't affect other `Animator` instances sharing the same `AnimatorController`. Call `clearSpeedOverride()` to resume live binding to `state.speed`. +- `playData.state` — the underlying shared `AnimatorState` asset. Mutating fields like `wrapMode` mutates the shared `AnimatorController` asset and therefore affects every `Animator` using this controller. ```typescript -const state = animator.findAnimatorState("xxx"); -// Play once -state.wrapMode = WrapMode.Once; -// Loop playback -state.wrapMode = WrapMode.Loop; +const playData = animator.findAnimatorState("xxx"); +if (!playData) { + // State not found in any layer + return; +} + +// Mutating the shared AnimatorState asset (affects all Animators using this controller). +playData.state.wrapMode = WrapMode.Once; +playData.state.wrapMode = WrapMode.Loop; ``` ### Animation Culling diff --git a/docs/zh/animation/animator.mdx b/docs/zh/animation/animator.mdx index 0f95eb4550..a85707f769 100644 --- a/docs/zh/animation/animator.mdx +++ b/docs/zh/animation/animator.mdx @@ -120,8 +120,16 @@ animator.speed = 1; 如果你只想针对某一个 `动画状态` 进行暂停,可以通过将它的速度设置为 0 来实现。 ```typescript -const state = animator.findAnimatorState("xxx"); -state.speed = 0; +const playData = animator.findAnimatorState("xxx"); +if (!playData) { + // 任何一个动画层都没有该状态 + return; +} + +// 每个 Animator 实例独立的播放速度覆盖(只影响当前 Animator 实例) +playData.speed = 0; +// 取消覆盖,恢复对 state.speed 的实时绑定 +playData.clearSpeedOverride(); ``` ### 过渡指定动画状态 @@ -147,14 +155,23 @@ currentState.wrapMode = WrapMode.Loop; ### 获取动画状态 -你可以使用 [findAnimatorState](/apis/core/#Animator-findAnimatorState)  方法来获取指定名称的 `动画状态` 。获取之后可以设置动画状态的属性,比如将默认的循环播放改为一次。 +你可以使用 [findAnimatorState](/apis/core/#Animator-findAnimatorState) 方法来获取指定名称 `动画状态` 的播放数据。返回类型为 `AnimatorStatePlayData | null`(当任何一个动画层都没有该状态时返回 `null`)。 + +返回的 `AnimatorStatePlayData` 提供两套语义不同的访问入口: + +- `playData.speed`:每个 `Animator` 实例独立的播放速度覆盖。读写只影响当前 `Animator` 实例,不会影响其他共享同一 `AnimatorController` 的 `Animator` 实例。调用 `clearSpeedOverride()` 可以恢复对 `state.speed` 的实时绑定。 +- `playData.state`:底层共享的 `AnimatorState` 资产。对 `wrapMode` 等字段的修改会改变共享的 `AnimatorController` 资产,因此会影响所有使用该控制器的 `Animator`。 ```typescript -const state = animator.findAnimatorState("xxx"); -// 播放一次 -state.wrapMode = WrapMode.Once; -// 循环播放 -state.wrapMode = WrapMode.Loop; +const playData = animator.findAnimatorState("xxx"); +if (!playData) { + // 任何一个动画层都没有该状态 + return; +} + +// 修改共享的 AnimatorState 资产(影响所有使用该控制器的 Animator) +playData.state.wrapMode = WrapMode.Once; +playData.state.wrapMode = WrapMode.Loop; ``` ### 动画裁剪 From 722cc2ba9356292781c13d041e489f94514608b1 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 00:21:32 +0800 Subject: [PATCH 15/92] fix(e2e): null-guard findAnimatorState callers findAnimatorState now returns AnimatorStatePlayData | null. e2e cases were dereferencing without a guard, which would surface as "Cannot read properties of null" if a state name doesn't match the asset. Add fail-fast guards naming the missing state for actionable errors. --- e2e/case/animator-additive.ts | 6 +++++- e2e/case/animator-event.ts | 3 +++ e2e/case/animator-play-backwards.ts | 6 +++++- e2e/case/animator-stateMachine.ts | 3 +++ e2e/case/animator-stateMachineScript.ts | 3 +++ 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/e2e/case/animator-additive.ts b/e2e/case/animator-additive.ts index 37d6e47e2c..b9c4becee6 100644 --- a/e2e/case/animator-additive.ts +++ b/e2e/case/animator-additive.ts @@ -56,7 +56,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const additivePoseNames = animations.filter((clip) => clip.name.includes("pose")).map((clip) => clip.name); additivePoseNames.forEach((name) => { - const clip = animator.findAnimatorState(name).state.clip; + const playData = animator.findAnimatorState(name); + if (!playData) { + throw new Error(`Animator state not found: ${name}`); + } + const clip = playData.state.clip; const newState = animatorStateMachine.addState(name); newState.clipStartTime = 1; newState.clip = clip; diff --git a/e2e/case/animator-event.ts b/e2e/case/animator-event.ts index c1d21b757b..193889eedf 100644 --- a/e2e/case/animator-event.ts +++ b/e2e/case/animator-event.ts @@ -53,6 +53,9 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const animator = defaultSceneRoot.getComponent(Animator); const state = animator.findAnimatorState("walk"); + if (!state) { + throw new Error("Animator state not found: walk"); + } const clip = state.state.clip; const event0 = new AnimationEvent(); diff --git a/e2e/case/animator-play-backwards.ts b/e2e/case/animator-play-backwards.ts index 526d21b9ca..936a370048 100644 --- a/e2e/case/animator-play-backwards.ts +++ b/e2e/case/animator-play-backwards.ts @@ -33,7 +33,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const { defaultSceneRoot } = gltfResource; rootEntity.addChild(defaultSceneRoot); const animator = defaultSceneRoot.getComponent(Animator); - animator.findAnimatorState("walk").speed = -1; + const walkState = animator.findAnimatorState("walk"); + if (!walkState) { + throw new Error("Animator state not found: walk"); + } + walkState.speed = -1; animator.play("walk"); updateForE2E(engine); diff --git a/e2e/case/animator-stateMachine.ts b/e2e/case/animator-stateMachine.ts index b8f8bca0be..6f83aa74c1 100644 --- a/e2e/case/animator-stateMachine.ts +++ b/e2e/case/animator-stateMachine.ts @@ -57,6 +57,9 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const idleState = animator.findAnimatorState("idle"); const walkState = animator.findAnimatorState("walk"); const runState = animator.findAnimatorState("run"); + if (!idleState || !walkState || !runState) { + throw new Error("Required animator states not found: idle/walk/run"); + } let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; diff --git a/e2e/case/animator-stateMachineScript.ts b/e2e/case/animator-stateMachineScript.ts index 20b4194f45..a6d4ebbb92 100644 --- a/e2e/case/animator-stateMachineScript.ts +++ b/e2e/case/animator-stateMachineScript.ts @@ -56,6 +56,9 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const animator = defaultSceneRoot.getComponent(Animator); const state = animator.findAnimatorState("walk"); + if (!state) { + throw new Error("Animator state not found: walk"); + } state.state.addStateMachineScript( class extends StateMachineScript { From a2d3c2e2b7f7bfc88e39c1c1380e40320b1f9dbd Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 00:24:47 +0800 Subject: [PATCH 16/92] fix(loader): _findSceneRootBone only returns wrapper when joints span multiple roots Previously: if all joints were under any sceneNodes' subtrees, _findSceneRootBone returned GLTF_ROOT, even when joints converged to a single top-level child. That over-promoted the rootBone to include unrelated sibling nodes (lights, cameras, props), affecting bounds. Now: track which top-level child each joint resolves to. Only return sceneRoot when joints span >1 different top-level children. Otherwise fall through to _findSkeletonRootBone for the LCA. --- .../loader/src/gltf/parser/GLTFSkinParser.ts | 5 +- tests/src/loader/GLTFLoader.test.ts | 76 ++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/loader/src/gltf/parser/GLTFSkinParser.ts b/packages/loader/src/gltf/parser/GLTFSkinParser.ts index a6e9299193..404b0e189d 100644 --- a/packages/loader/src/gltf/parser/GLTFSkinParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSkinParser.ts @@ -75,6 +75,7 @@ export class GLTFSkinParser extends GLTFParser { } const sceneRootChildren = new Set(sceneNodes.map((nodeIndex) => entities[nodeIndex])); + const topLevelJointRoots = new Set(); let allJointsUnderSceneRoot = true; for (let j = 0, m = joints.length; j < m; j++) { @@ -87,9 +88,11 @@ export class GLTFSkinParser extends GLTFParser { allJointsUnderSceneRoot = false; break; } + + topLevelJointRoots.add(entity); } - if (allJointsUnderSceneRoot) { + if (allJointsUnderSceneRoot && topLevelJointRoots.size > 1) { return sceneRoot; } } diff --git a/tests/src/loader/GLTFLoader.test.ts b/tests/src/loader/GLTFLoader.test.ts index 7e1bf162b7..6f3eaf0b17 100644 --- a/tests/src/loader/GLTFLoader.test.ts +++ b/tests/src/loader/GLTFLoader.test.ts @@ -40,7 +40,7 @@ beforeAll(async function () { class GLTFCustomJSONParser extends GLTFParser { parse(context: GLTFParserContext) { if (context.glTFResource.url.endsWith("testSkinRoot.gltf")) { - context.buffers = [new ArrayBuffer(128)]; + context.buffers = [new ArrayBuffer(192)]; return Promise.resolve({ asset: { version: "2.0" @@ -66,7 +66,64 @@ beforeAll(async function () { skins: [ { inverseBindMatrices: 0, - joints: [1, 2] + // Joints span both top-level scene roots: Character_Man (0) and Hips (1)/Spine (2). + joints: [0, 1, 2] + } + ], + accessors: [ + { + bufferView: 0, + byteOffset: 0, + componentType: 5126, + count: 3, + type: "MAT4" + } + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 192 + } + ], + buffers: [ + { + byteLength: 192 + } + ] + }); + } + + if (context.glTFResource.url.endsWith("testSingleSkeleton.gltf")) { + context.buffers = [new ArrayBuffer(128)]; + return Promise.resolve({ + asset: { + version: "2.0" + }, + scene: 0, + scenes: [ + { + // Two top-level roots: a character skeleton and an unrelated sibling (e.g., a light). + nodes: [0, 2] + } + ], + nodes: [ + { + name: "Character_Root", + children: [1] + }, + { + name: "mixamorig:Hips" + }, + { + name: "Light" + } + ], + skins: [ + { + inverseBindMatrices: 0, + // All joints converge to a single top-level root (Character_Root). + joints: [0, 1] } ], accessors: [ @@ -607,6 +664,21 @@ describe("glTF scene root structure", function () { expect(defaultSceneRoot.children.length).to.equal(2); expect(skins[0].rootBone).to.equal(defaultSceneRoot); }); + + it("Multi-root scenes whose joints converge to a single top-level root should not use the scene wrapper", async () => { + const glTFResource: GLTFResource = await engine.resourceManager.load({ + type: AssetType.GLTF, + url: "mock/path/testSingleSkeleton.gltf" + }); + const { defaultSceneRoot, skins } = glTFResource; + + expect(defaultSceneRoot.name).to.equal("GLTF_ROOT"); + // Scene has two top-level roots, but all joints converge to "Character_Root". + expect(defaultSceneRoot.children.length).to.equal(2); + expect(skins[0].rootBone).to.not.equal(defaultSceneRoot); + // rootBone should be inside the Character_Root subtree (LCA = Character_Root). + expect(skins[0].rootBone.name).to.equal("Character_Root"); + }); }); describe("glTF instance test", function () { From 7fab8938f2bec1610e1ab4dd00189820258704be Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 00:26:19 +0800 Subject: [PATCH 17/92] fix(animation): _preparePlay clears stale crossFade slot When play() interrupts a cross-fade, destPlayData and crossFadeTransition were left dangling. With persistent statePlayDataMap, this caused the self-crossFade alias guard to wrongly no-op subsequent crossFade calls to the previously-fading state. Clear destPlayData and crossFadeTransition on play() entry so the layer state matches reality. --- packages/core/src/animation/Animator.ts | 4 ++++ tests/src/core/Animator.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index b9bf37e400..2e99684ef4 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -1320,6 +1320,10 @@ export class Animator extends Component { const playData = animatorLayerData.getOrCreatePlayData(state); playData.resetForPlay(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); animatorLayerData.srcPlayData = playData; + // Clear any stale cross-fade slot from a previously-interrupted crossFade so + // subsequent crossFade() calls aren't no-op'd by the self-target alias guard. + animatorLayerData.destPlayData = null; + animatorLayerData.crossFadeTransition = null; animatorLayerData.resetCurrentCheckIndex(); return true; diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index bac7915f90..a67107b30b 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1458,4 +1458,29 @@ describe("Animator test", function () { expect(layerData.destPlayData).to.eq(destBefore); expect(layerData.destPlayData.playedTime).to.eq(destPlayedBefore); }); + + 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?.state.name).to.eq("Run"); + }); }); From 33a676b7fa0210e2b278cb89affef4fd68920a3c Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 00:26:57 +0800 Subject: [PATCH 18/92] fix(animation): crossFade early-return on missing state If the requested state name doesn't match any layer, _getAnimatorLayerData was being called with playLayerIndex = -1, which would write a junk AnimatorLayerData entry at array index -1 (JS array negative indexing creates a property). Guard the lookup at the entry point. --- packages/core/src/animation/Animator.ts | 3 +++ tests/src/core/Animator.test.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 2e99684ef4..37f0854833 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -369,6 +369,9 @@ export class Animator extends Component { } const { state, layerIndex: playLayerIndex } = this._getAnimatorStateInfo(stateName, layerIndex); + if (!state || playLayerIndex < 0) { + return; + } const { manuallyTransition } = this._getAnimatorLayerData(playLayerIndex); manuallyTransition.duration = duration; diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index a67107b30b..daf0793e05 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1483,4 +1483,19 @@ describe("Animator test", function () { animator.crossFade("Run", 0.3, 0, 0); expect(layerData.destPlayData?.state.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); + }); }); From fd1da52888ea22cfd4d36d3fefbabc50adfc5b58 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 00:27:23 +0800 Subject: [PATCH 19/92] docs(animation): mark playState as @internal Bring the JSDoc tag in line with the other engine-managed runtime fields on AnimatorStatePlayData (playedTime/clipTime/etc.) so docs/IDE filtering treats them uniformly. --- packages/core/src/animation/AnimatorStatePlayData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/animation/AnimatorStatePlayData.ts b/packages/core/src/animation/AnimatorStatePlayData.ts index 39ead9c9c3..ab15eb646e 100644 --- a/packages/core/src/animation/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/AnimatorStatePlayData.ts @@ -23,7 +23,7 @@ export class AnimatorStatePlayData { stateData: AnimatorStateData; /** @internal */ playedTime: number = 0; - /** Current playback state. Engine-managed. */ + /** @internal */ playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted; /** @internal */ clipTime: number = 0; From 862dddaad76d241592ba5c4d85569c0803aebc57 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 11:32:13 +0800 Subject: [PATCH 20/92] fix(entity): findByPath fallback no longer crashes on null parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-prefix fallback called _findChildByName with pathIndex=1, whose not-found backtrack path recursed into entity.parent — for detached or root entities, that's null and crashes on null._children. Use splits.slice(1) with pathIndex=0 so the recursion stays within the entity's subtree and returns null cleanly when the deeper path misses. Also retitle the fallback comment to a generic path-semantics description, since core/Entity should not carry GLTF-specific framing. --- packages/core/src/Entity.ts | 10 ++++------ tests/src/core/Entity.test.ts | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index edbf10c042..707d967c17 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -369,7 +369,7 @@ export class Entity extends EngineObject { /** * Find the entity by path. * @param path - The path of the entity eg: /entity - * @returns The component which be found + * @returns The entity that was found */ findByPath(path: string): Entity { const splits = path.split("/").filter(Boolean); @@ -384,14 +384,12 @@ export class Entity extends EngineObject { } // Fallback to self-name prefix only when there's no child by splits[0]. - // Some imported animation clips are normalized to include the single scene - // root name (e.g. "mixamorig:Hips/...") even when the Animator already sits - // on that root entity. But if the entity has a child with that name and the - // deeper path simply misses, that's a real not-found, not a self-prefix. + // Supports paths authored relative to this entity's parent but evaluated + // from this entity (e.g. "root/child/leaf" called on the entity named "root"). if (splits[0] === this.name) { const hasFirstSegmentChild = this._children.some((child) => child.name === splits[0]); if (!hasFirstSegmentChild) { - return splits.length === 1 ? this : Entity._findChildByName(this, 0, splits, 1); + return splits.length === 1 ? this : Entity._findChildByName(this, 0, splits.slice(1), 0); } } diff --git a/tests/src/core/Entity.test.ts b/tests/src/core/Entity.test.ts index b480a38dc6..e9bc43b5dc 100644 --- a/tests/src/core/Entity.test.ts +++ b/tests/src/core/Entity.test.ts @@ -359,6 +359,27 @@ describe("Entity", async () => { expect(parent.findByPath("shared/other")).to.eq(null); }); + it("findByPath returns null without crashing for missing child under self-prefix", () => { + // Detached root entity (no parent, no children) — exercises the + // backtrack path that previously crashed on null parent + const root = new Entity(engine, "root"); + expect(() => root.findByPath("root/missing")).not.to.throw(); + expect(root.findByPath("root/missing")).to.eq(null); + }); + + it("findByPath self-prefix fallback does not search beyond this entity's subtree", () => { + // root → [parent (no children), sibling] + const top = scene.getRootEntity(); + const target = new Entity(engine, "target"); + target.parent = top; + const sibling = new Entity(engine, "sibling"); + sibling.parent = top; + + // target.findByPath("target/sibling") with target name "target", no children: + // self-prefix fallback should NOT bubble up to top.children to find sibling + expect(target.findByPath("target/sibling")).to.eq(null); + }); + it("clearChildren", () => { const parent = new Entity(engine, "parent"); From 74622644128e07919255e95e9272e52782f38651 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 11:34:10 +0800 Subject: [PATCH 21/92] fix(animation): _getAnimatorStateInfo bounds-check layerIndex When called with an out-of-range layerIndex, _getAnimatorStateInfo accessed layers[idx].stateMachine and threw. This propagated to findAnimatorState (which is supposed to return null) and to play / crossFade entry points. Bound-check the index and return a stateInfo with layerIndex = -1 / state = null so all three callers see safe behavior. --- packages/core/src/animation/Animator.ts | 4 +++- tests/src/core/Animator.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 37f0854833..0a150fa64d 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -397,8 +397,10 @@ export class Animator extends Component { break; } } - } else { + } else if (layerIndex >= 0 && layerIndex < layers.length) { state = layers[layerIndex].stateMachine.findStateByName(stateName); + } else { + layerIndex = -1; } } stateInfo.layerIndex = layerIndex; diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index daf0793e05..6b5f5ce588 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1498,4 +1498,26 @@ describe("Animator test", function () { // @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); + expect(animator._animatorLayersData[99]).to.eq(undefined); + }); }); From 4edcd90a19fa6594f4e04c2b09ae6f8c707c1b89 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 11:34:34 +0800 Subject: [PATCH 22/92] fix(animation): guard remaining-time math against zero playSpeed When per-instance state speed is 0 (paused) and a transition fires, playCostTime / playSpeed produced NaN, which made remainDeltaTime > 0 evaluate false and the destination state silently dropped the remaining delta on that frame. Treat speed=0 as "no time consumed by this state" and pass deltaTime through to the destination instead. --- packages/core/src/animation/Animator.ts | 5 +++-- tests/src/core/Animator.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 0a150fa64d..1cb3bb135e 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -710,8 +710,9 @@ export class Animator extends Component { ); if (transition) { - // Remove speed factor, use actual cost time - const remainDeltaTime = deltaTime - playCostTime / playSpeed; + // 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. + const remainDeltaTime = playSpeed === 0 ? deltaTime : deltaTime - playCostTime / playSpeed; remainDeltaTime > 0 && this._updateState(layerData, remainDeltaTime, aniUpdate); } } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 6b5f5ce588..c23d7e043c 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1520,4 +1520,26 @@ describe("Animator test", function () { expect(animator._animatorLayersData[-1]).to.eq(undefined); expect(animator._animatorLayersData[99]).to.eq(undefined); }); + + it("transition out of a state with speed override 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); + }); }); From affa3ee2bcada79001a733af579a0c3094622a61 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 11:35:23 +0800 Subject: [PATCH 23/92] docs(loader): document Scene-before-Skin parse-order dependency GLTFSkinParser._findSceneRootBone reads glTFResource._sceneRoots which GLTFSceneParser populates synchronously. The current AssetPromise.all ordering preserves this; document the invariant so a future array reorder doesn't silently break skin root resolution. --- packages/loader/src/gltf/parser/GLTFParserContext.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/loader/src/gltf/parser/GLTFParserContext.ts b/packages/loader/src/gltf/parser/GLTFParserContext.ts index b549da5ca2..866a4b7552 100644 --- a/packages/loader/src/gltf/parser/GLTFParserContext.ts +++ b/packages/loader/src/gltf/parser/GLTFParserContext.ts @@ -116,6 +116,8 @@ export class GLTFParserContext { return AssetPromise.all([ this.get(GLTFParserType.Validator), + // Scene must be requested before Skin: GLTFSceneParser populates + // glTFResource._sceneRoots synchronously, which GLTFSkinParser._findSceneRootBone reads. this.get(GLTFParserType.Scene), this.get(GLTFParserType.Texture), this.get(GLTFParserType.Material), From b1e67f26939c7e83c6c710f5d5103cf9bb114919 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 11:35:47 +0800 Subject: [PATCH 24/92] style(e2e): camelCase local transition variables in animator-stateMachine Bring local AnimatorStateTransition declarations into line with the project's camelCase convention. --- e2e/case/animator-stateMachine.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/e2e/case/animator-stateMachine.ts b/e2e/case/animator-stateMachine.ts index 6f83aa74c1..34f1b7a899 100644 --- a/e2e/case/animator-stateMachine.ts +++ b/e2e/case/animator-stateMachine.ts @@ -102,16 +102,16 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { toIdleTransition.duration * idleState.state._getDuration(); // to run state - const RunToWalkTransition = new AnimatorStateTransition(); - RunToWalkTransition.destinationState = walkState.state; - RunToWalkTransition.duration = 0.3; - RunToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.state.addTransition(RunToWalkTransition); + const runToWalkTransition = new AnimatorStateTransition(); + runToWalkTransition.destinationState = walkState.state; + runToWalkTransition.duration = 0.3; + runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); + runState.state.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (RunToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() + //@ts-ignore - RunToWalkTransition.duration * walkState.state._getDuration(); + runToWalkTransition.duration * walkState.state._getDuration(); stateMachine.addEntryStateTransition(idleState.state); From 4ecfa0f37f6dfde018a5967b982ca0d7b41b5d4e Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 11:38:51 +0800 Subject: [PATCH 25/92] test(animation): add missing @ts-ignore on _animatorLayersData[99] access --- tests/src/core/Animator.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index c23d7e043c..2f27859a93 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1518,6 +1518,7 @@ describe("Animator test", function () { 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); }); From aa5132c81e920bc73bf23d6636a60a86fe48e8e5 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 12:27:00 +0800 Subject: [PATCH 26/92] refactor(animation): align statePlayDataMap with Record + Object.create(null) idiom AnimatorLayerData already used Record-style maps for animatorStateDataMap and curveOwnerPool; statePlayDataMap was the only Map in the animation module. Layer-internal stateName is canonical (AnimatorStateMachine deduplicates by name). Switch to the project's standard pattern for intra-class consistency and v8 hidden-class friendliness on small caches. Also normalize animatorStateDataMap initialization to Object.create(null) for the same null-prototype safety as curveOwnerPool. --- .../core/src/animation/internal/AnimatorLayerData.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index 5e4e56d87d..21f8ca2901 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -13,9 +13,9 @@ export class AnimatorLayerData { layerIndex: number; layer: AnimatorControllerLayer; curveOwnerPool: Record> = Object.create(null); - animatorStateDataMap: Record = {}; + animatorStateDataMap: Record = Object.create(null); /** Per-state PlayData handles. Lazy populated. */ - statePlayDataMap = new Map(); + statePlayDataMap: Record = Object.create(null); /** Currently playing state's PlayData; null when standby. */ srcPlayData: AnimatorStatePlayData | null = null; /** Cross-fade target state's PlayData; null when not cross-fading. */ @@ -28,10 +28,12 @@ export class AnimatorLayerData { /** Get or lazily create the persistent PlayData for a state. */ getOrCreatePlayData(state: AnimatorState): AnimatorStatePlayData { - let playData = this.statePlayDataMap.get(state); + const statePlayDataMap = this.statePlayDataMap; + const stateName = state.name; + let playData = statePlayDataMap[stateName]; if (!playData) { playData = new AnimatorStatePlayData(state); - this.statePlayDataMap.set(state, playData); + statePlayDataMap[stateName] = playData; } return playData; } From 34efeaa7e30c86d1ebb9b7f98ab138aaf9e28200 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 12:27:25 +0800 Subject: [PATCH 27/92] docs(animation): clarify pause vs resume in per-instance speed example The example showed `playData.speed = 0` immediately followed by `playData.clearSpeedOverride()`, which silently cancels the override. Comment out the resume call and label it as a later-stage operation so copy-pasting actually pauses the state. --- docs/en/animation/animator.mdx | 7 ++++--- docs/zh/animation/animator.mdx | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/en/animation/animator.mdx b/docs/en/animation/animator.mdx index cbed0d394c..773e858b20 100644 --- a/docs/en/animation/animator.mdx +++ b/docs/en/animation/animator.mdx @@ -122,10 +122,11 @@ if (!playData) { return; } -// Per-instance playback speed override (only affects this Animator instance). +// Pause only this Animator instance's playback of the state. playData.speed = 0; -// Restore live binding to the shared state.speed. -playData.clearSpeedOverride(); + +// ...later, when you want to resume tracking the shared state.speed: +// playData.clearSpeedOverride(); ``` ### Transition to Specified Animation State diff --git a/docs/zh/animation/animator.mdx b/docs/zh/animation/animator.mdx index a85707f769..84bbd8706b 100644 --- a/docs/zh/animation/animator.mdx +++ b/docs/zh/animation/animator.mdx @@ -126,10 +126,11 @@ if (!playData) { return; } -// 每个 Animator 实例独立的播放速度覆盖(只影响当前 Animator 实例) +// 仅暂停当前 Animator 实例的该状态播放。 playData.speed = 0; -// 取消覆盖,恢复对 state.speed 的实时绑定 -playData.clearSpeedOverride(); + +// ……稍后想恢复对共享 state.speed 的实时绑定时调用: +// playData.clearSpeedOverride(); ``` ### 过渡指定动画状态 From 55a96bf0767a7429d637875beabf536d90af297f Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 12:27:49 +0800 Subject: [PATCH 28/92] docs(animation): mark self-crossFade no-op as intentional policy The previous comment phrased the guard as a temporary workaround. The behavior is in fact deliberate: per-state persistent PlayData makes self-cross-fade structurally inexpressible without a separate transient track. Phrase the comment so future readers understand it as policy. --- packages/core/src/animation/Animator.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 1cb3bb135e..bd4b120650 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -1430,11 +1430,11 @@ export class Animator extends Component { const animatorLayerData = this._getAnimatorLayerData(layerIndex); - // Guard against crossFade-to-self/current-dest alias. - // statePlayDataMap holds one PlayData per AnimatorState; if the requested cross - // target is already on src or dest, getOrCreatePlayData would return the same - // instance and resetForPlay would corrupt the active runtime track. Treat as - // no-op until lifecycle splits persistent override from transient src/dest tracks. + // Self-cross-fade is intentionally a no-op: this layer holds one persistent + // PlayData handle per AnimatorState (so per-instance overrides like speed + // survive transitions). Supporting cross-fade-to-self would require a + // separate transient playback track per active fade, which is a larger + // runtime redesign deliberately deferred. if (animatorLayerData.srcPlayData?.state === crossState || animatorLayerData.destPlayData?.state === crossState) { return false; } From 86a0bf136ce81245f353a2194c421ddc514eb37e Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 12:28:29 +0800 Subject: [PATCH 29/92] perf(loader): _findSceneRootBone avoids Set allocations Replace per-scene Set creation with parent-walk identity checks. Tracks first-encountered top-level joint root and compares subsequent joints by reference, returning sceneRoot the moment a divergent root is found. --- .../loader/src/gltf/parser/GLTFSkinParser.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/loader/src/gltf/parser/GLTFSkinParser.ts b/packages/loader/src/gltf/parser/GLTFSkinParser.ts index 404b0e189d..d8a4d3727a 100644 --- a/packages/loader/src/gltf/parser/GLTFSkinParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSkinParser.ts @@ -74,27 +74,34 @@ export class GLTFSkinParser extends GLTFParser { continue; } - const sceneRootChildren = new Set(sceneNodes.map((nodeIndex) => entities[nodeIndex])); - const topLevelJointRoots = new Set(); - let allJointsUnderSceneRoot = true; + let firstTopLevelRoot: Entity = null; + let allUnderSceneRoot = true; for (let j = 0, m = joints.length; j < m; j++) { let entity = entities[joints[j]]; - while (entity?.parent && !sceneRootChildren.has(entity)) { + + // Walk up to the direct child of sceneRoot + while (entity?.parent && entity.parent !== sceneRoot) { entity = entity.parent; } - if (!sceneRootChildren.has(entity)) { - allJointsUnderSceneRoot = false; + if (entity?.parent !== sceneRoot) { + allUnderSceneRoot = false; break; } - topLevelJointRoots.add(entity); + if (firstTopLevelRoot === null) { + firstTopLevelRoot = entity; + } else if (entity !== firstTopLevelRoot) { + // joints span >1 top-level roots → wrapper is the right rootBone + return sceneRoot; + } } - if (allJointsUnderSceneRoot && topLevelJointRoots.size > 1) { - return sceneRoot; + if (!allUnderSceneRoot) { + continue; } + // joints converged to a single top-level root → fall through to skeleton LCA } return null; From b6205bdd657f4d791ff356b8cc411b1485c2b180 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 12:30:00 +0800 Subject: [PATCH 30/92] refactor(entity): _findChildByPathDown helper for self-prefix fallback Replace splits.slice(1) + _findChildByName(pathIndex=1) with a dedicated subtree-only path-search helper. Two improvements: no array allocation on every fallback, and the "fallback never backtracks to siblings" semantic is now expressed in the helper's contract instead of relying on the caller to neutralize backtracking via slicing. --- packages/core/src/Entity.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 707d967c17..41e2d8f649 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -43,6 +43,25 @@ export class Entity extends EngineObject { : Entity._findChildByName(entity.parent, entity.siblingIndex + 1, paths, pathIndex - 1); } + /** + * @internal + * Subtree-only path search: never backtracks to parent/siblings, returns null on miss. + */ + static _findChildByPathDown(entity: Entity, paths: string[], pathIndex: number): Entity { + const searchPath = paths[pathIndex]; + const isEndPath = pathIndex === paths.length - 1; + const children = entity._children; + + for (let i = 0, n = children.length; i < n; i++) { + const child = children[i]; + if (child.name === searchPath) { + return isEndPath ? child : Entity._findChildByPathDown(child, paths, pathIndex + 1); + } + } + + return null; + } + /** * @internal */ @@ -389,7 +408,7 @@ export class Entity extends EngineObject { if (splits[0] === this.name) { const hasFirstSegmentChild = this._children.some((child) => child.name === splits[0]); if (!hasFirstSegmentChild) { - return splits.length === 1 ? this : Entity._findChildByName(this, 0, splits.slice(1), 0); + return splits.length === 1 ? this : Entity._findChildByPathDown(this, splits, 1); } } From e230a5182ed5effcbc78d7b23a189e4536d6d53f Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 13:21:03 +0800 Subject: [PATCH 31/92] test(animation): state-machine self-transition is no-op Round-5 marked the alias guard in _prepareCrossFadeByTransition as intentional policy. Existing tests only covered manual crossFade() self-target no-op; this test locks in the same behavior when the transition is fired by the state machine via condition. Together they prevent silent regression of either path. --- tests/src/core/Animator.test.ts | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 2f27859a93..a759059082 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1459,6 +1459,40 @@ describe("Animator test", function () { 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.state.clearTransitions(); + animator.animatorController.addParameter("restart", false); + + const selfTransition = walk.state.addTransition(walk.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.state.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 From 36e2f40e31426dcbdd67ea1719207022594d91ba Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 13:21:39 +0800 Subject: [PATCH 32/92] test(animation): cover _updatePlayingState playSpeed===0 guard The earlier transition-out-of-speed-0 test went through _updateCrossFadeState (manual crossFade). The 0/0 NaN guard actually lives in _updatePlayingState's state-machine transition branch. Add a state-machine no-exit transition test that fires from a paused (speed override = 0) source state, ensuring dest receives the preserved remaining deltaTime and no NaN propagates. --- tests/src/core/Animator.test.ts | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index a759059082..7e1f465f17 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1577,4 +1577,38 @@ describe("Animator test", function () { // 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.state.clearTransitions(); + walk.state.clearTransitions(); + animator.animatorController.addParameter("goWalk", false); + + survey.speed = 0; // pause source per-instance + + const transition = survey.state.addTransition(walk.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?.state.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); + }); }); From 4ed7145a62e06426ae55b49aac8ebccbf44bf0b2 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 13:27:17 +0800 Subject: [PATCH 33/92] refactor(animation): underscore-prefix internal AnimatorStatePlayData fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public class previously exposed engine-managed runtime fields (stateData/playedTime/playState/clipTime/currentEventIndex/isForward/ offsetFrameTime) as plain public properties, relying on @internal JSDoc for visibility hiding. With underscore prefix the API surface is unambiguous: state (readonly), speed (getter/setter), and clearSpeedOverride() — anything else is implementation detail. User code that mutated runtime fields was already breaking Animator invariants; underscore-renaming makes the boundary explicit. --- packages/core/src/animation/Animator.ts | 74 +++++++++---------- .../src/animation/AnimatorStatePlayData.ts | 62 ++++++++-------- tests/src/core/Animator.test.ts | 72 +++++++++--------- 3 files changed, 106 insertions(+), 102 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index bd4b120650..3ce616cb3e 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -554,7 +554,7 @@ export class Animator extends Component { } private _prepareSrcCrossData(animatorLayerData: AnimatorLayerData, saveFixed: boolean): void { - const { curveLayerOwner } = animatorLayerData.srcPlayData.stateData; + const { curveLayerOwner } = animatorLayerData.srcPlayData._stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; if (!layerOwner) continue; @@ -565,7 +565,7 @@ export class Animator extends Component { } private _prepareDestCrossData(animatorLayerData: AnimatorLayerData, saveFixed: boolean): void { - const { curveLayerOwner } = animatorLayerData.destPlayData.stateData; + const { curveLayerOwner } = animatorLayerData.destPlayData._stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; if (!layerOwner) continue; @@ -632,12 +632,12 @@ export class Animator extends Component { srcPlayData.updateOrientation(playDeltaTime); - const { clipTime: lastClipTime, playState: lastPlayState } = srcPlayData; + const { _clipTime: lastClipTime, _playState: lastPlayState } = srcPlayData; // Precalculate to get the transition srcPlayData.update(playDeltaTime); - const { clipTime, isForward } = srcPlayData; + const { _clipTime: clipTime, _isForward: isForward } = srcPlayData; const { _transitionCollection: transitions } = state; const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine; @@ -694,7 +694,7 @@ export class Animator extends Component { srcPlayData.update(playCostTime - playDeltaTime); } else { playCostTime = playDeltaTime; - if (srcPlayData.playState === AnimatorStatePlayState.Finished) { + if (srcPlayData._playState === AnimatorStatePlayState.Finished) { layerData.layerState = LayerState.Finished; } } @@ -724,10 +724,10 @@ export class Animator extends Component { aniUpdate: boolean ): void { const curveBindings = playData.state.clip._curveBindings; - const finished = playData.playState === AnimatorStatePlayState.Finished; + const finished = playData._playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { - const curveLayerOwner = playData.stateData.curveLayerOwner; + const curveLayerOwner = playData._stateData.curveLayerOwner; for (let i = curveBindings.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; const owner = layerOwner?.curveOwner; @@ -740,7 +740,7 @@ export class Animator extends Component { if (curve.keys.length) { this._checkRevertOwner(owner, additive); - const value = owner.evaluateValue(curve, playData.clipTime, additive); + const value = owner.evaluateValue(curve, playData._clipTime, additive); aniUpdate && owner.applyValue(value, weight, additive); finished && layerOwner.saveFinalValue(); } @@ -772,18 +772,18 @@ export class Animator extends Component { srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); destPlayData.updateOrientation(dstPlayDeltaTime); - const { clipTime: lastSrcClipTime, playState: lastSrcPlayState } = srcPlayData; - const { clipTime: lastDestClipTime, playState: lastDstPlayState } = destPlayData; + const { _clipTime: lastSrcClipTime, _playState: lastSrcPlayState } = srcPlayData; + const { _clipTime: lastDestClipTime, _playState: lastDstPlayState } = destPlayData; let dstPlayCostTime: number; - if (destPlayData.isForward) { + if (destPlayData._isForward) { // The time that has been played - const playedTime = destPlayData.playedTime; + const playedTime = destPlayData._playedTime; dstPlayCostTime = playedTime + dstPlayDeltaTime > transitionDuration ? transitionDuration - playedTime : dstPlayDeltaTime; } else { // The time that has been played - const playedTime = destPlayData.playedTime; + const playedTime = destPlayData._playedTime; dstPlayCostTime = // -dstPlayDeltaTime: The time that will be played, negative are meant to make it be a periods // > transition: The time that will be played is enough to finish the transition @@ -800,13 +800,13 @@ export class Animator extends Component { srcPlayData.update(srcPlayCostTime); destPlayData.update(dstPlayCostTime); - let crossWeight = Math.abs(destPlayData.playedTime) / transitionDuration; + let crossWeight = Math.abs(destPlayData._playedTime) / transitionDuration; (crossWeight >= 1.0 - MathUtil.zeroTolerance || transitionDuration === 0) && (crossWeight = 1.0); const crossFadeFinished = crossWeight === 1.0; if (crossFadeFinished) { - srcPlayData.playState = AnimatorStatePlayState.Finished; + srcPlayData._playState = AnimatorStatePlayState.Finished; this._preparePlayOwner(layerData, destState); this._evaluatePlayingState(destPlayData, weight, additive, aniUpdate); } else { @@ -852,7 +852,7 @@ export class Animator extends Component { const { state: destState } = destPlayData; const { _curveBindings: destCurves } = destState.clip; - const finished = destPlayData.playState === AnimatorStatePlayState.Finished; + const finished = destPlayData._playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { for (let i = crossLayerOwnerCollection.length - 1; i >= 0; i--) { @@ -869,8 +869,8 @@ export class Animator extends Component { const value = owner.evaluateCrossFadeValue( srcCurveIndex >= 0 ? srcCurves[srcCurveIndex].curve : null, destCurveIndex >= 0 ? destCurves[destCurveIndex].curve : null, - srcPlayData.clipTime, - destPlayData.clipTime, + srcPlayData._clipTime, + destPlayData._clipTime, crossWeight, additive ); @@ -900,17 +900,17 @@ export class Animator extends Component { destPlayData.updateOrientation(playDeltaTime); - const { clipTime: lastDestClipTime, playState: lastPlayState } = destPlayData; + const { _clipTime: lastDestClipTime, _playState: lastPlayState } = destPlayData; let dstPlayCostTime: number; - if (destPlayData.isForward) { + if (destPlayData._isForward) { // The time that has been played - const playedTime = destPlayData.playedTime; + const playedTime = destPlayData._playedTime; dstPlayCostTime = playedTime + playDeltaTime > transitionDuration ? transitionDuration - playedTime : playDeltaTime; } else { // The time that has been played - const playedTime = destPlayData.playedTime; + const playedTime = destPlayData._playedTime; dstPlayCostTime = // -playDeltaTime: The time that will be played, negative are meant to make it be a periods // > transition: The time that will be played is enough to finish the transition @@ -925,7 +925,7 @@ export class Animator extends Component { destPlayData.update(dstPlayCostTime); - let crossWeight = Math.abs(destPlayData.playedTime) / transitionDuration; + let crossWeight = Math.abs(destPlayData._playedTime) / transitionDuration; (crossWeight >= 1.0 - MathUtil.zeroTolerance || transitionDuration === 0) && (crossWeight = 1.0); const crossFadeFinished = crossWeight === 1.0; @@ -965,7 +965,7 @@ export class Animator extends Component { const { state } = destPlayData; const { _curveBindings: curveBindings } = state.clip; - const { clipTime: destClipTime, playState } = destPlayData; + const { _clipTime: destClipTime, _playState: playState } = destPlayData; const finished = playState === AnimatorStatePlayState.Finished; // When the animator is culled (aniUpdate=false), if the play state has finished, the final value needs to be calculated and saved to be applied directly @@ -1006,7 +1006,7 @@ export class Animator extends Component { playData.updateOrientation(actualDeltaTime); - const { clipTime, isForward } = playData; + const { _clipTime: clipTime, _isForward: isForward } = playData; const { _transitionCollection: transitions } = state; const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine; @@ -1041,7 +1041,7 @@ export class Animator extends Component { return; } - const { curveLayerOwner } = playData.stateData; + const { curveLayerOwner } = playData._stateData; const { _curveBindings: curveBindings } = playData.state.clip; for (let i = curveBindings.length - 1; i >= 0; i--) { @@ -1058,7 +1058,7 @@ export class Animator extends Component { private _updateCrossFadeData(layerData: AnimatorLayerData): void { const { destPlayData } = layerData; - if (destPlayData.playState === AnimatorStatePlayState.Finished) { + if (destPlayData._playState === AnimatorStatePlayState.Finished) { layerData.layerState = LayerState.Finished; } else { layerData.layerState = LayerState.Playing; @@ -1071,7 +1071,7 @@ export class Animator extends Component { if (layerData.layerState === LayerState.Playing) { const srcPlayData = layerData.srcPlayData; if (srcPlayData.state !== playState) { - const { curveLayerOwner } = srcPlayData.stateData; + const { curveLayerOwner } = srcPlayData._stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { curveLayerOwner[i]?.curveOwner.revertDefaultValue(); } @@ -1478,14 +1478,14 @@ export class Animator extends Component { lastClipTime: number, deltaTime: number ): void { - const { state, isForward, clipTime } = playData; + const { state, _isForward: isForward, _clipTime: clipTime } = playData; const startTime = state._getClipActualStartTime(); const endTime = state._getClipActualEndTime(); if (isForward) { if (lastClipTime + deltaTime >= endTime) { this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, endTime); - playData.currentEventIndex = 0; + playData._currentEventIndex = 0; this._fireSubAnimationEvents(playData, eventHandlers, startTime, clipTime); } else { this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); @@ -1493,7 +1493,7 @@ export class Animator extends Component { } else { if (lastClipTime + deltaTime <= startTime) { this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, startTime); - playData.currentEventIndex = eventHandlers.length - 1; + playData._currentEventIndex = eventHandlers.length - 1; this._fireBackwardSubAnimationEvents(playData, eventHandlers, endTime, clipTime); } else { this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); @@ -1507,7 +1507,7 @@ export class Animator extends Component { lastClipTime: number, curClipTime: number ): void { - let eventIndex = playState.currentEventIndex; + let eventIndex = playState._currentEventIndex; for (let n = eventHandlers.length; eventIndex < n; eventIndex++) { const eventHandler = eventHandlers[eventIndex]; const { time, parameter } = eventHandler.event; @@ -1521,7 +1521,7 @@ export class Animator extends Component { for (let j = handlers.length - 1; j >= 0; j--) { handlers[j](parameter); } - playState.currentEventIndex = Math.min(eventIndex + 1, n - 1); + playState._currentEventIndex = Math.min(eventIndex + 1, n - 1); } } } @@ -1532,7 +1532,7 @@ export class Animator extends Component { lastClipTime: number, curClipTime: number ): void { - let eventIndex = playState.currentEventIndex; + let eventIndex = playState._currentEventIndex; for (; eventIndex >= 0; eventIndex--) { const eventHandler = eventHandlers[eventIndex]; const { time, parameter } = eventHandler.event; @@ -1546,7 +1546,7 @@ export class Animator extends Component { for (let j = handlers.length - 1; j >= 0; j--) { handlers[j](parameter); } - playState.currentEventIndex = Math.max(eventIndex - 1, 0); + playState._currentEventIndex = Math.max(eventIndex - 1, 0); } } } @@ -1590,13 +1590,13 @@ export class Animator extends Component { lastPlayState: AnimatorStatePlayState, deltaTime: number ) { - const { eventHandlers } = playData.stateData; + const { eventHandlers } = playData._stateData; eventHandlers.length && this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime); if (lastPlayState === AnimatorStatePlayState.UnStarted) { state._callOnEnter(this, layerIndex); } - if (lastPlayState !== AnimatorStatePlayState.Finished && playData.playState === AnimatorStatePlayState.Finished) { + if (lastPlayState !== AnimatorStatePlayState.Finished && playData._playState === AnimatorStatePlayState.Finished) { state._callOnExit(this, layerIndex); } else { state._callOnUpdate(this, layerIndex); diff --git a/packages/core/src/animation/AnimatorStatePlayData.ts b/packages/core/src/animation/AnimatorStatePlayData.ts index ab15eb646e..777cef03e9 100644 --- a/packages/core/src/animation/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/AnimatorStatePlayData.ts @@ -11,28 +11,32 @@ import { AnimatorStateData } from "./internal/AnimatorStateData"; * for the layer's lifetime, so per-instance overrides (e.g. speed) survive transitions * out of and back into the state. * - * Use `playData.state.xxx` to access shared AnimatorState configuration (clip, transitions, etc.). - * Use `playData.speed` for per-instance speed override (live-bound to state.speed when not overridden). - * Engine-managed runtime fields (playState, clipTime, ...) are read-only by user convention. + * Public surface is intentionally narrow: + * - `state`: the shared AnimatorState asset (read-only). + * - `speed` / `clearSpeedOverride()`: per-instance speed override. + * + * All other fields are engine-managed runtime state and are underscore-prefixed to + * mark them as implementation detail; mutating them from user code will corrupt + * Animator invariants. */ export class AnimatorStatePlayData { /** The shared AnimatorState asset. Read-only reference. */ readonly state: AnimatorState; /** @internal */ - stateData: AnimatorStateData; + _stateData: AnimatorStateData; /** @internal */ - playedTime: number = 0; + _playedTime: number = 0; /** @internal */ - playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted; + _playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted; /** @internal */ - clipTime: number = 0; + _clipTime: number = 0; /** @internal */ - currentEventIndex: number = 0; + _currentEventIndex: number = 0; /** @internal */ - isForward = true; + _isForward = true; /** @internal */ - offsetFrameTime: number = 0; + _offsetFrameTime: number = 0; private _speedOverride: number | undefined; private _changedOrientation = false; @@ -68,13 +72,13 @@ export class AnimatorStatePlayData { * Reset runtime fields when (re-)entering this state. Does NOT touch user overrides. */ resetForPlay(stateData: AnimatorStateData, offsetFrameTime: number): void { - this.stateData = stateData; - this.offsetFrameTime = offsetFrameTime; - this.playedTime = 0; - this.playState = AnimatorStatePlayState.UnStarted; - this.clipTime = this.state.clipStartTime * this.state.clip.length; - this.currentEventIndex = 0; - this.isForward = true; + this._stateData = stateData; + this._offsetFrameTime = offsetFrameTime; + this._playedTime = 0; + this._playState = AnimatorStatePlayState.UnStarted; + this._clipTime = this.state.clipStartTime * this.state.clip.length; + this._currentEventIndex = 0; + this._isForward = true; this._changedOrientation = false; this.state._transitionCollection.needResetCurrentCheckIndex = true; } @@ -82,44 +86,44 @@ export class AnimatorStatePlayData { /** @internal */ updateOrientation(deltaTime: number): void { if (deltaTime !== 0) { - const lastIsForward = this.isForward; - this.isForward = deltaTime > 0; - if (this.isForward !== lastIsForward) { + const lastIsForward = this._isForward; + this._isForward = deltaTime > 0; + if (this._isForward !== lastIsForward) { this._changedOrientation = true; - this.isForward || this._correctTime(); + this._isForward || this._correctTime(); } } } /** @internal */ update(deltaTime: number): void { - this.playedTime += deltaTime; + this._playedTime += deltaTime; const state = this.state; - let time = this.playedTime + this.offsetFrameTime; + let time = this._playedTime + this._offsetFrameTime; const duration = state._getDuration(); - this.playState = AnimatorStatePlayState.Playing; + this._playState = AnimatorStatePlayState.Playing; if (state.wrapMode === WrapMode.Loop) { time = duration ? time % duration : 0; } else { if (Math.abs(time) >= duration) { time = time < 0 ? -duration : duration; - this.playState = AnimatorStatePlayState.Finished; + this._playState = AnimatorStatePlayState.Finished; } } time < 0 && (time += duration); - this.clipTime = time + state.clipStartTime * state.clip.length; + this._clipTime = time + state.clipStartTime * state.clip.length; if (this._changedOrientation) { - !this.isForward && this._correctTime(); + !this._isForward && this._correctTime(); this._changedOrientation = false; } } private _correctTime() { const { state } = this; - if (this.clipTime === 0) { - this.clipTime = state.clipEndTime * state.clip.length; + if (this._clipTime === 0) { + this._clipTime = state.clipEndTime * state.clip.length; } } } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 7e1f465f17..30a6616a30 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -107,24 +107,24 @@ describe("Animator test", function () { const speed = 1; let expectedSpeed = speed * 0.5; animator.speed = expectedSpeed; - let playedTime = srcPlayData.playedTime; + let playedTime = srcPlayData._playedTime; // @ts-ignore animator.engine.time._frameCount++; animator.update(5); expect(animator.speed).to.eq(expectedSpeed); - expect(srcPlayData.playedTime).to.eq(playedTime + 5 * expectedSpeed); + expect(srcPlayData._playedTime).to.eq(playedTime + 5 * expectedSpeed); expectedSpeed = speed * 2; animator.speed = expectedSpeed; - playedTime = srcPlayData.playedTime; + playedTime = srcPlayData._playedTime; animator.update(10); expect(animator.speed).to.eq(expectedSpeed); - expect(srcPlayData.playedTime).to.eq(playedTime + 10 * expectedSpeed); + expect(srcPlayData._playedTime).to.eq(playedTime + 10 * expectedSpeed); expectedSpeed = speed * 0; animator.speed = expectedSpeed; - playedTime = srcPlayData.playedTime; + playedTime = srcPlayData._playedTime; animator.update(15); expect(animator.speed).to.eq(expectedSpeed); - expect(srcPlayData.playedTime).to.eq(playedTime + 15 * expectedSpeed); + expect(srcPlayData._playedTime).to.eq(playedTime + 15 * expectedSpeed); }); it("play animation", () => { @@ -161,7 +161,7 @@ describe("Animator test", function () { let animatorLayerData = animator["_animatorLayersData"]; const srcPlayData = animatorLayerData[0]?.srcPlayData; animator.update(5); - const curveOwner = srcPlayData.stateData.curveLayerOwner[0].curveOwner; + const curveOwner = srcPlayData._stateData.curveLayerOwner[0].curveOwner; const initValue = curveOwner.defaultValue; const currentValue = curveOwner.referenceTargetValue; expect(Quaternion.equals(initValue, currentValue)).to.eq(true); @@ -252,9 +252,9 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); const srcPlayData = layerData.srcPlayData; expect(srcPlayData.state.name).to.eq("Run"); - expect(srcPlayData.playedTime).to.eq(0.3); + expect(srcPlayData._playedTime).to.eq(0.3); // @ts-ignore - expect(srcPlayData.clipTime).to.eq(0.3 + 0.1 * runState.state._getDuration()); + expect(srcPlayData._clipTime).to.eq(0.3 + 0.1 * runState.state._getDuration()); }); it("animation cross fade by transition", () => { @@ -630,7 +630,7 @@ describe("Animator test", function () { const destPlayData = animator["_animatorLayersData"][0].destPlayData; const destState = destPlayData.state; const transitionDuration = toRunTransition.duration * destState._getDuration(); - const crossWeight = animator["_animatorLayersData"][0].destPlayData.playedTime / transitionDuration; + const crossWeight = animator["_animatorLayersData"][0].destPlayData._playedTime / transitionDuration; expect(crossWeight).to.lessThan(0.01); }); @@ -847,8 +847,8 @@ describe("Animator test", function () { animator.update(0.5); expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData.playedTime).to.eq(0.5); - expect(layerData.srcPlayData.clipTime).to.eq(walkState.state.clip.length * 0.5 + 0.5); + expect(layerData.srcPlayData._playedTime).to.eq(0.5); + expect(layerData.srcPlayData._clipTime).to.eq(walkState.state.clip.length * 0.5 + 0.5); }); it("hasExitTime", () => { @@ -877,7 +877,7 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(walkState.state.clip.length * 0.5); expect(layerData.destPlayData.state.name).to.eq("Run"); - expect(layerData.destPlayData.playedTime).to.eq(0); + expect(layerData.destPlayData._playedTime).to.eq(0); const anyToIdleTransition = stateMachine.addAnyStateTransition(idleState.state); anyToIdleTransition.hasExitTime = false; anyToIdleTransition.duration = 0.2; @@ -887,12 +887,12 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.1); expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData.playedTime).to.eq(0.1); + expect(layerData.srcPlayData._playedTime).to.eq(0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update(idleState.state.clip.length * 0.2 - 0.1); expect(layerData.srcPlayData.state.name).to.eq("Survey"); - expect(layerData.srcPlayData.clipTime).to.eq(idleState.state.clip.length * 0.2); + expect(layerData.srcPlayData._clipTime).to.eq(idleState.state.clip.length * 0.2); }); it("setTriggerParameter", () => { @@ -927,27 +927,27 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.1); expect(layerData.srcPlayData.state.name).to.eq("Walk"); - expect(layerData.srcPlayData.playedTime).to.eq(0.1); + expect(layerData.srcPlayData._playedTime).to.eq(0.1); expect(layerData.destPlayData.state.name).to.eq("Run"); - expect(layerData.destPlayData.playedTime).to.eq(0.1); + 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.state.clip.length * 0.1 - 0.1); expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData.playedTime).to.eq(runState.state.clip.length * 0.1); + expect(layerData.srcPlayData._playedTime).to.eq(runState.state.clip.length * 0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update(runState.state.clip.length * 0.6); expect(layerData.destPlayData.state.name).to.eq("Walk"); - expect(layerData.destPlayData.playedTime).to.eq(0); + expect(layerData.destPlayData._playedTime).to.eq(0); expect(animator.getParameterValue("triggerWalk")).to.eq(false); // @ts-ignore animator.engine.time._frameCount++; animator.update(walkState.state.clip.length * 0.3); expect(layerData.srcPlayData.state.name).to.eq("Walk"); - expect(layerData.srcPlayData.playedTime).to.eq(walkState.state.clip.length * 0.3); + expect(layerData.srcPlayData._playedTime).to.eq(walkState.state.clip.length * 0.3); }); it("fixedDuration", () => { @@ -972,8 +972,8 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.1); expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData.playedTime).to.eq(0.1); - expect(layerData.srcPlayData.clipTime).to.eq(0); + expect(layerData.srcPlayData._playedTime).to.eq(0.1); + expect(layerData.srcPlayData._clipTime).to.eq(0); }); it("transitionIndex", () => { @@ -1389,7 +1389,7 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - const srcPlayedBefore = layerData.srcPlayData.playedTime; + const srcPlayedBefore = layerData.srcPlayData._playedTime; // Start crossFade — during crossFade, src should still advance per playData.speed=4 animator.crossFade("Walk", 0.5, 0, 0); @@ -1397,7 +1397,7 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.05); // 50ms of crossfade - const srcPlayedAfter = layerData.srcPlayData.playedTime; + const srcPlayedAfter = layerData.srcPlayData._playedTime; const advanced = srcPlayedAfter - srcPlayedBefore; // With playData.speed=4 and dt=0.05, expect ~0.2 (4 * 0.05). With state.speed=1 it'd be ~0.05. expect(advanced).to.be.closeTo(0.2, 0.05); @@ -1426,13 +1426,13 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; const srcBefore = layerData.srcPlayData; - const playedBefore = srcBefore.playedTime; + 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.srcPlayData._playedTime).to.eq(playedBefore); expect(layerData.destPlayData).to.eq(null); }); @@ -1450,13 +1450,13 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; const destBefore = layerData.destPlayData; - const destPlayedBefore = destBefore.playedTime; + 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); + expect(layerData.destPlayData._playedTime).to.eq(destPlayedBefore); }); it("state-machine self-transition is also a no-op (alias-guard policy)", () => { @@ -1477,7 +1477,7 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; const srcBefore = layerData.srcPlayData; - const playedBefore = srcBefore.playedTime; + const playedBefore = srcBefore._playedTime; // Trigger the self-transition animator.setParameterValue("restart", true); @@ -1489,7 +1489,7 @@ describe("Animator test", function () { // src should keep advancing as if no transition happened, dest stays null. expect(layerData.srcPlayData).to.eq(srcBefore); expect(layerData.srcPlayData.state.name).to.eq("Walk"); - expect(layerData.srcPlayData.playedTime).to.be.greaterThan(playedBefore); + expect(layerData.srcPlayData._playedTime).to.be.greaterThan(playedBefore); expect(layerData.destPlayData).to.eq(null); }); @@ -1572,10 +1572,10 @@ describe("Animator test", function () { // @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(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); + expect(layerData.destPlayData?._playedTime).to.be.greaterThan(0); }); it("no-exit transition out of speed=0 source preserves remaining deltaTime and avoids NaN", () => { @@ -1604,11 +1604,11 @@ describe("Animator test", function () { // @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(Number.isNaN(layerData.srcPlayData._playedTime)).to.eq(false); + expect(Number.isNaN(layerData.destPlayData?._playedTime ?? 0)).to.eq(false); expect(layerData.destPlayData?.state.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); + expect(layerData.destPlayData?._playedTime).to.be.greaterThan(0); }); }); From 500657e719806e1967621cf7c79705f3e9879324 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 13:59:22 +0800 Subject: [PATCH 34/92] refactor(entity): findByPath polish from review feedback - Replace this._children.some(callback) with explicit for-loop to avoid the per-call callback allocation; matches core/animation iteration style for small caches and hot-ish paths. - Annotate _findChildByPathDown return type as Entity | null since the helper genuinely returns null on miss. --- packages/core/src/Entity.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 41e2d8f649..9fdf7bdc28 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -47,7 +47,7 @@ export class Entity extends EngineObject { * @internal * Subtree-only path search: never backtracks to parent/siblings, returns null on miss. */ - static _findChildByPathDown(entity: Entity, paths: string[], pathIndex: number): Entity { + static _findChildByPathDown(entity: Entity, paths: string[], pathIndex: number): Entity | null { const searchPath = paths[pathIndex]; const isEndPath = pathIndex === paths.length - 1; const children = entity._children; @@ -406,7 +406,14 @@ export class Entity extends EngineObject { // Supports paths authored relative to this entity's parent but evaluated // from this entity (e.g. "root/child/leaf" called on the entity named "root"). if (splits[0] === this.name) { - const hasFirstSegmentChild = this._children.some((child) => child.name === splits[0]); + const children = this._children; + let hasFirstSegmentChild = false; + for (let i = 0, n = children.length; i < n; i++) { + if (children[i].name === splits[0]) { + hasFirstSegmentChild = true; + break; + } + } if (!hasFirstSegmentChild) { return splits.length === 1 ? this : Entity._findChildByPathDown(this, splits, 1); } From e396243b033674294bbfd23cb3857832624479ce Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 14:06:18 +0800 Subject: [PATCH 35/92] docs(animation): tighten self-cross-fade no-op comment per review --- packages/core/src/animation/Animator.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 3ce616cb3e..353bfc6681 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -1430,11 +1430,10 @@ export class Animator extends Component { const animatorLayerData = this._getAnimatorLayerData(layerIndex); - // Self-cross-fade is intentionally a no-op: this layer holds one persistent - // PlayData handle per AnimatorState (so per-instance overrides like speed - // survive transitions). Supporting cross-fade-to-self would require a - // separate transient playback track per active fade, which is a larger - // runtime redesign deliberately deferred. + // Self/active-dest cross-fade is intentionally a no-op because each state + // owns one persistent PlayData handle per layer (so per-instance overrides + // like speed survive transitions). Supporting self cross-fade would require + // a separate transient playback track per active fade. if (animatorLayerData.srcPlayData?.state === crossState || animatorLayerData.destPlayData?.state === crossState) { return false; } From cc481e1ecf4773d9a34cb14787b471cbe7a2ba53 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 15:11:53 +0800 Subject: [PATCH 36/92] fix(entity): _findChildByPathDown retries same-name siblings on deep miss The previous helper short-circuited on the first same-name child match and never tried later same-name siblings, regressing vs the old _findChildByName behavior. When a path like "root/a/leaf" needed to fall through the first 'a' subtree (no leaf) into the second 'a' subtree (has leaf), the helper returned null instead of finding the leaf. Continue the for-loop on miss so subsequent same-name siblings are attempted. Subtree containment is preserved: the helper still doesn't backtrack into entity.parent or beyond the entity's subtree. --- packages/core/src/Entity.ts | 9 ++++++++- tests/src/core/Entity.test.ts | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 9fdf7bdc28..7eb1638750 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -55,7 +55,14 @@ export class Entity extends EngineObject { for (let i = 0, n = children.length; i < n; i++) { const child = children[i]; if (child.name === searchPath) { - return isEndPath ? child : Entity._findChildByPathDown(child, paths, pathIndex + 1); + if (isEndPath) { + return child; + } + const found = Entity._findChildByPathDown(child, paths, pathIndex + 1); + if (found) { + return found; + } + // Otherwise continue the for loop to try the next same-name sibling } } diff --git a/tests/src/core/Entity.test.ts b/tests/src/core/Entity.test.ts index e9bc43b5dc..babac249a0 100644 --- a/tests/src/core/Entity.test.ts +++ b/tests/src/core/Entity.test.ts @@ -380,6 +380,22 @@ describe("Entity", async () => { expect(target.findByPath("target/sibling")).to.eq(null); }); + it("findByPath self-prefix fallback retries same-name siblings on deep miss", () => { + const parent = new Entity(engine, "root"); + parent.parent = scene.getRootEntity(); + // Two same-name children: only the second one has the deeper "leaf" + const firstA = new Entity(engine, "a"); + firstA.parent = parent; + const secondA = new Entity(engine, "a"); + secondA.parent = parent; + const leaf = new Entity(engine, "leaf"); + leaf.parent = secondA; + + // Path "root/a/leaf" via self-prefix fallback should walk into the + // second 'a' after the first 'a' subtree misses 'leaf'. + expect(parent.findByPath("root/a/leaf")).to.eq(leaf); + }); + it("clearChildren", () => { const parent = new Entity(engine, "parent"); From 85b82d8d37954f67dc6c474eb09f85081e8060a1 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 15:13:36 +0800 Subject: [PATCH 37/92] fix(animation): rebuild PlayData when AnimatorState identity changes statePlayDataMap was keyed by state.name; if a user removed and re-added a state with the same name (creating a fresh AnimatorState instance), the next getOrCreatePlayData would return the cached handle whose .state still pointed at the removed state. Validate identity on lookup and rebuild on mismatch. This addresses dynamic controller mutation patterns; it doesn't depend on AnimatorController dispatching an update flag for state-level mutations (which it currently doesn't). --- .../animation/internal/AnimatorLayerData.ts | 2 +- tests/src/core/Animator.test.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index 21f8ca2901..79b616157a 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -31,7 +31,7 @@ export class AnimatorLayerData { const statePlayDataMap = this.statePlayDataMap; const stateName = state.name; let playData = statePlayDataMap[stateName]; - if (!playData) { + if (!playData || playData.state !== state) { playData = new AnimatorStatePlayData(state); statePlayDataMap[stateName] = playData; } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 30a6616a30..384ab93a34 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1417,6 +1417,36 @@ describe("Animator test", function () { survey.state.speed = 1; // restore }); + 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.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.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("crossFade to current state is no-op (avoids src/dest PlayData alias)", () => { animator.play("Walk"); // @ts-ignore From 6893ac20784ae8dc06c22b54fc4d3d8f435a0b00 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 15:14:31 +0800 Subject: [PATCH 38/92] fix(animation): findAnimatorState honors controller update flag play() and update() guarded against stale _animatorLayersData by checking _controllerUpdateFlag.flag and calling _reset(). findAnimatorState didn't, so handles returned right after a controller mutation (addLayer/removeLayer/clearLayers) were backed by pre-mutation layer data. Add the same guard. --- packages/core/src/animation/Animator.ts | 3 +++ tests/src/core/Animator.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 353bfc6681..4677b292d8 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -223,6 +223,9 @@ export class Animator extends Component { * @returns Per-instance AnimatorStatePlayData, or null if no state matches */ findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData | null { + if (this._controllerUpdateFlag?.flag) { + this._reset(); + } const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); if (!state || foundLayer < 0) return null; return this._getAnimatorLayerData(foundLayer).getOrCreatePlayData(state); diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 384ab93a34..a51308e30b 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1447,6 +1447,25 @@ describe("Animator test", function () { animator._reset(); }); + 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("crossFade to current state is no-op (avoids src/dest PlayData alias)", () => { animator.play("Walk"); // @ts-ignore From 8c5c9a364045f8206c80eb3bd4525dd53035d6ed Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 17:01:59 +0800 Subject: [PATCH 39/92] fix(animation): rebuild AnimatorStateData when state identity changes Same-name removeState + addState would hit the stateName-keyed cache and return the previous state's curveLayerOwner / event handlers, so playing the new state evaluated with the old curve owners (e.g. position curve applied to rotation owner). Track the source state on AnimatorStateData and rebuild on identity miss; detach the prior clipChangedListener so the old state's UpdateFlagManager doesn't keep mutating discarded data. Adds a play+evaluate regression locking down curveLayerOwner rebinding. --- packages/core/src/animation/Animator.ts | 11 +++ .../animation/internal/AnimatorStateData.ts | 5 ++ tests/src/core/Animator.test.ts | 85 ++++++++++++++++++- 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 4677b292d8..3846c6f135 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -419,8 +419,18 @@ export class Animator extends Component { ): AnimatorStateData { const { animatorStateDataMap } = animatorLayerData; let animatorStateData = animatorStateDataMap[stateName]; + if (animatorStateData && animatorStateData.state !== animatorState) { + // Same name but different state instance (e.g. removeState + addState same name): + // detach the old listener and rebuild stateData against the new state. + const { state: previousState, clipChangedListener } = animatorStateData; + if (previousState && clipChangedListener) { + previousState._updateFlagManager.removeListener(clipChangedListener); + } + animatorStateData = null; + } if (!animatorStateData) { animatorStateData = new AnimatorStateData(); + animatorStateData.state = animatorState; animatorStateDataMap[stateName] = animatorStateData; this._saveAnimatorStateData(animatorState, animatorStateData, animatorLayerData, layerIndex); this._saveAnimatorEventHandlers(animatorState, animatorStateData); @@ -509,6 +519,7 @@ export class Animator extends Component { }; clipChangedListener(); state._updateFlagManager.addListener(clipChangedListener); + animatorStateData.clipChangedListener = clipChangedListener; } private _clearCrossData(animatorLayerData: AnimatorLayerData): void { diff --git a/packages/core/src/animation/internal/AnimatorStateData.ts b/packages/core/src/animation/internal/AnimatorStateData.ts index 146c0bb52f..6625cb4b54 100644 --- a/packages/core/src/animation/internal/AnimatorStateData.ts +++ b/packages/core/src/animation/internal/AnimatorStateData.ts @@ -1,3 +1,4 @@ +import { AnimatorState } from "../AnimatorState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimationEventHandler } from "./AnimationEventHandler"; @@ -5,6 +6,10 @@ import { AnimationEventHandler } from "./AnimationEventHandler"; * @internal */ export class AnimatorStateData { + /** Source state this cached data was built against; used to detect identity change after remove/re-add. */ + state: AnimatorState | null = null; + /** Listener registered on `state._updateFlagManager`; kept so we can detach when stateData is rebuilt. */ + clipChangedListener: (() => void) | null = null; curveLayerOwner: AnimationCurveLayerOwner[] = []; eventHandlers: AnimationEventHandler[] = []; } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index a51308e30b..e9345383aa 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 } from "@galacean/engine-math"; +import { Quaternion, Vector3 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { glbResource } from "./model/fox"; @@ -1447,6 +1447,89 @@ describe("Animator test", function () { 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["X"]; + 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["X"]; + // 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"); From 4caa2bff87b7e452636c869143efc9c9d6f22d44 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 17:21:13 +0800 Subject: [PATCH 40/92] fix(animation): detach stateData listeners on _reset to prevent accumulation _reset() dropped the entire animatorLayersData array, which meant the clipChangedListener installed on each surviving AnimatorState's UpdateFlagManager was leaked. Repeated controller mutations would register a fresh listener per play() while the previous ones lived on, mutating discarded stateData on every clip change. Walk the cached stateData maps before clearing the array and remove the listener via the handle now stored on AnimatorStateData. Adds a regression locking down listener count after three controller mutation cycles. --- packages/core/src/animation/Animator.ts | 16 +++++++++++++++ tests/src/core/Animator.test.ts | 27 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 3846c6f135..4e6245f6db 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -330,6 +330,22 @@ export class Animator extends Component { } } + // Detach clipChangedListeners before dropping stateData; otherwise each + // controller mutation would leave a dead listener attached to the + // surviving AnimatorState's UpdateFlagManager. + const layersData = this._animatorLayersData; + for (let i = 0, n = layersData.length; i < n; i++) { + const stateDataMap = layersData[i]?.animatorStateDataMap; + if (!stateDataMap) continue; + for (const stateName in stateDataMap) { + const stateData = stateDataMap[stateName]; + const { state, clipChangedListener } = stateData; + if (state && clipChangedListener) { + state._updateFlagManager.removeListener(clipChangedListener); + } + } + } + this._animatorLayersData.length = 0; this._curveOwnerPool = Object.create(null); this._parametersValueMap = Object.create(null); diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index e9345383aa..315fbf14fd 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1549,6 +1549,33 @@ describe("Animator test", function () { controller.removeLayer(controller.layers.indexOf(dummyLayer)); }); + it("_reset detaches stateData clipChangedListeners so they do not accumulate on the AnimatorState", () => { + const survey = animator.findAnimatorState("Survey"); + expect(survey).not.to.eq(null); + const surveyState = survey.state; + // @ts-ignore — read internal listener list size + const listenersBefore = surveyState._updateFlagManager._listeners.length; + + // First play: registers one clipChangedListener for Survey on this layer. + animator.play("Survey"); + // @ts-ignore + const listenersAfterFirstPlay = surveyState._updateFlagManager._listeners.length; + expect(listenersAfterFirstPlay).to.eq(listenersBefore + 1); + + // Three controller mutations → three _reset() calls → without cleanup + // each reset would leave its prior listener attached and the next play + // would register a fresh one on top of it. + for (let i = 0; i < 3; i++) { + const dummy = new AnimatorControllerLayer(`__dummy_${i}__`); + animator.animatorController.addLayer(dummy); + animator.play("Survey"); + // @ts-ignore + const count = surveyState._updateFlagManager._listeners.length; + expect(count, `listener count after iteration ${i + 1}`).to.eq(listenersBefore + 1); + animator.animatorController.removeLayer(animator.animatorController.layers.indexOf(dummy)); + } + }); + it("crossFade to current state is no-op (avoids src/dest PlayData alias)", () => { animator.play("Walk"); // @ts-ignore From ce40c43b495a0ee5541e29e3d1c3f2e56d777c64 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Sun, 10 May 2026 17:43:23 +0800 Subject: [PATCH 41/92] fix(animation): _onDestroy reuses _reset to detach stateData listeners Round-11 plugged the same listener leak on _reset() but destroy() never went through that path, so destroying an Animator while its controller or any AnimatorState outlived it left the clipChangedListener attached to that state's UpdateFlagManager. The closure kept the destroyed entity reachable via clip dispatch. Call _reset() at the top of _onDestroy() so destroy reuses the listener detach pass we already added on controller mutation. Adds a regression that builds an Animator on a shared controller, plays once, destroys it, and asserts the listener count returns to its baseline. --- packages/core/src/animation/Animator.ts | 4 +++ tests/src/core/Animator.test.ts | 39 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 4e6245f6db..dbdc2f904c 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -368,6 +368,10 @@ export class Animator extends Component { } protected override _onDestroy(): void { + // Reuse _reset() to detach AnimatorStateData clipChangedListeners — without + // this the listener closures stay attached to surviving AnimatorState + // UpdateFlagManagers and keep referencing the destroyed entity / stateData. + this._reset(); super._onDestroy(); const controller = this._animatorController; if (controller) { diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 315fbf14fd..8575515f63 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1549,6 +1549,45 @@ describe("Animator test", function () { controller.removeLayer(controller.layers.indexOf(dummyLayer)); }); + it("destroy detaches stateData clipChangedListeners from surviving AnimatorState", () => { + // Build a controller whose AnimatorState we can keep alive after the animator is destroyed. + const controller = new AnimatorController(engine); + const layer = new AnimatorControllerLayer("layer"); + controller.addLayer(layer); + const sharedState = layer.stateMachine.addState("Y"); + const clip = new AnimationClip("yClip"); + const curve = new AnimationFloatCurve(); + const k1 = new Keyframe(); + k1.time = 0; + k1.value = 0; + const k2 = new Keyframe(); + k2.time = 1; + k2.value = 90; + curve.addKey(k1); + curve.addKey(k2); + clip.addCurveBinding("", Transform, "rotation.x", curve); + sharedState.clip = clip; + + // @ts-ignore — inspect listener attachment on the shared state directly. + const listenersBefore = sharedState._updateFlagManager._listeners.length; + + const localEntity = new Entity(engine); + const localAnimator = localEntity.addComponent(Animator); + localAnimator.animatorController = controller; + localAnimator.play("Y"); + // @ts-ignore + expect(sharedState._updateFlagManager._listeners.length).to.eq(listenersBefore + 1); + + // Destroying only the Animator (controller + state still alive) must + // detach the clipChangedListener it installed; otherwise the closure + // keeps a destroyed entity reachable through state.clip.dispatch(). + localAnimator.destroy(); + localEntity.destroy(); + + // @ts-ignore + expect(sharedState._updateFlagManager._listeners.length).to.eq(listenersBefore); + }); + it("_reset detaches stateData clipChangedListeners so they do not accumulate on the AnimatorState", () => { const survey = animator.findAnimatorState("Survey"); expect(survey).not.to.eq(null); From 15d88f5c6ed9a0d00f77d6fb6a16871541ace912 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 10:18:12 +0800 Subject: [PATCH 42/92] revert(entity): remove self-prefix fallback from findByPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-prefix branch added in 0273ebfaf assumed Animator could sit directly on a GLTF sceneRoot (single-root scenes), but c3d2160c3 in this same PR unconditionally wraps every scene in a GLTF_ROOT container 3 days earlier. After that change the Animator is always on the wrapper and binding paths always start with a real child name — the self-prefix branch is never reached on a real GLTF load. Evidence: the Animator regression added by 0273ebfaf itself (samples self-name-prefixed curve paths on wrapped roots) builds a "GLTF_ROOT" wrapper and a "mixamorig:Hips" child, then asserts wrappedRoot.findByPath("mixamorig:Hips"). splits[0] is "mixamorig:Hips", this.name is "GLTF_ROOT" — the self-prefix guard splits[0] === this.name is false, so the test passes via the generic child lookup with or without the branch. The four follow-up commits (c446e490a / 3ea4dd47e / da0d6a384 / 6de9e5a9e) were all patching edge cases introduced by the self-prefix branch itself; none of those bugs exist when the branch is gone. Drop: - findByPath self-prefix fallback (Entity.ts) - _findChildByPathDown helper, which existed only to back that fallback - six Entity tests that locked the self-prefix behaviour contract Keep: - generic findByPath sibling backtrack test (Entity.test.ts:304) which exercises _findChildByName's same-name-sibling retry - the Animator wrapper-relative regression (it now exercises the generic path, which is what it was actually testing anyway) - GLTFAnimationParser single-root prefix logic (necessary to make bindings GLTF_ROOT-relative) --- packages/core/src/Entity.ts | 52 +----------------------- tests/src/core/Entity.test.ts | 76 ----------------------------------- 2 files changed, 1 insertion(+), 127 deletions(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 7eb1638750..7f025f321a 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -43,32 +43,6 @@ export class Entity extends EngineObject { : Entity._findChildByName(entity.parent, entity.siblingIndex + 1, paths, pathIndex - 1); } - /** - * @internal - * Subtree-only path search: never backtracks to parent/siblings, returns null on miss. - */ - static _findChildByPathDown(entity: Entity, paths: string[], pathIndex: number): Entity | null { - const searchPath = paths[pathIndex]; - const isEndPath = pathIndex === paths.length - 1; - const children = entity._children; - - for (let i = 0, n = children.length; i < n; i++) { - const child = children[i]; - if (child.name === searchPath) { - if (isEndPath) { - return child; - } - const found = Entity._findChildByPathDown(child, paths, pathIndex + 1); - if (found) { - return found; - } - // Otherwise continue the for loop to try the next same-name sibling - } - } - - return null; - } - /** * @internal */ @@ -402,31 +376,7 @@ export class Entity extends EngineObject { if (!splits.length) { return this; } - - // Prefer descending into a same-name child (normal path semantics). - const childMatch = Entity._findChildByName(this, 0, splits, 0); - if (childMatch) { - return childMatch; - } - - // Fallback to self-name prefix only when there's no child by splits[0]. - // Supports paths authored relative to this entity's parent but evaluated - // from this entity (e.g. "root/child/leaf" called on the entity named "root"). - if (splits[0] === this.name) { - const children = this._children; - let hasFirstSegmentChild = false; - for (let i = 0, n = children.length; i < n; i++) { - if (children[i].name === splits[0]) { - hasFirstSegmentChild = true; - break; - } - } - if (!hasFirstSegmentChild) { - return splits.length === 1 ? this : Entity._findChildByPathDown(this, splits, 1); - } - } - - return null; + return Entity._findChildByName(this, 0, splits, 0); } /** diff --git a/tests/src/core/Entity.test.ts b/tests/src/core/Entity.test.ts index babac249a0..ac4795890e 100644 --- a/tests/src/core/Entity.test.ts +++ b/tests/src/core/Entity.test.ts @@ -320,82 +320,6 @@ describe("Entity", async () => { expect(parent.findByPath("child/grandson")).eq(grandson2); }); - it("findByPath accepts self-name prefix", () => { - const parent = new Entity(engine, "parent"); - parent.parent = scene.getRootEntity(); - const child = new Entity(engine, "child"); - child.parent = parent; - const grandson = new Entity(engine, "grandson"); - grandson.parent = child; - - expect(parent.findByPath("parent")).eq(parent); - expect(parent.findByPath("parent/child")).eq(child); - expect(parent.findByPath("parent/child/grandson")).eq(grandson); - }); - - it("findByPath prefers same-name child over self", () => { - const parent = new Entity(engine, "shared"); - parent.parent = scene.getRootEntity(); - const sameNameChild = new Entity(engine, "shared"); - sameNameChild.parent = parent; - const grandson = new Entity(engine, "leaf"); - grandson.parent = sameNameChild; - - // Should walk into the child named "shared", not return self - expect(parent.findByPath("shared")).to.eq(sameNameChild); - expect(parent.findByPath("shared/leaf")).to.eq(grandson); - }); - - it("findByPath does not fallback to self-prefix when same-name child exists but deeper path misses", () => { - const parent = new Entity(engine, "shared"); - parent.parent = scene.getRootEntity(); - const sameNameChild = new Entity(engine, "shared"); - sameNameChild.parent = parent; - const sibling = new Entity(engine, "other"); - sibling.parent = parent; - - // Same-name child exists but doesn't have an "other" descendant - // → must NOT fallback to self-prefix and return parent/other - expect(parent.findByPath("shared/other")).to.eq(null); - }); - - it("findByPath returns null without crashing for missing child under self-prefix", () => { - // Detached root entity (no parent, no children) — exercises the - // backtrack path that previously crashed on null parent - const root = new Entity(engine, "root"); - expect(() => root.findByPath("root/missing")).not.to.throw(); - expect(root.findByPath("root/missing")).to.eq(null); - }); - - it("findByPath self-prefix fallback does not search beyond this entity's subtree", () => { - // root → [parent (no children), sibling] - const top = scene.getRootEntity(); - const target = new Entity(engine, "target"); - target.parent = top; - const sibling = new Entity(engine, "sibling"); - sibling.parent = top; - - // target.findByPath("target/sibling") with target name "target", no children: - // self-prefix fallback should NOT bubble up to top.children to find sibling - expect(target.findByPath("target/sibling")).to.eq(null); - }); - - it("findByPath self-prefix fallback retries same-name siblings on deep miss", () => { - const parent = new Entity(engine, "root"); - parent.parent = scene.getRootEntity(); - // Two same-name children: only the second one has the deeper "leaf" - const firstA = new Entity(engine, "a"); - firstA.parent = parent; - const secondA = new Entity(engine, "a"); - secondA.parent = parent; - const leaf = new Entity(engine, "leaf"); - leaf.parent = secondA; - - // Path "root/a/leaf" via self-prefix fallback should walk into the - // second 'a' after the first 'a' subtree misses 'leaf'. - expect(parent.findByPath("root/a/leaf")).to.eq(leaf); - }); - it("clearChildren", () => { const parent = new Entity(engine, "parent"); From 9d296b0ed466817cdbb991fa9fdd96817ebd9d94 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 10:44:08 +0800 Subject: [PATCH 43/92] refactor(animation): collapse getOrCreatePlayData guard with optional chaining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two conditions !playData || playData.state !== state can be expressed as a single playData?.state !== state — identical semantics across all three cases (undefined, identity match, identity mismatch). Removes one branch token while making the intent ("cache entry doesn't belong to this state") read as a single thought. --- packages/core/src/animation/internal/AnimatorLayerData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index 79b616157a..ceb9c89454 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -31,7 +31,7 @@ export class AnimatorLayerData { const statePlayDataMap = this.statePlayDataMap; const stateName = state.name; let playData = statePlayDataMap[stateName]; - if (!playData || playData.state !== state) { + if (playData?.state !== state) { playData = new AnimatorStatePlayData(state); statePlayDataMap[stateName] = playData; } From 5859036a0aeb35559934d1a81fac979eeca0cf1e Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 11:03:34 +0800 Subject: [PATCH 44/92] refactor(animation): drop clearSpeedOverride, treat speed write as final MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clearSpeedOverride() existed solely to flip the instance back into live-binding state.speed after a write. In practice no production flow actually needs that: every "restore" use case is expressible as a direct write (playData.speed = previousValue, or = playData.state.speed to follow the current asset value). Keeping the API around forces users to reason about a two-state model (live-bind vs override) instead of the simpler "write = instance owns it" semantics. Behavior: - speed still live-binds to state.speed until first write (good editor ergonomics: tweaking asset speed propagates to instances that haven't claimed ownership yet) - writing once flips the instance to own its own speed; no escape hatch back to live-bind — by design, since the write is the signal of intent - to re-follow the asset, simply assign playData.state.speed back Drops 1 public API from AnimatorStatePlayData.d.ts. Surface is now { state, speed, get/set speed }. Tests: removed the dedicated clearSpeedOverride regression. The remaining 53 Animator tests still cover speed override read/write, override survival across cross-fade, clone isolation, and speed=0 no-NaN guard. Docs: updated zh/en animator.mdx pause example + findAnimatorState section to use "assign the asset value back" instead of "clearSpeedOverride()". --- docs/en/animation/animator.mdx | 8 +++++--- docs/zh/animation/animator.mdx | 8 +++++--- .../core/src/animation/AnimatorStatePlayData.ts | 15 ++++++--------- tests/src/core/Animator.test.ts | 14 -------------- 4 files changed, 16 insertions(+), 29 deletions(-) diff --git a/docs/en/animation/animator.mdx b/docs/en/animation/animator.mdx index 773e858b20..ef04e5212a 100644 --- a/docs/en/animation/animator.mdx +++ b/docs/en/animation/animator.mdx @@ -125,8 +125,10 @@ if (!playData) { // Pause only this Animator instance's playback of the state. playData.speed = 0; -// ...later, when you want to resume tracking the shared state.speed: -// playData.clearSpeedOverride(); +// To resume later, just assign the desired speed again, e.g.: +// playData.speed = 1; +// or follow whatever the asset is currently configured to: +// playData.speed = playData.state.speed; ``` ### Transition to Specified Animation State @@ -155,7 +157,7 @@ You can use the [findAnimatorState](/apis/core/#Animator-findAnimatorState) meth The returned `AnimatorStatePlayData` exposes two distinct surfaces: -- `playData.speed` — per-Animator playback speed override. Reading and writing it only affects this `Animator` instance and won't affect other `Animator` instances sharing the same `AnimatorController`. Call `clearSpeedOverride()` to resume live binding to `state.speed`. +- `playData.speed` — per-Animator playback speed. **Until written**, it live-binds to `state.speed`; once you write a value the instance owns its own speed, and later changes to `state.speed` no longer flow through. To follow the asset again, simply assign `playData.state.speed` back. Reading and writing only affects this `Animator` instance and won't affect other `Animator` instances sharing the same `AnimatorController`. - `playData.state` — the underlying shared `AnimatorState` asset. Mutating fields like `wrapMode` mutates the shared `AnimatorController` asset and therefore affects every `Animator` using this controller. ```typescript diff --git a/docs/zh/animation/animator.mdx b/docs/zh/animation/animator.mdx index 84bbd8706b..5b93ef42a2 100644 --- a/docs/zh/animation/animator.mdx +++ b/docs/zh/animation/animator.mdx @@ -129,8 +129,10 @@ if (!playData) { // 仅暂停当前 Animator 实例的该状态播放。 playData.speed = 0; -// ……稍后想恢复对共享 state.speed 的实时绑定时调用: -// playData.clearSpeedOverride(); +// 想要恢复时再写一次目标速度即可,例如: +// playData.speed = 1; +// 或者跟随当前 asset 配置: +// playData.speed = playData.state.speed; ``` ### 过渡指定动画状态 @@ -160,7 +162,7 @@ currentState.wrapMode = WrapMode.Loop; 返回的 `AnimatorStatePlayData` 提供两套语义不同的访问入口: -- `playData.speed`:每个 `Animator` 实例独立的播放速度覆盖。读写只影响当前 `Animator` 实例,不会影响其他共享同一 `AnimatorController` 的 `Animator` 实例。调用 `clearSpeedOverride()` 可以恢复对 `state.speed` 的实时绑定。 +- `playData.speed`:每个 `Animator` 实例独立的播放速度。**未写入前**实时绑定到 `state.speed`,一旦写入即由该实例自行持有,之后修改 `state.speed` 不会再传递到此实例;想再次跟随 asset 时直接写回 `playData.state.speed` 即可。读写只影响当前 `Animator` 实例,不会影响其他共享同一 `AnimatorController` 的 `Animator` 实例。 - `playData.state`:底层共享的 `AnimatorState` 资产。对 `wrapMode` 等字段的修改会改变共享的 `AnimatorController` 资产,因此会影响所有使用该控制器的 `Animator`。 ```typescript diff --git a/packages/core/src/animation/AnimatorStatePlayData.ts b/packages/core/src/animation/AnimatorStatePlayData.ts index 777cef03e9..7fccae55b8 100644 --- a/packages/core/src/animation/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/AnimatorStatePlayData.ts @@ -13,7 +13,9 @@ import { AnimatorStateData } from "./internal/AnimatorStateData"; * * Public surface is intentionally narrow: * - `state`: the shared AnimatorState asset (read-only). - * - `speed` / `clearSpeedOverride()`: per-instance speed override. + * - `speed`: per-instance speed. Reads live-bind to `state.speed` until a value is + * assigned, after which the instance owns its own speed and asset changes no longer + * affect it. Write a fresh value (or `playData.state.speed`) to update it again. * * All other fields are engine-managed runtime state and are underscore-prefixed to * mark them as implementation detail; mutating them from user code will corrupt @@ -44,10 +46,10 @@ export class AnimatorStatePlayData { /** * Per-instance playback speed for this state. * - * - Read: returns the override if set; otherwise live-reads `state.speed`. - * - Write: sets the override. Subsequent changes to `state.speed` no longer affect this instance until `clearSpeedOverride()`. + * - Read: live-reads `state.speed` until written; afterwards returns the per-instance value. + * - Write: claims per-instance ownership. Later changes to `state.speed` no longer flow through. * - * Override persists across state transitions. + * Per-instance value persists across state transitions. */ get speed(): number { return this._speedOverride ?? this.state.speed; @@ -57,11 +59,6 @@ export class AnimatorStatePlayData { this._speedOverride = value; } - /** Clear the per-instance speed override; resume tracking shared `state.speed`. */ - clearSpeedOverride(): void { - this._speedOverride = undefined; - } - /** @internal */ constructor(state: AnimatorState) { this.state = state; diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 8575515f63..4e1b9305cc 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1403,20 +1403,6 @@ describe("Animator test", function () { expect(advanced).to.be.closeTo(0.2, 0.05); }); - it("clearSpeedOverride resumes shared state.speed", () => { - const survey = animator.findAnimatorState("Survey"); - survey.speed = 0.5; - expect(survey.speed).to.eq(0.5); - - survey.state.speed = 3; - expect(survey.speed).to.eq(0.5); // override still wins - - survey.clearSpeedOverride(); - expect(survey.speed).to.eq(3); // now follows asset - - survey.state.speed = 1; // restore - }); - 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"); From eec99ade70ee66d2df82c46c883cddacf4f2919e Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 11:19:27 +0800 Subject: [PATCH 45/92] refactor(animation): tighten AnimatorStateData lifecycle invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two adjustments codex called out in round-9 polish review: A) Centralize listener detach in AnimatorStateData.dispose(). The two call sites that previously open-coded the same "destructure { state, clipChangedListener }, null-check, removeListener" template — the identity-mismatch branch in _getAnimatorStateData and the cleanup loop in _reset — now both call stateData.dispose(). Future listener sources (or rebuild paths) stay encapsulated in the data holder. B) Make AnimatorStateData.state a readonly constructor parameter. The runtime invariant is "a live stateData is always bound to a state", but the previous nullable field forced every consumer to guard `state && clipChangedListener` even though the null window only existed for one statement between new and assignment. Construction via `new AnimatorStateData(animatorState)` mirrors the same shape as AnimatorStatePlayData(state) and removes the dead null branches. No behavior change. 53 Animator tests still pass — they already cover the lazy create, identity rebuild, listener accumulation prevention, and destroy-time detach paths. --- packages/core/src/animation/Animator.ts | 14 +++----------- .../src/animation/internal/AnimatorStateData.ts | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index dbdc2f904c..1c3ec861e6 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -338,11 +338,7 @@ export class Animator extends Component { const stateDataMap = layersData[i]?.animatorStateDataMap; if (!stateDataMap) continue; for (const stateName in stateDataMap) { - const stateData = stateDataMap[stateName]; - const { state, clipChangedListener } = stateData; - if (state && clipChangedListener) { - state._updateFlagManager.removeListener(clipChangedListener); - } + stateDataMap[stateName].dispose(); } } @@ -442,15 +438,11 @@ export class Animator extends Component { if (animatorStateData && animatorStateData.state !== animatorState) { // Same name but different state instance (e.g. removeState + addState same name): // detach the old listener and rebuild stateData against the new state. - const { state: previousState, clipChangedListener } = animatorStateData; - if (previousState && clipChangedListener) { - previousState._updateFlagManager.removeListener(clipChangedListener); - } + animatorStateData.dispose(); animatorStateData = null; } if (!animatorStateData) { - animatorStateData = new AnimatorStateData(); - animatorStateData.state = animatorState; + animatorStateData = new AnimatorStateData(animatorState); animatorStateDataMap[stateName] = animatorStateData; this._saveAnimatorStateData(animatorState, animatorStateData, animatorLayerData, layerIndex); this._saveAnimatorEventHandlers(animatorState, animatorStateData); diff --git a/packages/core/src/animation/internal/AnimatorStateData.ts b/packages/core/src/animation/internal/AnimatorStateData.ts index 6625cb4b54..45e2099eb0 100644 --- a/packages/core/src/animation/internal/AnimatorStateData.ts +++ b/packages/core/src/animation/internal/AnimatorStateData.ts @@ -6,10 +6,19 @@ import { AnimationEventHandler } from "./AnimationEventHandler"; * @internal */ export class AnimatorStateData { - /** Source state this cached data was built against; used to detect identity change after remove/re-add. */ - state: AnimatorState | null = null; - /** Listener registered on `state._updateFlagManager`; kept so we can detach when stateData is rebuilt. */ + /** Listener registered on `state._updateFlagManager`; kept so dispose() can detach it. */ clipChangedListener: (() => void) | null = null; curveLayerOwner: AnimationCurveLayerOwner[] = []; eventHandlers: AnimationEventHandler[] = []; + + constructor(readonly state: AnimatorState) {} + + /** Detach the clipChangedListener from state's UpdateFlagManager. No-op if not attached. */ + dispose(): void { + const { clipChangedListener } = this; + if (clipChangedListener) { + this.state._updateFlagManager.removeListener(clipChangedListener); + this.clipChangedListener = null; + } + } } From aed9e10b429778a66da254c46d262cb7bcfbc3f0 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 11:33:01 +0800 Subject: [PATCH 46/92] docs(animation): document getCurrentAnimatorState nullable return The signature on this PR became `AnimatorState | null` (it returns `_animatorLayersData[layerIndex]?.srcPlayData?.state ?? null`), but the 2.0 breaking-change summary only called out `findAnimatorState()`. Update JSDoc + zh/en animator.mdx to spell out the two null cases (missing layer / no state currently playing) and switch the snippet to the if-guarded form so callers see the right pattern. --- docs/en/animation/animator.mdx | 12 +++++++----- docs/zh/animation/animator.mdx | 12 +++++++----- packages/core/src/animation/Animator.ts | 3 ++- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/en/animation/animator.mdx b/docs/en/animation/animator.mdx index ef04e5212a..556c11eb04 100644 --- a/docs/en/animation/animator.mdx +++ b/docs/en/animation/animator.mdx @@ -141,14 +141,16 @@ animator.crossFade("OtherStateName", 0.3); ### Get Current Playing Animation State -You can use the [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) method to get the currently playing `AnimatorState` . The parameter is the index `layerIndex` of the `AnimatorControllerLayer` where the `AnimatorState` is located. For details, see the [API documentation](/apis/core/#Animator-getCurrentAnimatorState). After obtaining it, you can set the properties of the `AnimatorState` , such as changing the default loop playback to play once. +You can use the [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) method to get the currently playing `AnimatorState` . The parameter is the index `layerIndex` of the `AnimatorControllerLayer` where the `AnimatorState` is located. For details, see the [API documentation](/apis/core/#Animator-getCurrentAnimatorState). The return type is `AnimatorState | null` — `null` is returned when the specified layer doesn't exist or no state is currently playing on it. Once you obtain a non-null state you can set its properties, such as changing the default loop playback to play once. ```typescript const currentState = animator.getCurrentAnimatorState(0); -// Play once -currentState.wrapMode = WrapMode.Once; -// Loop playback -currentState.wrapMode = WrapMode.Loop; +if (currentState) { + // Play once + currentState.wrapMode = WrapMode.Once; + // Loop playback + currentState.wrapMode = WrapMode.Loop; +} ``` ### Get Animation State diff --git a/docs/zh/animation/animator.mdx b/docs/zh/animation/animator.mdx index 5b93ef42a2..b3964fd146 100644 --- a/docs/zh/animation/animator.mdx +++ b/docs/zh/animation/animator.mdx @@ -146,14 +146,16 @@ animator.crossFade("OtherStateName", 0.3); ### 获取当前在播放的动画状态 -你可以使用 [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState)  方法来获取当前正在播放的 `动画状态`。参数为 `动画状态` 所在 `动画层` 的序号`layerIndex`, 详见[API 文档](/apis/core/#Animator-getCurrentAnimatorState)。获取之后可以设置 `动画状态` 的属性,比如将默认的循环播放改为一次。 +你可以使用 [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState)  方法来获取当前正在播放的 `动画状态`。参数为 `动画状态` 所在 `动画层` 的序号`layerIndex`, 详见[API 文档](/apis/core/#Animator-getCurrentAnimatorState)。返回类型为 `AnimatorState | null`,当指定 `动画层` 不存在或该层当前没有播放任何状态时返回 `null`。获取到非空状态后可以设置其属性,比如将默认的循环播放改为一次。 ```typescript const currentState = animator.getCurrentAnimatorState(0); -// 播放一次 -currentState.wrapMode = WrapMode.Once; -// 循环播放 -currentState.wrapMode = WrapMode.Loop; +if (currentState) { + // 播放一次 + currentState.wrapMode = WrapMode.Once; + // 循环播放 + currentState.wrapMode = WrapMode.Loop; +} ``` ### 获取动画状态 diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 1c3ec861e6..6478934297 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -206,8 +206,9 @@ export class Animator extends Component { } /** - * Get the playing state from the target layerIndex. + * Get the current playing state from the target layer. * @param layerIndex - The layer index + * @returns The currently playing AnimatorState, or null if the layer is missing or no state is playing */ getCurrentAnimatorState(layerIndex: number): AnimatorState | null { return this._animatorLayersData[layerIndex]?.srcPlayData?.state ?? null; From 88ff930d54ade0953bf22d8e053106261c296412 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 13:41:52 +0800 Subject: [PATCH 47/92] docs(loader): warn against Skin-await-Scene cycle next to Scene-before-Skin order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strengthens the inline comment so a future "let's make the dependency explicit" refactor doesn't fall into the obvious trap of awaiting context.get(Scene) inside Skin. GLTFSceneParser._createRenderer requests context.get(Skin) for skinned renderers, so Skin → full Scene → Skin would deadlock the cached promise. The correct long-term path is exposing scene root wrapper creation as its own synchronous phase (e.g. ensureSceneRootsCreated()) that Skin can pull on demand without awaiting the entire Scene parse — but that's a loader-pipeline refactor for a follow-up PR, not this one. --- packages/loader/src/gltf/parser/GLTFParserContext.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/loader/src/gltf/parser/GLTFParserContext.ts b/packages/loader/src/gltf/parser/GLTFParserContext.ts index 866a4b7552..152d78fd8a 100644 --- a/packages/loader/src/gltf/parser/GLTFParserContext.ts +++ b/packages/loader/src/gltf/parser/GLTFParserContext.ts @@ -114,10 +114,14 @@ export class GLTFParserContext { this.glTF = json; this.needAnimatorController = !!(json.skins || json.animations); + // Scene-before-Skin parse order + // + // Skin._findSceneRootBone reads glTFResource._sceneRoots, populated + // synchronously by Scene's parse head. Do not reverse — Scene's async + // tail awaits Skin via _createRenderer for skinned renderers, so a + // "Skin awaits Scene" rewrite would deadlock on the cached promise. return AssetPromise.all([ this.get(GLTFParserType.Validator), - // Scene must be requested before Skin: GLTFSceneParser populates - // glTFResource._sceneRoots synchronously, which GLTFSkinParser._findSceneRootBone reads. this.get(GLTFParserType.Scene), this.get(GLTFParserType.Texture), this.get(GLTFParserType.Material), From 6230dca4d68401801e55edf2277c5f4e412bf674 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 16:07:06 +0800 Subject: [PATCH 48/92] refactor(loader): drop _findSceneRootBone, resolve skin rootBone via LCA only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _findSceneRootBone was a fast-path classifier introduced in 9974c5bbf to handle multi-root scenes where joints span multiple top-level scene nodes — it short-circuited those cases to return the GLTF_ROOT wrapper. But c3d2160c3 (already on the branch) made GLTFSceneParser create the wrapper unconditionally and attach every top-level scene node under it. With that invariant, joint parent chains always include the wrapper at the top, so the existing LCA algorithm in _findSkeletonRootBone is already correct for both cases: Multi-root spanning: joints share only GLTF_ROOT → LCA returns wrapper. Single-root / converged: joints share a deeper ancestor → LCA returns the actual skeleton root, not the wrapper. The two-function ?? chain was masquerading a dispatch (case selection) as a fallback (try-then-retry), forcing readers into both helpers to understand that null from _findSceneRootBone meant "not my case" rather than "lookup failed". Removing it leaves a single, uniform rule: rootBone = LCA(joints). Changes: - Drop _findSceneRootBone (~50 lines) - Rename _findSkeletonRootBone to _findSkeletonRootBoneByLCA so the algorithm is self-describing - Tighten return type to Entity | null (rootNode starts as null and the function can return null when paths don't share any ancestor) - Simplify the call site to one path; the comment now states the invariant rather than guarding against a phantom fallback Existing GLTFLoader tests cover both branches and still pass: - "Multi-root skins without skeleton should use the scene wrapper as rootBone" verifies LCA → wrapper for the spanning case - "Multi-root scenes whose joints converge to a single top-level root should not use the scene wrapper" verifies LCA → Character_Root for the converged case --- .../loader/src/gltf/parser/GLTFSkinParser.ts | 74 +++---------------- 1 file changed, 12 insertions(+), 62 deletions(-) diff --git a/packages/loader/src/gltf/parser/GLTFSkinParser.ts b/packages/loader/src/gltf/parser/GLTFSkinParser.ts index d8a4d3727a..31ee338df9 100644 --- a/packages/loader/src/gltf/parser/GLTFSkinParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSkinParser.ts @@ -36,16 +36,19 @@ export class GLTFSkinParser extends GLTFParser { // Get skeleton if (skeleton !== undefined) { - const rootBone = entities[skeleton]; - skin.rootBone = rootBone; + skin.rootBone = entities[skeleton]; } else { - const rootBone = - this._findSceneRootBone(context, joints, entities) ?? this._findSkeletonRootBone(joints, entities); - if (rootBone) { - skin.rootBone = rootBone; - } else { + // Resolve rootBone from the joints' lowest common ancestor. + // + // Multi-root scenes are not a special case: GLTFSceneParser unconditionally + // attaches every top-level node under a GLTF_ROOT wrapper, so when joints + // span multiple top-level scene nodes, their LCA is naturally the wrapper. + // When joints converge in one branch, the LCA is the actual skeleton root. + const rootBone = this._findSkeletonRootBoneByLCA(joints, entities); + if (!rootBone) { throw "Failed to find skeleton root bone."; } + skin.rootBone = rootBone; } return skin; @@ -54,60 +57,7 @@ export class GLTFSkinParser extends GLTFParser { return AssetPromise.resolve(skinPromise); } - private _findSceneRootBone(context: GLTFParserContext, joints: number[], entities: Entity[]): Entity | null { - const { glTF, glTFResource } = context; - const scenes = glTF.scenes; - const sceneRoots = glTFResource._sceneRoots; - - if (!scenes?.length || !sceneRoots?.length) { - return null; - } - - for (let i = 0, n = scenes.length; i < n; i++) { - const sceneNodes = scenes[i].nodes ?? []; - if (sceneNodes.length <= 1) { - continue; - } - - const sceneRoot = sceneRoots[i]; - if (!sceneRoot) { - continue; - } - - let firstTopLevelRoot: Entity = null; - let allUnderSceneRoot = true; - - for (let j = 0, m = joints.length; j < m; j++) { - let entity = entities[joints[j]]; - - // Walk up to the direct child of sceneRoot - while (entity?.parent && entity.parent !== sceneRoot) { - entity = entity.parent; - } - - if (entity?.parent !== sceneRoot) { - allUnderSceneRoot = false; - break; - } - - if (firstTopLevelRoot === null) { - firstTopLevelRoot = entity; - } else if (entity !== firstTopLevelRoot) { - // joints span >1 top-level roots → wrapper is the right rootBone - return sceneRoot; - } - } - - if (!allUnderSceneRoot) { - continue; - } - // joints converged to a single top-level root → fall through to skeleton LCA - } - - return null; - } - - private _findSkeletonRootBone(joints: number[], entities: Entity[]): Entity { + private _findSkeletonRootBoneByLCA(joints: number[], entities: Entity[]): Entity | null { const paths = >{}; for (const index of joints) { const path = new Array(); @@ -119,7 +69,7 @@ export class GLTFSkinParser extends GLTFParser { paths[index] = path; } - let rootNode = null; + let rootNode: Entity | null = null; for (let i = 0; ; i++) { let path = paths[joints[0]]; if (i >= path.length) { From 31e711e090eaec7a5ae8889dc5c2051c21feed7a Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 16:47:19 +0800 Subject: [PATCH 49/92] docs(loader): update Scene-before-Skin comment after LCA unification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0623cb93a deleted _findSceneRootBone and made the Skin parser resolve rootBone via joint parent-chain LCA only. The previous comment still referenced both _findSceneRootBone and the "Skin reads _sceneRoots" mechanism, neither of which is true anymore. Restate the actual invariant: LCA needs the GLTF_ROOT wrapper present in joint parent chains, which Scene's parse head provides synchronously when it attaches top-level nodes under the wrapper. The cycle warning against "Skin awaits full Scene" stays — Scene's async tail still requests Skin via _createRenderer. --- .../loader/src/gltf/parser/GLTFParserContext.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/loader/src/gltf/parser/GLTFParserContext.ts b/packages/loader/src/gltf/parser/GLTFParserContext.ts index 152d78fd8a..ef9f29bec8 100644 --- a/packages/loader/src/gltf/parser/GLTFParserContext.ts +++ b/packages/loader/src/gltf/parser/GLTFParserContext.ts @@ -116,10 +116,15 @@ export class GLTFParserContext { // Scene-before-Skin parse order // - // Skin._findSceneRootBone reads glTFResource._sceneRoots, populated - // synchronously by Scene's parse head. Do not reverse — Scene's async - // tail awaits Skin via _createRenderer for skinned renderers, so a - // "Skin awaits Scene" rewrite would deadlock on the cached promise. + // Skin rootBone resolution walks joint parent chains and computes the + // joints' lowest common ancestor. Scene's parse head must run first + // because it synchronously attaches top-level scene nodes under the + // GLTF_ROOT wrapper; when joints span multiple top-level scene nodes + // that wrapper naturally becomes the LCA. + // + // Do not rewrite Skin to await full Scene: Scene's async tail can + // request Skin via _createRenderer for skinned renderers, which would + // deadlock on the cached promise. return AssetPromise.all([ this.get(GLTFParserType.Validator), this.get(GLTFParserType.Scene), From 82026eeaf087e115a3d58a64dac8fa0978af8a4e Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 17:11:48 +0800 Subject: [PATCH 50/92] refactor(animation): rename _speedOverride to _speed after dropping override mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After d0f3c35d6 removed clearSpeedOverride(), the "override mode" two- state concept stopped existing — speed is now simply a per-instance value that live-binds to state.speed until written. The internal _speedOverride field name still implied the old "claims override, can be cleared" semantics. Rename to _speed and clean up "override" wording from the doc header, resetForPlay comment, and five test names / inline comments so the source matches the actual model. Behavior unchanged. All 53 Animator tests still pass. --- packages/core/src/animation/AnimatorStatePlayData.ts | 10 +++++----- tests/src/core/Animator.test.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/core/src/animation/AnimatorStatePlayData.ts b/packages/core/src/animation/AnimatorStatePlayData.ts index 7fccae55b8..af5acd0be6 100644 --- a/packages/core/src/animation/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/AnimatorStatePlayData.ts @@ -8,7 +8,7 @@ import { AnimatorStateData } from "./internal/AnimatorStateData"; * * Lifecycle: created lazily by AnimatorLayerData.getOrCreatePlayData on first access * (either via Animator.findAnimatorState or when the state begins playing). Persists - * for the layer's lifetime, so per-instance overrides (e.g. speed) survive transitions + * for the layer's lifetime, so per-instance state (e.g. speed) survives transitions * out of and back into the state. * * Public surface is intentionally narrow: @@ -40,7 +40,7 @@ export class AnimatorStatePlayData { /** @internal */ _offsetFrameTime: number = 0; - private _speedOverride: number | undefined; + private _speed: number | undefined; private _changedOrientation = false; /** @@ -52,11 +52,11 @@ export class AnimatorStatePlayData { * Per-instance value persists across state transitions. */ get speed(): number { - return this._speedOverride ?? this.state.speed; + return this._speed ?? this.state.speed; } set speed(value: number) { - this._speedOverride = value; + this._speed = value; } /** @internal */ @@ -66,7 +66,7 @@ export class AnimatorStatePlayData { /** * @internal - * Reset runtime fields when (re-)entering this state. Does NOT touch user overrides. + * Reset runtime fields when (re-)entering this state. Does NOT touch user-written per-instance values (e.g. speed). */ resetForPlay(stateData: AnimatorStateData, offsetFrameTime: number): void { this._stateData = stateData; diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 4e1b9305cc..bd4e2c9fa0 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1327,7 +1327,7 @@ describe("Animator test", function () { expect(cloneAnimator.findAnimatorState("Survey")).to.eq(survey); }); - it("speed override set before play applies on first play", () => { + it("per-instance speed set before play applies on first play", () => { const handle = animator.findAnimatorState("Survey"); handle.speed = 0.5; animator.play("Survey"); @@ -1340,7 +1340,7 @@ describe("Animator test", function () { expect(handle.speed).to.eq(0.5); }); - it("speed override survives crossFade out and back", () => { + it("per-instance speed survives crossFade out and back", () => { animator.findAnimatorState("Survey").speed = 0.5; animator.play("Survey"); // @ts-ignore @@ -1365,7 +1365,7 @@ describe("Animator test", function () { expect(srcPlayData.speed).to.eq(0.5); }); - it("speed override is per-Animator (clone isolation)", () => { + 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); @@ -1380,7 +1380,7 @@ describe("Animator test", function () { }); it("crossFade phase uses playData.speed for time progression", () => { - // Set high override speed on src state + // 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 @@ -1740,7 +1740,7 @@ describe("Animator test", function () { expect(animator._animatorLayersData[99]).to.eq(undefined); }); - it("transition out of a state with speed override 0 does not produce NaN", () => { + 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"); From 35a3147e71ff440771ca5a51b2a9c44e897bab37 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 17:19:30 +0800 Subject: [PATCH 51/92] refactor(animation): extract _resetIfControllerUpdated helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same three lines — if (this._controllerUpdateFlag?.flag) { this._reset(); } — guarded entry into play(), update(), findAnimatorState(), and _crossFade() to drop stale layer data after a controller mutation. Pull them into a single private helper so the intent is named once and the call sites read as one statement. No behavior change. All 53 Animator tests still pass. --- packages/core/src/animation/Animator.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 6478934297..6640311825 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -115,9 +115,7 @@ export class Animator extends Component { * @param normalizedTimeOffset - The normalized time offset (between 0 and 1, default 0) to start the state's animation from */ play(stateName: string, layerIndex: number = -1, normalizedTimeOffset: number = 0): void { - if (this._controllerUpdateFlag?.flag) { - this._reset(); - } + this._resetIfControllerUpdated(); const stateInfo = this._getAnimatorStateInfo(stateName, layerIndex); const { state } = stateInfo; @@ -192,9 +190,7 @@ export class Animator extends Component { return; } - if (this._controllerUpdateFlag?.flag) { - this._reset(); - } + this._resetIfControllerUpdated(); this._updateMark++; @@ -224,9 +220,7 @@ export class Animator extends Component { * @returns Per-instance AnimatorStatePlayData, or null if no state matches */ findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData | null { - if (this._controllerUpdateFlag?.flag) { - this._reset(); - } + this._resetIfControllerUpdated(); const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); if (!state || foundLayer < 0) return null; return this._getAnimatorLayerData(foundLayer).getOrCreatePlayData(state); @@ -353,6 +347,12 @@ export class Animator extends Component { } } + private _resetIfControllerUpdated(): void { + if (this._controllerUpdateFlag?.flag) { + this._reset(); + } + } + /** * @internal */ @@ -384,9 +384,7 @@ export class Animator extends Component { normalizedTimeOffset: number, isFixedDuration: boolean ): void { - if (this._controllerUpdateFlag?.flag) { - this._reset(); - } + this._resetIfControllerUpdated(); const { state, layerIndex: playLayerIndex } = this._getAnimatorStateInfo(stateName, layerIndex); if (!state || playLayerIndex < 0) { From 479a8be8b8025d481676810838820c2618a79545 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 17:55:49 +0800 Subject: [PATCH 52/92] docs(animation): tighten AnimatorStatePlayData class doc and explain _correctTime Two doc-only polish points from a second-pass review: - The top-level class JSDoc duplicated the `speed` getter's full semantics paragraph. Shorten the class doc to a one-line surface pointer ("see the `speed` getter for live-bind semantics"), so the detailed read/write behavior is documented once at the getter. - `_correctTime` reads as "correct what?" without tracing both call sites. Add a one-line comment explaining that reverse playback resumed at clipTime=0 would step into negatives, so we snap to clipEnd to keep the reverse loop seamless. No code or behavior change. 53 Animator tests still pass. --- packages/core/src/animation/AnimatorStatePlayData.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/animation/AnimatorStatePlayData.ts b/packages/core/src/animation/AnimatorStatePlayData.ts index af5acd0be6..41bea00a14 100644 --- a/packages/core/src/animation/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/AnimatorStatePlayData.ts @@ -13,9 +13,7 @@ import { AnimatorStateData } from "./internal/AnimatorStateData"; * * Public surface is intentionally narrow: * - `state`: the shared AnimatorState asset (read-only). - * - `speed`: per-instance speed. Reads live-bind to `state.speed` until a value is - * assigned, after which the instance owns its own speed and asset changes no longer - * affect it. Write a fresh value (or `playData.state.speed`) to update it again. + * - `speed`: per-instance playback speed (see the `speed` getter for live-bind semantics). * * All other fields are engine-managed runtime state and are underscore-prefixed to * mark them as implementation detail; mutating them from user code will corrupt @@ -119,6 +117,8 @@ export class AnimatorStatePlayData { private _correctTime() { const { state } = this; + // Reverse playback resumed at clipTime=0 would step into negatives; jump to + // clipEnd so the next sample continues seamlessly from the end of the clip. if (this._clipTime === 0) { this._clipTime = state.clipEndTime * state.clip.length; } From 4457f62eff65fa68dbea972c2b92a2f8a8047905 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 11 May 2026 19:37:53 +0800 Subject: [PATCH 53/92] docs(loader): explain why _sceneRoots[i] is written synchronously 9974c5bbf added a synchronous (glTFResource._sceneRoots ||= [])[index] = sceneRoot write inside GLTFSceneParser.parse. Its original consumer (the now-deleted _findSceneRootBone in GLTFSkinParser, removed in 0623cb93a) is gone, so the line looks redundant against the asynchronous _handleSubAsset path that the glTFResourceMap[Scene] = "_sceneRoots" mapping triggers. It is not redundant: dev/2.0 already writes _defaultSceneRoot synchronously here, while _sceneRoots[i] would only be filled in Scene's async tail through _handleSubAsset. The two wrapper-index fields would observe different visibility timings, and any same-tick reader (e.g. a future synchronous parser consumer or editor inspector) could see _defaultSceneRoot set while _sceneRoots[i] is still undefined. Add an inline comment so a future cleanup pass doesn't delete this line under the wrong assumption that _handleSubAsset already covers it. No behavior change. GLTF loader tests still pass. --- packages/loader/src/gltf/parser/GLTFSceneParser.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/loader/src/gltf/parser/GLTFSceneParser.ts b/packages/loader/src/gltf/parser/GLTFSceneParser.ts index b74424d2e6..2187673d37 100644 --- a/packages/loader/src/gltf/parser/GLTFSceneParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSceneParser.ts @@ -38,6 +38,11 @@ export class GLTFSceneParser extends GLTFParser { sceneRoot.addChild(context.get(GLTFParserType.Entity, sceneNodes[i])); } + // Mirror _defaultSceneRoot's synchronous write so _sceneRoots[i] is visible + // in the same tick. _handleSubAsset would otherwise fill it asynchronously + // via the glTFResourceMap path after Scene's promise resolves — looks like + // a duplicate write, but it aligns the two wrapper-index fields so callers + // never observe one as set and the other still undefined. (glTFResource._sceneRoots ||= [])[index] = sceneRoot; if (isDefaultScene) { glTFResource._defaultSceneRoot = sceneRoot; From 31344e576e0c761d37b3aee2680dd62d273dd2d2 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 14 May 2026 21:21:27 +0800 Subject: [PATCH 54/92] docs(loader): tighten skin rootBone resolution comments - Compress the caller comment to two lines that state the trigger and the precise condition under which LCA falls back to the GLTF_ROOT wrapper (only when joints span multiple top-level scene nodes; joints spanning sibling armatures under the same top-level still resolve to a non-wrapper node). - Add a brief JSDoc on _findSkeletonRootBoneByLCA covering algorithm and null-return semantics; the cross-parser wrapper invariant stays in the caller comment where it is consumed. --- packages/loader/src/gltf/parser/GLTFSkinParser.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/loader/src/gltf/parser/GLTFSkinParser.ts b/packages/loader/src/gltf/parser/GLTFSkinParser.ts index 31ee338df9..2201ccdcac 100644 --- a/packages/loader/src/gltf/parser/GLTFSkinParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSkinParser.ts @@ -34,16 +34,11 @@ export class GLTFSkinParser extends GLTFParser { } skin.bones = bones; - // Get skeleton + // Get skeleton — when `skin.skeleton` is absent, resolve via joints' LCA + // LCA falls back to the GLTF_ROOT wrapper only when joints span multiple top-level scene nodes if (skeleton !== undefined) { skin.rootBone = entities[skeleton]; } else { - // Resolve rootBone from the joints' lowest common ancestor. - // - // Multi-root scenes are not a special case: GLTFSceneParser unconditionally - // attaches every top-level node under a GLTF_ROOT wrapper, so when joints - // span multiple top-level scene nodes, their LCA is naturally the wrapper. - // When joints converge in one branch, the LCA is the actual skeleton root. const rootBone = this._findSkeletonRootBoneByLCA(joints, entities); if (!rootBone) { throw "Failed to find skeleton root bone."; @@ -57,6 +52,10 @@ export class GLTFSkinParser extends GLTFParser { return AssetPromise.resolve(skinPromise); } + /** + * Resolve the skeleton rootBone as the lowest common ancestor of the joints' parent chains. + * Returns null when joints share no common ancestor. + */ private _findSkeletonRootBoneByLCA(joints: number[], entities: Entity[]): Entity | null { const paths = >{}; for (const index of joints) { From 7882a1f494cd6d2a3674794222d804f0f8379a12 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 14 May 2026 22:52:44 +0800 Subject: [PATCH 55/92] revert(loader): drop orphan _sceneRoots[i] synchronous write The synchronous write was introduced together with _findSceneRootBone, the only same-tick consumer that needed _sceneRoots[i] visible during Skin parse. Once _findSceneRootBone was deleted in favor of the LCA-only resolution, this write lost its consumer. _findSkeletonRootBoneByLCA reads entity.parent, not _sceneRoots, so the same-tick alignment with _defaultSceneRoot is no longer load-bearing. _handleSubAsset still writes _sceneRoots[i] asynchronously after Scene resolves, which is what every post-load reader (instantiateSceneRoot, sceneRoots getter) actually consumes. --- packages/loader/src/gltf/parser/GLTFSceneParser.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/loader/src/gltf/parser/GLTFSceneParser.ts b/packages/loader/src/gltf/parser/GLTFSceneParser.ts index 2187673d37..5ee981b1d3 100644 --- a/packages/loader/src/gltf/parser/GLTFSceneParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSceneParser.ts @@ -38,12 +38,6 @@ export class GLTFSceneParser extends GLTFParser { sceneRoot.addChild(context.get(GLTFParserType.Entity, sceneNodes[i])); } - // Mirror _defaultSceneRoot's synchronous write so _sceneRoots[i] is visible - // in the same tick. _handleSubAsset would otherwise fill it asynchronously - // via the glTFResourceMap path after Scene's promise resolves — looks like - // a duplicate write, but it aligns the two wrapper-index fields so callers - // never observe one as set and the other still undefined. - (glTFResource._sceneRoots ||= [])[index] = sceneRoot; if (isDefaultScene) { glTFResource._defaultSceneRoot = sceneRoot; } From b109a42c6455157d68deb4ca39e89aaf47f309b6 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 14 May 2026 23:08:33 +0800 Subject: [PATCH 56/92] docs(loader): tighten skin rootBone resolution comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the 13-line Scene-before-Skin justification. The array position already encodes the data-flow direction (Scene precedes Skin), and at runtime the ordering is enforced by JS microtask semantics regardless of array index — Scene.parse attaches GLTF_ROOT synchronously inside AssetPromise.all, and Skin's LCA runs inside getAccessorBuffer().then, so sync always precedes async within the same batch. Keep the reorder for readability and for safety margin against future synchronous LCA refactors; remove the prose that over-stated the current invariant as load-bearing. --- packages/loader/src/gltf/parser/GLTFParserContext.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/loader/src/gltf/parser/GLTFParserContext.ts b/packages/loader/src/gltf/parser/GLTFParserContext.ts index ef9f29bec8..b549da5ca2 100644 --- a/packages/loader/src/gltf/parser/GLTFParserContext.ts +++ b/packages/loader/src/gltf/parser/GLTFParserContext.ts @@ -114,17 +114,6 @@ export class GLTFParserContext { this.glTF = json; this.needAnimatorController = !!(json.skins || json.animations); - // Scene-before-Skin parse order - // - // Skin rootBone resolution walks joint parent chains and computes the - // joints' lowest common ancestor. Scene's parse head must run first - // because it synchronously attaches top-level scene nodes under the - // GLTF_ROOT wrapper; when joints span multiple top-level scene nodes - // that wrapper naturally becomes the LCA. - // - // Do not rewrite Skin to await full Scene: Scene's async tail can - // request Skin via _createRenderer for skinned renderers, which would - // deadlock on the cached promise. return AssetPromise.all([ this.get(GLTFParserType.Validator), this.get(GLTFParserType.Scene), From ddcb9eeccce172c26a7e463d5f0a4c0535a80db8 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 14 May 2026 23:10:23 +0800 Subject: [PATCH 57/92] refactor(loader): revert _findSkeletonRootBone rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the ByLCA suffix added earlier. Same-module private helpers (_computeLocalBounds, _createRenderer, _parseEntityComponent) all use intent-only naming with no algorithm in the identifier; ByLCA is the sole exception. The algorithm name lives in the JSDoc, which is the right place — it documents the current implementation choice without making the algorithm part of the function's contract. Callers care about "find rootBone", not "find rootBone by which algorithm". --- packages/loader/src/gltf/parser/GLTFSkinParser.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/loader/src/gltf/parser/GLTFSkinParser.ts b/packages/loader/src/gltf/parser/GLTFSkinParser.ts index 2201ccdcac..ed67758c83 100644 --- a/packages/loader/src/gltf/parser/GLTFSkinParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSkinParser.ts @@ -39,7 +39,7 @@ export class GLTFSkinParser extends GLTFParser { if (skeleton !== undefined) { skin.rootBone = entities[skeleton]; } else { - const rootBone = this._findSkeletonRootBoneByLCA(joints, entities); + const rootBone = this._findSkeletonRootBone(joints, entities); if (!rootBone) { throw "Failed to find skeleton root bone."; } @@ -56,7 +56,7 @@ export class GLTFSkinParser extends GLTFParser { * Resolve the skeleton rootBone as the lowest common ancestor of the joints' parent chains. * Returns null when joints share no common ancestor. */ - private _findSkeletonRootBoneByLCA(joints: number[], entities: Entity[]): Entity | null { + private _findSkeletonRootBone(joints: number[], entities: Entity[]): Entity | null { const paths = >{}; for (const index of joints) { const path = new Array(); From efa4795e430636f720de4f3a67d24d0d0a17bd06 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Thu, 14 May 2026 23:20:30 +0800 Subject: [PATCH 58/92] docs(entity): fix stale @returns text on getChild and findByName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same template error as the findByPath JSDoc fix in this PR — three sibling methods originally carried "@returns The component which be found", which is wrong on both axes: the return type is Entity not Component, and the phrasing is ungrammatical. Bring all three into sync. Also fix the findByName @param ("The name of the entity which want to be found") and the stray tab character on getChild's @returns line. --- packages/core/src/Entity.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 7f025f321a..010931a74f 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -341,7 +341,7 @@ export class Entity extends EngineObject { * @deprecated Please use `children` property instead. * Find child entity by index. * @param index - The index of the child entity - * @returns The component which be found + * @returns The entity that was found */ getChild(index: number): Entity { return this._children[index]; @@ -349,8 +349,8 @@ export class Entity extends EngineObject { /** * Find entity by name. - * @param name - The name of the entity which want to be found - * @returns The component which be found + * @param name - The name of the entity to find + * @returns The entity that was found */ findByName(name: string): Entity { if (name === this.name) { From fe78f5ecb3d696ce31c7967f6786fa075973aafc Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 15:43:23 +0800 Subject: [PATCH 59/92] refactor(animation): split AnimatorState into asset (Def) and per-instance view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building on the per-state PlayData handle introduced earlier in this PR, this completes the design by separating two concerns that were tangled in AnimatorStatePlayData: 1. The shared AnimatorController-owned configuration (former AnimatorState). 2. The per-Animator playback view that users get from findAnimatorState. 3. The engine-internal runtime tracking (playedTime, clipTime, etc.). Type layout: - AnimatorStateDef (renamed from AnimatorState): shared asset on the controller. Returned by AnimatorStateMachine.findStateByName / addState / states; carried on AnimatorStateTransition.destinationState; consumed by AnimatorStateMachineScript callbacks. Free to mutate during editing / asset construction — runtime users shouldn't touch it. - AnimatorState (new public type): per-Animator playback view that users get from Animator.findAnimatorState. Backed by a stable handle per (Animator, AnimatorStateDef) pair. Exposes: - `def`: shared asset back-reference (read-only). - `name`, `clip`, `wrapMode`, `clipStartTime`, `clipEndTime`: readonly forwards from `def`. - `speed`: per-instance override (live-binds to `def.speed` until written, then claims ownership; survives transitions). Writes here only affect this Animator. - AnimatorStateRuntime (internal): the engine-owned bookkeeping previously held in AnimatorStatePlayData's underscore-prefixed fields. No longer exposed; underscores dropped. API: - findAnimatorState / getCurrentAnimatorState now return AnimatorState (the view), not the def. - No `findAnimatorStateInstance` is needed: Animator is itself a per-component instance, so the view returned by findAnimatorState is naturally per-instance. - Mutating asset-level config still has a path: walk through controller -> layer -> stateMachine.findStateByName, which returns AnimatorStateDef. The longer path is a visual cue that the change is broadcast to every Animator using this controller. Removes packages/core/src/animation/AnimatorStatePlayData.ts (was the intermediate handle from the earlier commits in this PR) and the playData.state escape hatch that let users accidentally mutate the shared asset through the per-instance handle. --- packages/core/src/animation/Animator.ts | 281 +++++++++--------- packages/core/src/animation/AnimatorState.ts | 270 +++-------------- .../core/src/animation/AnimatorStateDef.ts | 260 ++++++++++++++++ .../src/animation/AnimatorStateMachine.ts | 28 +- .../src/animation/AnimatorStatePlayData.ts | 126 -------- .../src/animation/AnimatorStateTransition.ts | 4 +- .../AnimatorStateTransitionCollection.ts | 6 +- .../core/src/animation/StateMachineScript.ts | 10 +- packages/core/src/animation/index.ts | 2 +- .../animation/internal/AnimatorLayerData.ts | 44 +-- .../animation/internal/AnimatorStateData.ts | 4 +- .../internal/AnimatorStateRuntime.ts | 97 ++++++ .../loader/src/AnimatorControllerLoader.ts | 9 +- .../parser/GLTFAnimatorControllerParser.ts | 2 +- 14 files changed, 606 insertions(+), 537 deletions(-) create mode 100644 packages/core/src/animation/AnimatorStateDef.ts delete mode 100644 packages/core/src/animation/AnimatorStatePlayData.ts create mode 100644 packages/core/src/animation/internal/AnimatorStateRuntime.ts diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 6640311825..9a47d1aa83 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -23,7 +23,8 @@ import { AnimationCurveLayerOwner } from "./internal/AnimationCurveLayerOwner"; import { AnimationEventHandler } from "./internal/AnimationEventHandler"; import { AnimatorLayerData } from "./internal/AnimatorLayerData"; import { AnimatorStateData } from "./internal/AnimatorStateData"; -import { AnimatorStatePlayData } from "./AnimatorStatePlayData"; +import { AnimatorStateDef } from "./AnimatorStateDef"; +import { AnimatorStateRuntime } from "./internal/AnimatorStateRuntime"; import { AnimationCurveOwner } from "./internal/animationCurveOwner/AnimationCurveOwner"; /** @@ -202,28 +203,36 @@ export class Animator extends Component { } /** - * Get the current playing state from the target layer. + * Get the per-Animator state view currently playing on the target layer. + * + * Writes on the returned `AnimatorState` (e.g. `state.speed`) only affect + * this Animator; the shared `AnimatorStateDef` asset is untouched. + * * @param layerIndex - The layer index - * @returns The currently playing AnimatorState, or null if the layer is missing or no state is playing + * @returns Per-instance state view, or null if the layer is missing or no state is playing */ getCurrentAnimatorState(layerIndex: number): AnimatorState | null { - return this._animatorLayersData[layerIndex]?.srcPlayData?.state ?? null; + return this._animatorLayersData[layerIndex]?.srcRuntime?.state ?? null; } /** - * Get the per-instance play data handle for a state by name. - * The returned handle persists for the layer's lifetime; modifications to - * `playData.speed` survive state transitions. + * Get or lazily create the per-Animator state view for a named state. + * + * Mirrors the `Renderer.getInstanceMaterial` pattern: the shared + * `AnimatorStateDef` on the controller stays shared, while overrides on the + * returned view (e.g. `state.speed`) only affect this Animator. The returned + * view persists for the layer's lifetime, so overrides survive transitions + * out of and back into the state. * * @param stateName - The state name * @param layerIndex - The layer index (default -1, searches all layers) - * @returns Per-instance AnimatorStatePlayData, or null if no state matches + * @returns Per-instance state view, or null if no state matches */ - findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStatePlayData | null { + findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorState | null { this._resetIfControllerUpdated(); const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); if (!state || foundLayer < 0) return null; - return this._getAnimatorLayerData(foundLayer).getOrCreatePlayData(state); + return this._getAnimatorLayerData(foundLayer).getOrCreateRuntime(state).state; } /** @@ -404,7 +413,7 @@ export class Animator extends Component { private _getAnimatorStateInfo(stateName: string, layerIndex: number): IAnimatorStateInfo { const { _animatorController: animatorController, _tempAnimatorStateInfo: stateInfo } = this; - let state: AnimatorState = null; + let state: AnimatorStateDef = null; if (animatorController) { const layers = animatorController.layers; if (layerIndex === -1) { @@ -428,7 +437,7 @@ export class Animator extends Component { private _getAnimatorStateData( stateName: string, - animatorState: AnimatorState, + animatorState: AnimatorStateDef, animatorLayerData: AnimatorLayerData, layerIndex: number ): AnimatorStateData { @@ -450,7 +459,7 @@ export class Animator extends Component { } private _saveAnimatorStateData( - animatorState: AnimatorState, + animatorState: AnimatorStateDef, animatorStateData: AnimatorStateData, animatorLayerData: AnimatorLayerData, layerIndex: number @@ -502,7 +511,7 @@ export class Animator extends Component { } } - private _saveAnimatorEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void { + private _saveAnimatorEventHandlers(state: AnimatorStateDef, animatorStateData: AnimatorStateData): void { const eventHandlerPool = this._animationEventHandlerPool; const scripts = []; const { eventHandlers } = animatorStateData; @@ -557,8 +566,8 @@ export class Animator extends Component { } private _prepareStandbyCrossFading(animatorLayerData: AnimatorLayerData): void { - // Standby have two sub state, one is never play (srcPlayData is null), one is finished (srcPlayData is non-null) - animatorLayerData.srcPlayData && this._prepareSrcCrossData(animatorLayerData, true); + // Standby have two sub state, one is never play (srcRuntime is null), one is finished (srcRuntime is non-null) + animatorLayerData.srcRuntime && this._prepareSrcCrossData(animatorLayerData, true); // Add dest cross curve data this._prepareDestCrossData(animatorLayerData, true); } @@ -579,7 +588,7 @@ export class Animator extends Component { } private _prepareSrcCrossData(animatorLayerData: AnimatorLayerData, saveFixed: boolean): void { - const { curveLayerOwner } = animatorLayerData.srcPlayData._stateData; + const { curveLayerOwner } = animatorLayerData.srcRuntime.stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; if (!layerOwner) continue; @@ -590,7 +599,7 @@ export class Animator extends Component { } private _prepareDestCrossData(animatorLayerData: AnimatorLayerData, saveFixed: boolean): void { - const { curveLayerOwner } = animatorLayerData.destPlayData._stateData; + const { curveLayerOwner } = animatorLayerData.destRuntime.stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; if (!layerOwner) continue; @@ -649,20 +658,20 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ): void { - const { srcPlayData } = layerData; - const { state } = srcPlayData; + const { srcRuntime } = layerData; + const state = srcRuntime.state.def; - const playSpeed = srcPlayData.speed * this.speed; + const playSpeed = srcRuntime.state.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; - srcPlayData.updateOrientation(playDeltaTime); + srcRuntime.updateOrientation(playDeltaTime); - const { _clipTime: lastClipTime, _playState: lastPlayState } = srcPlayData; + const { clipTime: lastClipTime, playState: lastPlayState } = srcRuntime; // Precalculate to get the transition - srcPlayData.update(playDeltaTime); + srcRuntime.update(playDeltaTime); - const { _clipTime: clipTime, _isForward: isForward } = srcPlayData; + const { clipTime: clipTime, isForward: isForward } = srcRuntime; const { _transitionCollection: transitions } = state; const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine; @@ -671,7 +680,7 @@ export class Animator extends Component { this._applyStateTransitions( layerData, isForward, - srcPlayData, + srcRuntime, anyStateTransitions, lastClipTime, clipTime, @@ -682,7 +691,7 @@ export class Animator extends Component { this._applyStateTransitions( layerData, isForward, - srcPlayData, + srcRuntime, transitions, lastClipTime, clipTime, @@ -716,18 +725,18 @@ export class Animator extends Component { playCostTime = 0; } // Revert actualDeltaTime and update playCostTime - srcPlayData.update(playCostTime - playDeltaTime); + srcRuntime.update(playCostTime - playDeltaTime); } else { playCostTime = playDeltaTime; - if (srcPlayData._playState === AnimatorStatePlayState.Finished) { + if (srcRuntime.playState === AnimatorStatePlayState.Finished) { layerData.layerState = LayerState.Finished; } } - this._evaluatePlayingState(srcPlayData, weight, additive, aniUpdate); + this._evaluatePlayingState(srcRuntime, weight, additive, aniUpdate); this._fireAnimationEventsAndCallScripts( layerData.layerIndex, - srcPlayData, + srcRuntime, state, lastClipTime, lastPlayState, @@ -743,16 +752,16 @@ export class Animator extends Component { } private _evaluatePlayingState( - playData: AnimatorStatePlayData, + runtime: AnimatorStateRuntime, weight: number, additive: boolean, aniUpdate: boolean ): void { - const curveBindings = playData.state.clip._curveBindings; - const finished = playData._playState === AnimatorStatePlayState.Finished; + const curveBindings = runtime.state.clip._curveBindings; + const finished = runtime.playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { - const curveLayerOwner = playData._stateData.curveLayerOwner; + const curveLayerOwner = runtime.stateData.curveLayerOwner; for (let i = curveBindings.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; const owner = layerOwner?.curveOwner; @@ -765,7 +774,7 @@ export class Animator extends Component { if (curve.keys.length) { this._checkRevertOwner(owner, additive); - const value = owner.evaluateValue(curve, playData._clipTime, additive); + const value = owner.evaluateValue(curve, runtime.clipTime, additive); aniUpdate && owner.applyValue(value, weight, additive); finished && layerOwner.saveFinalValue(); } @@ -780,35 +789,35 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ) { - const { srcPlayData, destPlayData, layerIndex } = layerData; + const { srcRuntime, destRuntime, layerIndex } = layerData; const { speed } = this; - const { state: srcState } = srcPlayData; - const { state: destState } = destPlayData; + const srcState = srcRuntime.state.def; + const destState = destRuntime.state.def; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, destState, deltaTime, aniUpdate)) { return; } - const srcPlaySpeed = srcPlayData.speed * speed; - const dstPlaySpeed = destPlayData.speed * speed; + const srcPlaySpeed = srcRuntime.state.speed * speed; + const dstPlaySpeed = destRuntime.state.speed * speed; const dstPlayDeltaTime = dstPlaySpeed * deltaTime; - srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); - destPlayData.updateOrientation(dstPlayDeltaTime); + srcRuntime.updateOrientation(srcPlaySpeed * deltaTime); + destRuntime.updateOrientation(dstPlayDeltaTime); - const { _clipTime: lastSrcClipTime, _playState: lastSrcPlayState } = srcPlayData; - const { _clipTime: lastDestClipTime, _playState: lastDstPlayState } = destPlayData; + const { clipTime: lastSrcClipTime, playState: lastSrcPlayState } = srcRuntime; + const { clipTime: lastDestClipTime, playState: lastDstPlayState } = destRuntime; let dstPlayCostTime: number; - if (destPlayData._isForward) { + if (destRuntime.isForward) { // The time that has been played - const playedTime = destPlayData._playedTime; + const playedTime = destRuntime.playedTime; dstPlayCostTime = playedTime + dstPlayDeltaTime > transitionDuration ? transitionDuration - playedTime : dstPlayDeltaTime; } else { // The time that has been played - const playedTime = destPlayData._playedTime; + const playedTime = destRuntime.playedTime; dstPlayCostTime = // -dstPlayDeltaTime: The time that will be played, negative are meant to make it be a periods // > transition: The time that will be played is enough to finish the transition @@ -822,25 +831,25 @@ export class Animator extends Component { const actualCostTime = dstPlaySpeed === 0 ? deltaTime : dstPlayCostTime / dstPlaySpeed; const srcPlayCostTime = actualCostTime * srcPlaySpeed; - srcPlayData.update(srcPlayCostTime); - destPlayData.update(dstPlayCostTime); + srcRuntime.update(srcPlayCostTime); + destRuntime.update(dstPlayCostTime); - let crossWeight = Math.abs(destPlayData._playedTime) / transitionDuration; + let crossWeight = Math.abs(destRuntime.playedTime) / transitionDuration; (crossWeight >= 1.0 - MathUtil.zeroTolerance || transitionDuration === 0) && (crossWeight = 1.0); const crossFadeFinished = crossWeight === 1.0; if (crossFadeFinished) { - srcPlayData._playState = AnimatorStatePlayState.Finished; + srcRuntime.playState = AnimatorStatePlayState.Finished; this._preparePlayOwner(layerData, destState); - this._evaluatePlayingState(destPlayData, weight, additive, aniUpdate); + this._evaluatePlayingState(destRuntime, weight, additive, aniUpdate); } else { - this._evaluateCrossFadeState(layerData, srcPlayData, destPlayData, weight, crossWeight, additive, aniUpdate); + this._evaluateCrossFadeState(layerData, srcRuntime, destRuntime, weight, crossWeight, additive, aniUpdate); } this._fireAnimationEventsAndCallScripts( layerIndex, - srcPlayData, + srcRuntime, srcState, lastSrcClipTime, lastSrcPlayState, @@ -849,7 +858,7 @@ export class Animator extends Component { this._fireAnimationEventsAndCallScripts( layerIndex, - destPlayData, + destRuntime, destState, lastDestClipTime, lastDstPlayState, @@ -865,19 +874,19 @@ export class Animator extends Component { private _evaluateCrossFadeState( layerData: AnimatorLayerData, - srcPlayData: AnimatorStatePlayData, - destPlayData: AnimatorStatePlayData, + srcRuntime: AnimatorStateRuntime, + destRuntime: AnimatorStateRuntime, weight: number, crossWeight: number, additive: boolean, aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const { _curveBindings: srcCurves } = srcPlayData.state.clip; - const { state: destState } = destPlayData; + const { _curveBindings: srcCurves } = srcRuntime.state.clip; + const { state: destState } = destRuntime; const { _curveBindings: destCurves } = destState.clip; - const finished = destPlayData._playState === AnimatorStatePlayState.Finished; + const finished = destRuntime.playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { for (let i = crossLayerOwnerCollection.length - 1; i >= 0; i--) { @@ -894,8 +903,8 @@ export class Animator extends Component { const value = owner.evaluateCrossFadeValue( srcCurveIndex >= 0 ? srcCurves[srcCurveIndex].curve : null, destCurveIndex >= 0 ? destCurves[destCurveIndex].curve : null, - srcPlayData._clipTime, - destPlayData._clipTime, + srcRuntime.clipTime, + destRuntime.clipTime, crossWeight, additive ); @@ -912,30 +921,30 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ) { - const { destPlayData } = layerData; - const { state } = destPlayData; + const { destRuntime } = layerData; + const state = destRuntime.state.def; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, state, deltaTime, aniUpdate)) { return; } - const playSpeed = destPlayData.speed * this.speed; + const playSpeed = destRuntime.state.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; - destPlayData.updateOrientation(playDeltaTime); + destRuntime.updateOrientation(playDeltaTime); - const { _clipTime: lastDestClipTime, _playState: lastPlayState } = destPlayData; + const { clipTime: lastDestClipTime, playState: lastPlayState } = destRuntime; let dstPlayCostTime: number; - if (destPlayData._isForward) { + if (destRuntime.isForward) { // The time that has been played - const playedTime = destPlayData._playedTime; + const playedTime = destRuntime.playedTime; dstPlayCostTime = playedTime + playDeltaTime > transitionDuration ? transitionDuration - playedTime : playDeltaTime; } else { // The time that has been played - const playedTime = destPlayData._playedTime; + const playedTime = destRuntime.playedTime; dstPlayCostTime = // -playDeltaTime: The time that will be played, negative are meant to make it be a periods // > transition: The time that will be played is enough to finish the transition @@ -948,23 +957,23 @@ export class Animator extends Component { const actualCostTime = playSpeed === 0 ? deltaTime : dstPlayCostTime / playSpeed; - destPlayData.update(dstPlayCostTime); + destRuntime.update(dstPlayCostTime); - let crossWeight = Math.abs(destPlayData._playedTime) / transitionDuration; + let crossWeight = Math.abs(destRuntime.playedTime) / transitionDuration; (crossWeight >= 1.0 - MathUtil.zeroTolerance || transitionDuration === 0) && (crossWeight = 1.0); const crossFadeFinished = crossWeight === 1.0; if (crossFadeFinished) { this._preparePlayOwner(layerData, state); - this._evaluatePlayingState(destPlayData, weight, additive, aniUpdate); + this._evaluatePlayingState(destRuntime, weight, additive, aniUpdate); } else { - this._evaluateCrossFadeFromPoseState(layerData, destPlayData, weight, crossWeight, additive, aniUpdate); + this._evaluateCrossFadeFromPoseState(layerData, destRuntime, weight, crossWeight, additive, aniUpdate); } this._fireAnimationEventsAndCallScripts( layerData.layerIndex, - destPlayData, + destRuntime, state, lastDestClipTime, lastPlayState, @@ -980,17 +989,17 @@ export class Animator extends Component { private _evaluateCrossFadeFromPoseState( layerData: AnimatorLayerData, - destPlayData: AnimatorStatePlayData, + destRuntime: AnimatorStateRuntime, weight: number, crossWeight: number, additive: boolean, aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const { state } = destPlayData; + const { state } = destRuntime; const { _curveBindings: curveBindings } = state.clip; - const { _clipTime: destClipTime, _playState: playState } = destPlayData; + const { clipTime: destClipTime, playState: playState } = destRuntime; const finished = playState === AnimatorStatePlayState.Finished; // When the animator is culled (aniUpdate=false), if the play state has finished, the final value needs to be calculated and saved to be applied directly @@ -1024,14 +1033,14 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ): void { - const playData = layerData.srcPlayData; - const { state } = playData; - const actualSpeed = playData.speed * this.speed; + const runtime = layerData.srcRuntime; + const state = runtime.state.def; + const actualSpeed = runtime.state.speed * this.speed; const actualDeltaTime = actualSpeed * deltaTime; - playData.updateOrientation(actualDeltaTime); + runtime.updateOrientation(actualDeltaTime); - const { _clipTime: clipTime, _isForward: isForward } = playData; + const { clipTime: clipTime, isForward: isForward } = runtime; const { _transitionCollection: transitions } = state; const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine; @@ -1041,7 +1050,7 @@ export class Animator extends Component { this._applyStateTransitions( layerData, isForward, - playData, + runtime, transitions, clipTime, clipTime, @@ -1052,12 +1061,12 @@ export class Animator extends Component { if (transition) { this._updateState(layerData, deltaTime, aniUpdate); } else { - this._evaluateFinishedState(playData, weight, additive, aniUpdate); + this._evaluateFinishedState(runtime, weight, additive, aniUpdate); } } private _evaluateFinishedState( - playData: AnimatorStatePlayData, + runtime: AnimatorStateRuntime, weight: number, additive: boolean, aniUpdate: boolean @@ -1066,8 +1075,8 @@ export class Animator extends Component { return; } - const { curveLayerOwner } = playData._stateData; - const { _curveBindings: curveBindings } = playData.state.clip; + const { curveLayerOwner } = runtime.stateData; + const { _curveBindings: curveBindings } = runtime.state.clip; for (let i = curveBindings.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; @@ -1082,8 +1091,8 @@ export class Animator extends Component { } private _updateCrossFadeData(layerData: AnimatorLayerData): void { - const { destPlayData } = layerData; - if (destPlayData._playState === AnimatorStatePlayState.Finished) { + const { destRuntime } = layerData; + if (destRuntime.playState === AnimatorStatePlayState.Finished) { layerData.layerState = LayerState.Finished; } else { layerData.layerState = LayerState.Playing; @@ -1092,11 +1101,11 @@ export class Animator extends Component { layerData.crossFadeTransition = null; } - private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorState): void { + private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorStateDef): void { if (layerData.layerState === LayerState.Playing) { - const srcPlayData = layerData.srcPlayData; - if (srcPlayData.state !== playState) { - const { curveLayerOwner } = srcPlayData._stateData; + const srcRuntime = layerData.srcRuntime; + if (srcRuntime.state.def !== playState) { + const { curveLayerOwner } = srcRuntime.stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { curveLayerOwner[i]?.curveOwner.revertDefaultValue(); } @@ -1112,14 +1121,14 @@ export class Animator extends Component { private _applyStateTransitions( layerData: AnimatorLayerData, isForward: boolean, - playData: AnimatorStatePlayData, + runtime: AnimatorStateRuntime, transitionCollection: AnimatorStateTransitionCollection, lastClipTime: number, clipTime: number, deltaTime: number, aniUpdate: boolean ): AnimatorStateTransition { - const { state } = playData; + const state = runtime.state.def; const clipDuration = state.clip.length; let targetTransition: AnimatorStateTransition = null; const startTime = state.clipStartTime * clipDuration; @@ -1203,7 +1212,7 @@ export class Animator extends Component { private _tryCrossFadeInterrupt( layerData: AnimatorLayerData, transitionDuration: number, - currentDestState: AnimatorState, + currentDestState: AnimatorStateDef, deltaTime: number, aniUpdate: boolean ): boolean { @@ -1224,7 +1233,7 @@ export class Animator extends Component { layerData: AnimatorLayerData, transitionCollection: AnimatorStateTransitionCollection, aniUpdate: boolean, - excludeDestState?: AnimatorState + excludeDestState?: AnimatorStateDef ): AnimatorStateTransition { for (let i = 0, n = transitionCollection.noExitTimeCount; i < n; ++i) { const transition = transitionCollection.get(i); @@ -1245,7 +1254,7 @@ export class Animator extends Component { private _checkSubTransition( layerData: AnimatorLayerData, - state: AnimatorState, + state: AnimatorStateDef, transitionCollection: AnimatorStateTransitionCollection, lastClipTime: number, curClipTime: number, @@ -1282,7 +1291,7 @@ export class Animator extends Component { private _checkBackwardsSubTransition( layerData: AnimatorLayerData, - state: AnimatorState, + state: AnimatorStateDef, transitionCollection: AnimatorStateTransitionCollection, lastClipTime: number, curClipTime: number, @@ -1335,7 +1344,7 @@ export class Animator extends Component { } } - private _preparePlay(state: AnimatorState, layerIndex: number, normalizedTimeOffset: number = 0): boolean { + private _preparePlay(state: AnimatorStateDef, layerIndex: number, normalizedTimeOffset: number = 0): boolean { const name = state.name; if (!state.clip) { Logger.warn(`The state named ${name} has no AnimationClip data.`); @@ -1348,12 +1357,12 @@ export class Animator extends Component { this._preparePlayOwner(animatorLayerData, state); animatorLayerData.layerState = LayerState.Playing; - const playData = animatorLayerData.getOrCreatePlayData(state); - playData.resetForPlay(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); - animatorLayerData.srcPlayData = playData; + const runtime = animatorLayerData.getOrCreateRuntime(state); + runtime.resetForPlay(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); + animatorLayerData.srcRuntime = runtime; // Clear any stale cross-fade slot from a previously-interrupted crossFade so // subsequent crossFade() calls aren't no-op'd by the self-target alias guard. - animatorLayerData.destPlayData = null; + animatorLayerData.destRuntime = null; animatorLayerData.crossFadeTransition = null; animatorLayerData.resetCurrentCheckIndex(); @@ -1455,19 +1464,22 @@ export class Animator extends Component { const animatorLayerData = this._getAnimatorLayerData(layerIndex); - // Self/active-dest cross-fade is intentionally a no-op because each state - // owns one persistent PlayData handle per layer (so per-instance overrides + // Self/active-dest cross-fade is intentionally a no-op because each def + // owns one persistent state view per layer (so per-instance overrides // like speed survive transitions). Supporting self cross-fade would require // a separate transient playback track per active fade. - if (animatorLayerData.srcPlayData?.state === crossState || animatorLayerData.destPlayData?.state === crossState) { + if ( + animatorLayerData.srcRuntime?.state.def === crossState || + animatorLayerData.destRuntime?.state.def === crossState + ) { return false; } const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex); - const destPlayData = animatorLayerData.getOrCreatePlayData(crossState); - destPlayData.resetForPlay(animatorStateData, transition.offset * crossState._getClipActualEndTime()); - animatorLayerData.destPlayData = destPlayData; + const destRuntime = animatorLayerData.getOrCreateRuntime(crossState); + destRuntime.resetForPlay(animatorStateData, transition.offset * crossState._getClipActualEndTime()); + animatorLayerData.destRuntime = destRuntime; animatorLayerData.resetCurrentCheckIndex(); switch (animatorLayerData.layerState) { @@ -1497,41 +1509,42 @@ export class Animator extends Component { } private _fireAnimationEvents( - playData: AnimatorStatePlayData, + runtime: AnimatorStateRuntime, eventHandlers: AnimationEventHandler[], lastClipTime: number, deltaTime: number ): void { - const { state, _isForward: isForward, _clipTime: clipTime } = playData; + const { isForward, clipTime } = runtime; + const state = runtime.state.def; const startTime = state._getClipActualStartTime(); const endTime = state._getClipActualEndTime(); if (isForward) { if (lastClipTime + deltaTime >= endTime) { - this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, endTime); - playData._currentEventIndex = 0; - this._fireSubAnimationEvents(playData, eventHandlers, startTime, clipTime); + this._fireSubAnimationEvents(runtime, eventHandlers, lastClipTime, endTime); + runtime.currentEventIndex = 0; + this._fireSubAnimationEvents(runtime, eventHandlers, startTime, clipTime); } else { - this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); + this._fireSubAnimationEvents(runtime, 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); + this._fireBackwardSubAnimationEvents(runtime, eventHandlers, lastClipTime, startTime); + runtime.currentEventIndex = eventHandlers.length - 1; + this._fireBackwardSubAnimationEvents(runtime, eventHandlers, endTime, clipTime); } else { - this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); + this._fireBackwardSubAnimationEvents(runtime, eventHandlers, lastClipTime, clipTime); } } } private _fireSubAnimationEvents( - playState: AnimatorStatePlayData, + playState: AnimatorStateRuntime, eventHandlers: AnimationEventHandler[], lastClipTime: number, curClipTime: number ): void { - let eventIndex = playState._currentEventIndex; + let eventIndex = playState.currentEventIndex; for (let n = eventHandlers.length; eventIndex < n; eventIndex++) { const eventHandler = eventHandlers[eventIndex]; const { time, parameter } = eventHandler.event; @@ -1545,18 +1558,18 @@ export class Animator extends Component { for (let j = handlers.length - 1; j >= 0; j--) { handlers[j](parameter); } - playState._currentEventIndex = Math.min(eventIndex + 1, n - 1); + playState.currentEventIndex = Math.min(eventIndex + 1, n - 1); } } } private _fireBackwardSubAnimationEvents( - playState: AnimatorStatePlayData, + playState: AnimatorStateRuntime, eventHandlers: AnimationEventHandler[], lastClipTime: number, curClipTime: number ): void { - let eventIndex = playState._currentEventIndex; + let eventIndex = playState.currentEventIndex; for (; eventIndex >= 0; eventIndex--) { const eventHandler = eventHandlers[eventIndex]; const { time, parameter } = eventHandler.event; @@ -1570,7 +1583,7 @@ export class Animator extends Component { for (let j = handlers.length - 1; j >= 0; j--) { handlers[j](parameter); } - playState._currentEventIndex = Math.max(eventIndex - 1, 0); + playState.currentEventIndex = Math.max(eventIndex - 1, 0); } } } @@ -1608,19 +1621,19 @@ export class Animator extends Component { private _fireAnimationEventsAndCallScripts( layerIndex: number, - playData: AnimatorStatePlayData, - state: AnimatorState, + runtime: AnimatorStateRuntime, + state: AnimatorStateDef, lastClipTime: number, lastPlayState: AnimatorStatePlayState, deltaTime: number ) { - const { eventHandlers } = playData._stateData; - eventHandlers.length && this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime); + const { eventHandlers } = runtime.stateData; + eventHandlers.length && this._fireAnimationEvents(runtime, eventHandlers, lastClipTime, deltaTime); if (lastPlayState === AnimatorStatePlayState.UnStarted) { state._callOnEnter(this, layerIndex); } - if (lastPlayState !== AnimatorStatePlayState.Finished && playData._playState === AnimatorStatePlayState.Finished) { + if (lastPlayState !== AnimatorStatePlayState.Finished && runtime.playState === AnimatorStatePlayState.Finished) { state._callOnExit(this, layerIndex); } else { state._callOnUpdate(this, layerIndex); @@ -1637,5 +1650,5 @@ export class Animator extends Component { interface IAnimatorStateInfo { layerIndex: number; - state: AnimatorState; + state: AnimatorStateDef; } diff --git a/packages/core/src/animation/AnimatorState.ts b/packages/core/src/animation/AnimatorState.ts index 3962c6da82..77702ae710 100644 --- a/packages/core/src/animation/AnimatorState.ts +++ b/packages/core/src/animation/AnimatorState.ts @@ -1,260 +1,72 @@ -import { Engine } from "../Engine"; -import { UpdateFlagManager } from "../UpdateFlagManager"; import { AnimationClip } from "./AnimationClip"; -import type { Animator } from "./Animator"; -import { AnimatorStateTransition } from "./AnimatorStateTransition"; -import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; +import { AnimatorStateDef } from "./AnimatorStateDef"; +import { AnimatorStateRuntime } from "./internal/AnimatorStateRuntime"; import { WrapMode } from "./enums/WrapMode"; -import { StateMachineScript } from "./StateMachineScript"; /** - * States are the basic building blocks of a state machine. Each state contains a AnimationClip which will play while the character is in that state. + * Per-Animator runtime view of an `AnimatorStateDef`. + * + * `findAnimatorState` returns this view: each Animator gets its own instance + * bound to the shared `AnimatorStateDef` asset on the controller. Writes on + * the per-instance fields (currently only `speed`) only affect this Animator; + * reads of asset fields (`name`, `clip`, `wrapMode`, ...) forward to the shared + * def. + * + * Lifecycle: lazy-created by `Animator.findAnimatorState` on first access and + * persists for the layer's lifetime so per-instance overrides survive + * transitions out of and back into the state. */ export class AnimatorState { - /** The speed of the clip. 1 is normal speed, default 1. */ - speed: number = 1.0; - /** The wrap mode used in the state. */ - wrapMode: WrapMode = WrapMode.Loop; + /** The shared AnimatorStateDef asset this view is bound to. */ + readonly def: AnimatorStateDef; /** @internal */ - _updateFlagManager: UpdateFlagManager = new UpdateFlagManager(); - /** @internal */ - _transitionCollection: AnimatorStateTransitionCollection = new AnimatorStateTransitionCollection(); + _runtime: AnimatorStateRuntime; - private _onStateEnterScripts: StateMachineScript[] = []; - private _onStateUpdateScripts: StateMachineScript[] = []; - private _onStateExitScripts: StateMachineScript[] = []; - private _engine: Engine; - private _clipStartTime = 0; - private _clipEndTime = 1; - private _clip: AnimationClip; + private _speed: number | undefined; - /** - * The transitions that are going out of the state. - */ - get transitions(): Readonly { - return this._transitionCollection.transitions; + /** The state's name (from the shared asset). */ + get name(): string { + return this.def.name; } - /** - * The clip that is being played by this animator state. - */ + /** The animation clip (from the shared asset). */ get clip(): AnimationClip { - return this._clip; + return this.def.clip; } - set clip(clip: AnimationClip) { - const lastClip = this._clip; - if (lastClip === clip) { - return; - } - - if (lastClip) { - lastClip._updateFlagManager.removeListener(this._onClipChanged); - } - - this._clip = clip; - this._clipEndTime = Math.min(this._clipEndTime, 1); - - this._onClipChanged(); - - clip && clip._updateFlagManager.addListener(this._onClipChanged); + /** The wrap mode (from the shared asset). */ + get wrapMode(): WrapMode { + return this.def.wrapMode; } - /** - * The normalized start time of the clip, the range is 0 to 1, default is 0. - */ + /** Normalized clip start time (from the shared asset). */ get clipStartTime(): number { - return this._clipStartTime; - } - - set clipStartTime(time: number) { - this._clipStartTime = Math.max(time, 0); + return this.def.clipStartTime; } - /** - * The normalized end time of the clip, the range is 0 to 1, default is 1. - */ + /** Normalized clip end time (from the shared asset). */ get clipEndTime(): number { - return this._clipEndTime; - } - - set clipEndTime(time: number) { - this._clipEndTime = Math.min(time, 1); - } - - /** - * @param name - The state's name - */ - constructor(public readonly name: string) { - this._onClipChanged = this._onClipChanged.bind(this); + return this.def.clipEndTime; } /** - * Add an outgoing transition. - * @param transition - The transition + * Per-instance playback speed for this state. + * + * Read: returns the per-instance override if set, otherwise reads through to `def.speed`. + * Write: claims per-instance ownership; later changes to `def.speed` no longer flow through. + * The per-instance value persists across state transitions on the owning Animator. */ - addTransition(transition: AnimatorStateTransition): AnimatorStateTransition; - /** - * Add an outgoing transition to the destination state. - * @param animatorState - The destination state - */ - addTransition(animatorState: AnimatorState): AnimatorStateTransition; - - addTransition(transitionOrAnimatorState: AnimatorStateTransition | AnimatorState): AnimatorStateTransition { - return this._transitionCollection.add(transitionOrAnimatorState); + get speed(): number { + return this._speed ?? this.def.speed; } - /** - * Add an outgoing transition to exit of the stateMachine. - * @param exitTime - The time at which the transition can take effect. This is represented in normalized time. - */ - addExitTransition(exitTime: number = 1.0): AnimatorStateTransition { - const transition = new AnimatorStateTransition(); - transition._isExit = true; - transition.exitTime = exitTime; - - return this._transitionCollection.add(transition); + set speed(value: number) { + this._speed = value; } - /** - * Remove a transition from the state. - * @param transition - The transition - */ - removeTransition(transition: AnimatorStateTransition): void { - this._transitionCollection.remove(transition); - if (transition._isExit) { - transition._isExit = false; - } - } - - /** - * Adds a state machine script class of type T to the AnimatorState. - * @param scriptType - The state machine script class of type T - */ - addStateMachineScript(scriptType: new () => T): T { - const script = new scriptType(); - script._engine = this._engine; - script._state = this; - - const { prototype } = StateMachineScript; - if (script.onStateEnter !== prototype.onStateEnter) { - this._onStateEnterScripts.push(script); - } - if (script.onStateUpdate !== prototype.onStateUpdate) { - this._onStateUpdateScripts.push(script); - } - if (script.onStateExit !== prototype.onStateExit) { - this._onStateExitScripts.push(script); - } - return script; - } - - /** - * Clears all transitions from the state. - */ - clearTransitions(): void { - this._transitionCollection.clear(); - } - - /** - * @internal - */ - _callOnEnter(animator: Animator, layerIndex: number): void { - const scripts = this._onStateEnterScripts; - for (let i = 0, n = scripts.length; i < n; i++) { - scripts[i].onStateEnter(animator, this, layerIndex); - } - } - - /** - * @internal - */ - _callOnUpdate(animator: Animator, layerIndex: number): void { - const scripts = this._onStateUpdateScripts; - for (let i = 0, n = scripts.length; i < n; i++) { - scripts[i].onStateUpdate(animator, this, layerIndex); - } - } - - /** - * @internal - */ - _callOnExit(animator: Animator, layerIndex: number): void { - const scripts = this._onStateExitScripts; - for (let i = 0, n = scripts.length; i < n; i++) { - scripts[i].onStateExit(animator, this, layerIndex); - } - } - - /** - * @internal - */ - _getDuration(): number { - if (this.clip) { - return (this._clipEndTime - this._clipStartTime) * this.clip.length; - } - return null; - } - - /** - * @internal - */ - _removeStateMachineScript(script: StateMachineScript): void { - const { prototype } = StateMachineScript; - if (script.onStateEnter !== prototype.onStateEnter) { - const index = this._onStateEnterScripts.indexOf(script); - index !== -1 && this._onStateEnterScripts.splice(index, 1); - } - if (script.onStateUpdate !== prototype.onStateUpdate) { - const index = this._onStateUpdateScripts.indexOf(script); - index !== -1 && this._onStateUpdateScripts.splice(index, 1); - } - if (script.onStateExit !== prototype.onStateExit) { - const index = this._onStateExitScripts.indexOf(script); - index !== -1 && this._onStateExitScripts.splice(index, 1); - } - } - - /** - * @internal - */ - _onClipChanged(): void { - this._updateFlagManager.dispatch(); - } - - /** - * @internal - */ - _getClipActualStartTime(): number { - return this._clipStartTime * this.clip.length; - } - - /** - * @internal - */ - _getClipActualEndTime(): number { - return this._clipEndTime * this.clip.length; - } - - /** - * @internal - */ - _setEngine(engine: Engine): void { - this._engine = engine; - const { - _onStateEnterScripts: enterScripts, - _onStateUpdateScripts: updateScripts, - _onStateExitScripts: exitScripts - } = this; - for (let i = 0, n = enterScripts.length; i < n; i++) { - enterScripts[i]._engine = engine; - } - for (let i = 0, n = updateScripts.length; i < n; i++) { - updateScripts[i]._engine = engine; - } - for (let i = 0, n = exitScripts.length; i < n; i++) { - exitScripts[i]._engine = engine; - } + /** @internal */ + constructor(def: AnimatorStateDef) { + this.def = def; } } diff --git a/packages/core/src/animation/AnimatorStateDef.ts b/packages/core/src/animation/AnimatorStateDef.ts new file mode 100644 index 0000000000..47dbf16710 --- /dev/null +++ b/packages/core/src/animation/AnimatorStateDef.ts @@ -0,0 +1,260 @@ +import { Engine } from "../Engine"; +import { UpdateFlagManager } from "../UpdateFlagManager"; +import { AnimationClip } from "./AnimationClip"; +import type { Animator } from "./Animator"; +import { AnimatorStateTransition } from "./AnimatorStateTransition"; +import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; +import { WrapMode } from "./enums/WrapMode"; +import { StateMachineScript } from "./StateMachineScript"; + +/** + * States are the basic building blocks of a state machine. Each state contains a AnimationClip which will play while the character is in that state. + */ +export class AnimatorStateDef { + /** The speed of the clip. 1 is normal speed, default 1. */ + speed: number = 1.0; + /** The wrap mode used in the state. */ + wrapMode: WrapMode = WrapMode.Loop; + + /** @internal */ + _updateFlagManager: UpdateFlagManager = new UpdateFlagManager(); + /** @internal */ + _transitionCollection: AnimatorStateTransitionCollection = new AnimatorStateTransitionCollection(); + + private _onStateEnterScripts: StateMachineScript[] = []; + private _onStateUpdateScripts: StateMachineScript[] = []; + private _onStateExitScripts: StateMachineScript[] = []; + private _engine: Engine; + private _clipStartTime = 0; + private _clipEndTime = 1; + private _clip: AnimationClip; + + /** + * The transitions that are going out of the state. + */ + get transitions(): Readonly { + return this._transitionCollection.transitions; + } + + /** + * The clip that is being played by this animator state. + */ + get clip(): AnimationClip { + return this._clip; + } + + set clip(clip: AnimationClip) { + const lastClip = this._clip; + if (lastClip === clip) { + return; + } + + if (lastClip) { + lastClip._updateFlagManager.removeListener(this._onClipChanged); + } + + this._clip = clip; + this._clipEndTime = Math.min(this._clipEndTime, 1); + + this._onClipChanged(); + + clip && clip._updateFlagManager.addListener(this._onClipChanged); + } + + /** + * The normalized start time of the clip, the range is 0 to 1, default is 0. + */ + get clipStartTime(): number { + return this._clipStartTime; + } + + set clipStartTime(time: number) { + this._clipStartTime = Math.max(time, 0); + } + + /** + * The normalized end time of the clip, the range is 0 to 1, default is 1. + */ + get clipEndTime(): number { + return this._clipEndTime; + } + + set clipEndTime(time: number) { + this._clipEndTime = Math.min(time, 1); + } + + /** + * @param name - The state's name + */ + constructor(public readonly name: string) { + this._onClipChanged = this._onClipChanged.bind(this); + } + + /** + * Add an outgoing transition. + * @param transition - The transition + */ + addTransition(transition: AnimatorStateTransition): AnimatorStateTransition; + /** + * Add an outgoing transition to the destination state. + * @param animatorState - The destination state + */ + addTransition(animatorState: AnimatorStateDef): AnimatorStateTransition; + + addTransition(transitionOrAnimatorState: AnimatorStateTransition | AnimatorStateDef): AnimatorStateTransition { + return this._transitionCollection.add(transitionOrAnimatorState); + } + + /** + * Add an outgoing transition to exit of the stateMachine. + * @param exitTime - The time at which the transition can take effect. This is represented in normalized time. + */ + addExitTransition(exitTime: number = 1.0): AnimatorStateTransition { + const transition = new AnimatorStateTransition(); + transition._isExit = true; + transition.exitTime = exitTime; + + return this._transitionCollection.add(transition); + } + /** + * Remove a transition from the state. + * @param transition - The transition + */ + removeTransition(transition: AnimatorStateTransition): void { + this._transitionCollection.remove(transition); + if (transition._isExit) { + transition._isExit = false; + } + } + + /** + * Adds a state machine script class of type T to the AnimatorState. + * @param scriptType - The state machine script class of type T + */ + addStateMachineScript(scriptType: new () => T): T { + const script = new scriptType(); + script._engine = this._engine; + script._state = this; + + const { prototype } = StateMachineScript; + if (script.onStateEnter !== prototype.onStateEnter) { + this._onStateEnterScripts.push(script); + } + if (script.onStateUpdate !== prototype.onStateUpdate) { + this._onStateUpdateScripts.push(script); + } + if (script.onStateExit !== prototype.onStateExit) { + this._onStateExitScripts.push(script); + } + + return script; + } + + /** + * Clears all transitions from the state. + */ + clearTransitions(): void { + this._transitionCollection.clear(); + } + + /** + * @internal + */ + _callOnEnter(animator: Animator, layerIndex: number): void { + const scripts = this._onStateEnterScripts; + for (let i = 0, n = scripts.length; i < n; i++) { + scripts[i].onStateEnter(animator, this, layerIndex); + } + } + + /** + * @internal + */ + _callOnUpdate(animator: Animator, layerIndex: number): void { + const scripts = this._onStateUpdateScripts; + for (let i = 0, n = scripts.length; i < n; i++) { + scripts[i].onStateUpdate(animator, this, layerIndex); + } + } + + /** + * @internal + */ + _callOnExit(animator: Animator, layerIndex: number): void { + const scripts = this._onStateExitScripts; + for (let i = 0, n = scripts.length; i < n; i++) { + scripts[i].onStateExit(animator, this, layerIndex); + } + } + + /** + * @internal + */ + _getDuration(): number { + if (this.clip) { + return (this._clipEndTime - this._clipStartTime) * this.clip.length; + } + return null; + } + + /** + * @internal + */ + _removeStateMachineScript(script: StateMachineScript): void { + const { prototype } = StateMachineScript; + if (script.onStateEnter !== prototype.onStateEnter) { + const index = this._onStateEnterScripts.indexOf(script); + index !== -1 && this._onStateEnterScripts.splice(index, 1); + } + if (script.onStateUpdate !== prototype.onStateUpdate) { + const index = this._onStateUpdateScripts.indexOf(script); + index !== -1 && this._onStateUpdateScripts.splice(index, 1); + } + if (script.onStateExit !== prototype.onStateExit) { + const index = this._onStateExitScripts.indexOf(script); + index !== -1 && this._onStateExitScripts.splice(index, 1); + } + } + + /** + * @internal + */ + _onClipChanged(): void { + this._updateFlagManager.dispatch(); + } + + /** + * @internal + */ + _getClipActualStartTime(): number { + return this._clipStartTime * this.clip.length; + } + + /** + * @internal + */ + _getClipActualEndTime(): number { + return this._clipEndTime * this.clip.length; + } + + /** + * @internal + */ + _setEngine(engine: Engine): void { + this._engine = engine; + const { + _onStateEnterScripts: enterScripts, + _onStateUpdateScripts: updateScripts, + _onStateExitScripts: exitScripts + } = this; + for (let i = 0, n = enterScripts.length; i < n; i++) { + enterScripts[i]._engine = engine; + } + for (let i = 0, n = updateScripts.length; i < n; i++) { + updateScripts[i]._engine = engine; + } + for (let i = 0, n = exitScripts.length; i < n; i++) { + exitScripts[i]._engine = engine; + } + } +} diff --git a/packages/core/src/animation/AnimatorStateMachine.ts b/packages/core/src/animation/AnimatorStateMachine.ts index 7a2914280a..97c47c4642 100644 --- a/packages/core/src/animation/AnimatorStateMachine.ts +++ b/packages/core/src/animation/AnimatorStateMachine.ts @@ -1,9 +1,9 @@ import { Engine } from "../Engine"; -import { AnimatorState } from "./AnimatorState"; +import { AnimatorStateDef } from "./AnimatorStateDef"; import { AnimatorStateTransition } from "./AnimatorStateTransition"; import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; export interface AnimatorStateMap { - [key: string]: AnimatorState; + [key: string]: AnimatorStateDef; } /** @@ -11,7 +11,7 @@ export interface AnimatorStateMap { */ export class AnimatorStateMachine { /** The list of states. */ - readonly states: AnimatorState[] = []; + readonly states: AnimatorStateDef[] = []; private _engine: Engine; @@ -19,7 +19,7 @@ export class AnimatorStateMachine { * The state will be played automatically. * @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered. */ - defaultState: AnimatorState; + defaultState: AnimatorStateDef; /** @internal */ _entryTransitionCollection = new AnimatorStateTransitionCollection(); @@ -46,10 +46,10 @@ export class AnimatorStateMachine { * Add a state to the state machine. * @param name - The name of the new state */ - addState(name: string): AnimatorState { + addState(name: string): AnimatorStateDef { let state = this.findStateByName(name); if (!state) { - state = new AnimatorState(name); + state = new AnimatorStateDef(name); state._setEngine(this._engine); this.states.push(state); this._statesMap[name] = state; @@ -63,7 +63,7 @@ export class AnimatorStateMachine { * Remove a state from the state machine. * @param state - The state */ - removeState(state: AnimatorState): void { + removeState(state: AnimatorStateDef): void { const { name } = state; const index = this.states.indexOf(state); if (index > -1) { @@ -76,7 +76,7 @@ export class AnimatorStateMachine { * Get the state by name. * @param name - The layer's name */ - findStateByName(name: string): AnimatorState { + findStateByName(name: string): AnimatorStateDef { return this._statesMap[name]; } @@ -106,9 +106,11 @@ export class AnimatorStateMachine { * @param animatorState - The destination state */ - addEntryStateTransition(animatorState: AnimatorState): AnimatorStateTransition; + addEntryStateTransition(animatorState: AnimatorStateDef): AnimatorStateTransition; - addEntryStateTransition(transitionOrAnimatorState: AnimatorStateTransition | AnimatorState): AnimatorStateTransition { + addEntryStateTransition( + transitionOrAnimatorState: AnimatorStateTransition | AnimatorStateDef + ): AnimatorStateTransition { return this._entryTransitionCollection.add(transitionOrAnimatorState); } @@ -129,9 +131,11 @@ export class AnimatorStateMachine { * Add an any transition to the destination state, the default value of any transition's hasExitTime is false. * @param animatorState - The destination state */ - addAnyStateTransition(animatorState: AnimatorState): AnimatorStateTransition; + addAnyStateTransition(animatorState: AnimatorStateDef): AnimatorStateTransition; - addAnyStateTransition(transitionOrAnimatorState: AnimatorStateTransition | AnimatorState): AnimatorStateTransition { + addAnyStateTransition( + transitionOrAnimatorState: AnimatorStateTransition | AnimatorStateDef + ): AnimatorStateTransition { return this._anyStateTransitionCollection.add(transitionOrAnimatorState); } diff --git a/packages/core/src/animation/AnimatorStatePlayData.ts b/packages/core/src/animation/AnimatorStatePlayData.ts deleted file mode 100644 index 41bea00a14..0000000000 --- a/packages/core/src/animation/AnimatorStatePlayData.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { AnimatorState } from "./AnimatorState"; -import { AnimatorStatePlayState } from "./enums/AnimatorStatePlayState"; -import { WrapMode } from "./enums/WrapMode"; -import { AnimatorStateData } from "./internal/AnimatorStateData"; - -/** - * Per-Animator per-state runtime handle. - * - * Lifecycle: created lazily by AnimatorLayerData.getOrCreatePlayData on first access - * (either via Animator.findAnimatorState or when the state begins playing). Persists - * for the layer's lifetime, so per-instance state (e.g. speed) survives transitions - * out of and back into the state. - * - * Public surface is intentionally narrow: - * - `state`: the shared AnimatorState asset (read-only). - * - `speed`: per-instance playback speed (see the `speed` getter for live-bind semantics). - * - * All other fields are engine-managed runtime state and are underscore-prefixed to - * mark them as implementation detail; mutating them from user code will corrupt - * Animator invariants. - */ -export class AnimatorStatePlayData { - /** The shared AnimatorState asset. Read-only reference. */ - readonly state: AnimatorState; - - /** @internal */ - _stateData: AnimatorStateData; - /** @internal */ - _playedTime: number = 0; - /** @internal */ - _playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted; - /** @internal */ - _clipTime: number = 0; - /** @internal */ - _currentEventIndex: number = 0; - /** @internal */ - _isForward = true; - /** @internal */ - _offsetFrameTime: number = 0; - - private _speed: number | undefined; - private _changedOrientation = false; - - /** - * Per-instance playback speed for this state. - * - * - Read: live-reads `state.speed` until written; afterwards returns the per-instance value. - * - Write: claims per-instance ownership. Later changes to `state.speed` no longer flow through. - * - * Per-instance value persists across state transitions. - */ - get speed(): number { - return this._speed ?? this.state.speed; - } - - set speed(value: number) { - this._speed = value; - } - - /** @internal */ - constructor(state: AnimatorState) { - this.state = state; - } - - /** - * @internal - * Reset runtime fields when (re-)entering this state. Does NOT touch user-written per-instance values (e.g. speed). - */ - resetForPlay(stateData: AnimatorStateData, offsetFrameTime: number): void { - this._stateData = stateData; - this._offsetFrameTime = offsetFrameTime; - this._playedTime = 0; - this._playState = AnimatorStatePlayState.UnStarted; - this._clipTime = this.state.clipStartTime * this.state.clip.length; - this._currentEventIndex = 0; - this._isForward = true; - this._changedOrientation = false; - this.state._transitionCollection.needResetCurrentCheckIndex = true; - } - - /** @internal */ - updateOrientation(deltaTime: number): void { - if (deltaTime !== 0) { - const lastIsForward = this._isForward; - this._isForward = deltaTime > 0; - if (this._isForward !== lastIsForward) { - this._changedOrientation = true; - this._isForward || this._correctTime(); - } - } - } - - /** @internal */ - update(deltaTime: number): void { - this._playedTime += deltaTime; - const state = this.state; - let time = this._playedTime + this._offsetFrameTime; - const duration = state._getDuration(); - this._playState = AnimatorStatePlayState.Playing; - if (state.wrapMode === WrapMode.Loop) { - time = duration ? time % duration : 0; - } else { - if (Math.abs(time) >= duration) { - time = time < 0 ? -duration : duration; - this._playState = AnimatorStatePlayState.Finished; - } - } - - time < 0 && (time += duration); - this._clipTime = time + state.clipStartTime * state.clip.length; - - if (this._changedOrientation) { - !this._isForward && this._correctTime(); - this._changedOrientation = false; - } - } - - private _correctTime() { - const { state } = this; - // Reverse playback resumed at clipTime=0 would step into negatives; jump to - // clipEnd so the next sample continues seamlessly from the end of the clip. - if (this._clipTime === 0) { - this._clipTime = state.clipEndTime * state.clip.length; - } - } -} diff --git a/packages/core/src/animation/AnimatorStateTransition.ts b/packages/core/src/animation/AnimatorStateTransition.ts index 651924643f..ec6ce32c2c 100644 --- a/packages/core/src/animation/AnimatorStateTransition.ts +++ b/packages/core/src/animation/AnimatorStateTransition.ts @@ -1,6 +1,6 @@ import { AnimatorCondition } from "./AnimatorCondition"; import { AnimatorControllerParameterValue } from "./AnimatorControllerParameter"; -import { AnimatorState } from "./AnimatorState"; +import { AnimatorStateDef } from "./AnimatorStateDef"; import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; import { AnimatorConditionMode } from "./enums/AnimatorConditionMode"; @@ -15,7 +15,7 @@ export class AnimatorStateTransition { /** ExitTime represents the exact time at which the transition can take effect. This is represented in normalized time. */ exitTime = 1.0; /** The destination state of the transition. */ - destinationState: AnimatorState; + destinationState: AnimatorStateDef; /** Mutes the transition. The transition will never occur. */ mute = false; /** Determines whether the duration of the transition is reported in a fixed duration in seconds or as a normalized time. */ diff --git a/packages/core/src/animation/AnimatorStateTransitionCollection.ts b/packages/core/src/animation/AnimatorStateTransitionCollection.ts index 6ea486b508..ba36f482c0 100644 --- a/packages/core/src/animation/AnimatorStateTransitionCollection.ts +++ b/packages/core/src/animation/AnimatorStateTransitionCollection.ts @@ -1,4 +1,4 @@ -import { AnimatorState } from "./AnimatorState"; +import { AnimatorStateDef } from "./AnimatorStateDef"; import { AnimatorStateTransition } from "./AnimatorStateTransition"; /** @@ -24,9 +24,9 @@ export class AnimatorStateTransitionCollection { return this.transitions[index]; } - add(transitionOrAnimatorState: AnimatorStateTransition | AnimatorState): AnimatorStateTransition { + add(transitionOrAnimatorState: AnimatorStateTransition | AnimatorStateDef): AnimatorStateTransition { let transition: AnimatorStateTransition; - if (transitionOrAnimatorState instanceof AnimatorState) { + if (transitionOrAnimatorState instanceof AnimatorStateDef) { transition = new AnimatorStateTransition(); transition.hasExitTime = false; transition.destinationState = transitionOrAnimatorState; diff --git a/packages/core/src/animation/StateMachineScript.ts b/packages/core/src/animation/StateMachineScript.ts index 775d69eaef..7fc29c4dbd 100644 --- a/packages/core/src/animation/StateMachineScript.ts +++ b/packages/core/src/animation/StateMachineScript.ts @@ -1,5 +1,5 @@ import { Animator } from "../animation/Animator"; -import { AnimatorState } from "../animation/AnimatorState"; +import { AnimatorStateDef } from "../animation/AnimatorStateDef"; import { EngineObject } from "../base/EngineObject"; /** @@ -7,7 +7,7 @@ import { EngineObject } from "../base/EngineObject"; */ export class StateMachineScript extends EngineObject { /** @internal */ - _state: AnimatorState; + _state: AnimatorStateDef; constructor() { super(null); @@ -19,7 +19,7 @@ export class StateMachineScript extends EngineObject { * @param animatorState - The state be evaluated * @param layerIndex - The index of the layer where the state is located */ - onStateEnter(animator: Animator, animatorState: AnimatorState, layerIndex: number): void {} + onStateEnter(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void {} /** * onStateUpdate is called on each Update frame between onStateEnter and onStateExit callbacks. @@ -27,7 +27,7 @@ export class StateMachineScript extends EngineObject { * @param animatorState - The state be evaluated * @param layerIndex - The index of the layer where the state is located */ - onStateUpdate(animator: Animator, animatorState: AnimatorState, layerIndex: number): void {} + onStateUpdate(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void {} /** * onStateExit is called when a transition ends and the state machine finishes evaluating this state. @@ -35,7 +35,7 @@ export class StateMachineScript extends EngineObject { * @param animatorState - The state be evaluated * @param layerIndex - The index of the layer where the state is located */ - onStateExit(animator: Animator, animatorState: AnimatorState, layerIndex: number): void {} + onStateExit(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void {} protected override _onDestroy(): void { super._onDestroy(); diff --git a/packages/core/src/animation/index.ts b/packages/core/src/animation/index.ts index 307ac5bdd0..d6b3c8db3f 100644 --- a/packages/core/src/animation/index.ts +++ b/packages/core/src/animation/index.ts @@ -11,7 +11,7 @@ export { Animator } from "./Animator"; export { AnimatorController } from "./AnimatorController"; export { AnimatorControllerLayer } from "./AnimatorControllerLayer"; export { AnimatorState } from "./AnimatorState"; -export { AnimatorStatePlayData } from "./AnimatorStatePlayData"; +export { AnimatorStateDef } from "./AnimatorStateDef"; export { AnimatorStateMachine } from "./AnimatorStateMachine"; export { AnimatorStateTransition } from "./AnimatorStateTransition"; export { AnimatorConditionMode } from "./enums/AnimatorConditionMode"; diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index ceb9c89454..cad232901c 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -1,10 +1,11 @@ import { AnimatorControllerLayer } from "../AnimatorControllerLayer"; import { AnimatorState } from "../AnimatorState"; +import { AnimatorStateDef } from "../AnimatorStateDef"; import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { LayerState } from "../enums/LayerState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimatorStateData } from "./AnimatorStateData"; -import { AnimatorStatePlayData } from "../AnimatorStatePlayData"; +import { AnimatorStateRuntime } from "./AnimatorStateRuntime"; /** * @internal @@ -14,34 +15,39 @@ export class AnimatorLayerData { layer: AnimatorControllerLayer; curveOwnerPool: Record> = Object.create(null); animatorStateDataMap: Record = Object.create(null); - /** Per-state PlayData handles. Lazy populated. */ - statePlayDataMap: Record = Object.create(null); - /** Currently playing state's PlayData; null when standby. */ - srcPlayData: AnimatorStatePlayData | null = null; - /** Cross-fade target state's PlayData; null when not cross-fading. */ - destPlayData: AnimatorStatePlayData | null = null; + /** Per-state user-facing view containers. Lazy populated. */ + stateMap: Record = Object.create(null); + /** Currently playing state's runtime; null when standby. */ + srcRuntime: AnimatorStateRuntime | null = null; + /** Cross-fade target state's runtime; null when not cross-fading. */ + destRuntime: AnimatorStateRuntime | null = null; layerState: LayerState = LayerState.Standby; crossCurveMark: number = 0; manuallyTransition: AnimatorStateTransition = new AnimatorStateTransition(); crossFadeTransition: AnimatorStateTransition; crossLayerOwnerCollection: AnimationCurveLayerOwner[] = []; - /** Get or lazily create the persistent PlayData for a state. */ - getOrCreatePlayData(state: AnimatorState): AnimatorStatePlayData { - const statePlayDataMap = this.statePlayDataMap; - const stateName = state.name; - let playData = statePlayDataMap[stateName]; - if (playData?.state !== state) { - playData = new AnimatorStatePlayData(state); - statePlayDataMap[stateName] = playData; + /** + * Get or lazily create the persistent (state-view, runtime) pair for a def. + * Rebuilds when the cached view is bound to a different def object + * (same-name remove + re-add). + */ + getOrCreateRuntime(def: AnimatorStateDef): AnimatorStateRuntime { + const map = this.stateMap; + const name = def.name; + let state = map[name]; + if (state?.def !== def) { + state = new AnimatorState(def); + new AnimatorStateRuntime(state); + map[name] = state; } - return playData; + return state._runtime; } - /** After cross-fade completes, promote destPlayData to srcPlayData. */ + /** After cross-fade completes, promote destRuntime to srcRuntime. */ promoteDest(): void { - this.srcPlayData = this.destPlayData; - this.destPlayData = null; + this.srcRuntime = this.destRuntime; + this.destRuntime = null; } resetCurrentCheckIndex(): void { diff --git a/packages/core/src/animation/internal/AnimatorStateData.ts b/packages/core/src/animation/internal/AnimatorStateData.ts index 45e2099eb0..c89a40f345 100644 --- a/packages/core/src/animation/internal/AnimatorStateData.ts +++ b/packages/core/src/animation/internal/AnimatorStateData.ts @@ -1,4 +1,4 @@ -import { AnimatorState } from "../AnimatorState"; +import { AnimatorStateDef } from "../AnimatorStateDef"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimationEventHandler } from "./AnimationEventHandler"; @@ -11,7 +11,7 @@ export class AnimatorStateData { curveLayerOwner: AnimationCurveLayerOwner[] = []; eventHandlers: AnimationEventHandler[] = []; - constructor(readonly state: AnimatorState) {} + constructor(readonly state: AnimatorStateDef) {} /** Detach the clipChangedListener from state's UpdateFlagManager. No-op if not attached. */ dispose(): void { diff --git a/packages/core/src/animation/internal/AnimatorStateRuntime.ts b/packages/core/src/animation/internal/AnimatorStateRuntime.ts new file mode 100644 index 0000000000..75e9eaa2ef --- /dev/null +++ b/packages/core/src/animation/internal/AnimatorStateRuntime.ts @@ -0,0 +1,97 @@ +import { AnimatorState } from "../AnimatorState"; +import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState"; +import { WrapMode } from "../enums/WrapMode"; +import { AnimatorStateData } from "./AnimatorStateData"; + +/** + * @internal + * + * Engine-owned runtime playback state for a single (Animator, AnimatorStateDef) pair. + * + * Lives alongside an `AnimatorState` (user-facing per-instance view) and tracks + * evaluation-time fields: how much has played, current clip time, play state, + * event index, orientation, etc. Mutated by the Animator's update loop; not + * exposed to user code. + */ +export class AnimatorStateRuntime { + /** The user-facing per-instance view this runtime is bound to. */ + readonly state: AnimatorState; + + /** Curve owners + event handlers (shared per-Animator per-state). */ + stateData: AnimatorStateData; + + playedTime: number = 0; + playState: AnimatorStatePlayState = AnimatorStatePlayState.UnStarted; + clipTime: number = 0; + currentEventIndex: number = 0; + isForward: boolean = true; + offsetFrameTime: number = 0; + + private _changedOrientation: boolean = false; + + constructor(state: AnimatorState) { + this.state = state; + state._runtime = this; + } + + /** + * Reset runtime fields when (re-)entering this state. + * Does NOT touch user-written per-instance overrides on `state`. + */ + resetForPlay(stateData: AnimatorStateData, offsetFrameTime: number): void { + const def = this.state.def; + this.stateData = stateData; + this.offsetFrameTime = offsetFrameTime; + this.playedTime = 0; + this.playState = AnimatorStatePlayState.UnStarted; + this.clipTime = def.clipStartTime * def.clip.length; + this.currentEventIndex = 0; + this.isForward = true; + this._changedOrientation = false; + def._transitionCollection.needResetCurrentCheckIndex = true; + } + + updateOrientation(deltaTime: number): void { + if (deltaTime !== 0) { + const lastIsForward = this.isForward; + this.isForward = deltaTime > 0; + if (this.isForward !== lastIsForward) { + this._changedOrientation = true; + this.isForward || this._correctTime(); + } + } + } + + update(deltaTime: number): void { + this.playedTime += deltaTime; + const def = this.state.def; + let time = this.playedTime + this.offsetFrameTime; + const duration = def._getDuration(); + this.playState = AnimatorStatePlayState.Playing; + if (def.wrapMode === WrapMode.Loop) { + time = duration ? time % duration : 0; + } else { + if (Math.abs(time) >= duration) { + time = time < 0 ? -duration : duration; + this.playState = AnimatorStatePlayState.Finished; + } + } + + time < 0 && (time += duration); + this.clipTime = time + def.clipStartTime * def.clip.length; + + if (this._changedOrientation) { + !this.isForward && this._correctTime(); + this._changedOrientation = false; + } + } + + private _correctTime(): void { + const def = this.state.def; + // Reverse playback resumed at clipTime=0 would step into negatives; jump to + // clipEnd so the next sample continues seamlessly from the end of the clip. + if (this.clipTime === 0) { + this.clipTime = def.clipEndTime * def.clip.length; + } + } +} diff --git a/packages/loader/src/AnimatorControllerLoader.ts b/packages/loader/src/AnimatorControllerLoader.ts index b28a89ddb9..6ba846a7af 100644 --- a/packages/loader/src/AnimatorControllerLoader.ts +++ b/packages/loader/src/AnimatorControllerLoader.ts @@ -8,7 +8,7 @@ import { AnimatorController, AnimatorControllerLayer, AnimatorStateTransition, - AnimatorState, + AnimatorStateDef, AnimatorConditionMode, AnimatorControllerParameterValue, WrapMode @@ -36,7 +36,7 @@ class AnimatorControllerLoader extends Loader { if (stateMachineData) { const { states, transitions, entryTransitions, anyTransitions } = stateMachineData; const stateMachine = layer.stateMachine; - const statesMap: Record = {}; + const statesMap: Record = {}; const transitionsMap: Record = {}; states.forEach((stateData: IStateData, stateIndex: number) => { const { @@ -119,7 +119,10 @@ class AnimatorControllerLoader extends Loader { }); } - private _createTransition(transitionData: ITransitionData, destinationState: AnimatorState): AnimatorStateTransition { + private _createTransition( + transitionData: ITransitionData, + destinationState: AnimatorStateDef + ): AnimatorStateTransition { const transition = new AnimatorStateTransition(); transition.hasExitTime = transitionData.hasExitTime; transition.isFixedDuration = transitionData.isFixedDuration; diff --git a/packages/loader/src/gltf/parser/GLTFAnimatorControllerParser.ts b/packages/loader/src/gltf/parser/GLTFAnimatorControllerParser.ts index c37921e29d..47bbdda64b 100644 --- a/packages/loader/src/gltf/parser/GLTFAnimatorControllerParser.ts +++ b/packages/loader/src/gltf/parser/GLTFAnimatorControllerParser.ts @@ -41,7 +41,7 @@ export class GLTFAnimatorControllerParser extends GLTFParser { const name = animationClip.name; const uniqueName = animatorStateMachine.makeUniqueStateName(name); if (uniqueName !== name) { - console.warn(`AnimatorState name is existed, name: ${name} reset to ${uniqueName}`); + console.warn(`AnimatorStateDef name is existed, name: ${name} reset to ${uniqueName}`); } const animatorState = animatorStateMachine.addState(uniqueName); animatorState.clip = animationClip; From e94f0366d71dc3c34a5a6b47ea19abffcc4634cf Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 15:43:55 +0800 Subject: [PATCH 60/92] refactor(animation): adapt tests, e2e and docs to AnimatorState view API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the type split in the previous commit: - Tests previously written against playData.state (the shared asset back-reference on the intermediate handle) now use `.def` directly on the AnimatorState view returned by findAnimatorState. - Tests that exercise per-instance speed override use `state.speed = ...` without any extra hop. Tests that intentionally mutate the shared asset (the setup hook resetting wrapMode / clipStart/EndTime to defaults before each case) now go through `state.def.xxx`, matching the new type contract. - The internal AnimatorStatePlayData fields (`_playedTime`, `_clipTime`, `_playState`) accessed via `@ts-ignore` are renamed without the underscore: they now live on AnimatorStateRuntime, which is fully internal, so the underscore convention no longer applies. Test sites that used `srcPlayData._playedTime` now read `srcRuntime.playedTime`. - e2e cases (animator-additive, -event, -stateMachine, -stateMachineScript) drop the `.state` indirection. State-machine assembly through AnimatorStateTransition / AnimatorStateMachine / StateMachineScript now uses AnimatorStateDef consistently — the e2e for StateMachineScript imports AnimatorStateDef instead of AnimatorState. - Docs (en/zh animator.mdx): - Pause example: `state.speed = 0` is now a single-line per-instance write; the old comments about live-bind vs. override two-state model are gone because the new API does not require users to reason about that anymore. - Get-state section: explains that findAnimatorState returns a per-Animator view, with `state.speed` writable as per-instance and `state.def` available as the escape hatch when you really need to mutate the shared asset (broadcast to every Animator using the same controller). --- docs/en/animation/animator.mdx | 35 +- docs/zh/animation/animator.mdx | 35 +- e2e/case/animator-additive.ts | 2 +- e2e/case/animator-event.ts | 2 +- e2e/case/animator-stateMachine.ts | 42 +-- e2e/case/animator-stateMachineScript.ts | 10 +- tests/src/core/Animator.test.ts | 432 ++++++++++++------------ 7 files changed, 278 insertions(+), 280 deletions(-) diff --git a/docs/en/animation/animator.mdx b/docs/en/animation/animator.mdx index 556c11eb04..1ff7d185bc 100644 --- a/docs/en/animation/animator.mdx +++ b/docs/en/animation/animator.mdx @@ -113,22 +113,20 @@ animator.speed = 0; animator.speed = 1; ``` -If you only want to pause a specific `AnimatorState` , you can do so by setting its speed to 0. +If you only want to pause a specific `AnimatorState` on this Animator instance, set the per-instance speed to 0. ```typescript -const playData = animator.findAnimatorState("xxx"); -if (!playData) { +const state = animator.findAnimatorState("xxx"); +if (!state) { // State not found in any layer return; } // Pause only this Animator instance's playback of the state. -playData.speed = 0; +state.speed = 0; -// To resume later, just assign the desired speed again, e.g.: -// playData.speed = 1; -// or follow whatever the asset is currently configured to: -// playData.speed = playData.state.speed; +// To resume later, write any non-zero value: +// state.speed = 1; ``` ### Transition to Specified Animation State @@ -155,23 +153,24 @@ if (currentState) { ### Get Animation State -You can use the [findAnimatorState](/apis/core/#Animator-findAnimatorState) method to get the playback data for a specified `AnimatorState`. The return type is `AnimatorStatePlayData | null` (`null` when no layer contains a state with that name). +[findAnimatorState](/apis/core/#Animator-findAnimatorState) returns the per-Animator `AnimatorState` view for the named state (`AnimatorState | null`). Writes on the view (e.g. `state.speed`) only affect this Animator — the shared `AnimatorStateDef` asset on the controller is untouched. Mirrors the `Renderer.getInstanceMaterial` pattern. -The returned `AnimatorStatePlayData` exposes two distinct surfaces: - -- `playData.speed` — per-Animator playback speed. **Until written**, it live-binds to `state.speed`; once you write a value the instance owns its own speed, and later changes to `state.speed` no longer flow through. To follow the asset again, simply assign `playData.state.speed` back. Reading and writing only affects this `Animator` instance and won't affect other `Animator` instances sharing the same `AnimatorController`. -- `playData.state` — the underlying shared `AnimatorState` asset. Mutating fields like `wrapMode` mutates the shared `AnimatorController` asset and therefore affects every `Animator` using this controller. +- `state.speed` — per-Animator playback speed. Until written, reads through to the shared default; once written, the instance owns its own speed. +- `state.name`, `state.clip`, `state.wrapMode`, `state.clipStartTime`, `state.clipEndTime` — read-only forwards from the shared `AnimatorStateDef`. +- `state.def` — the underlying shared `AnimatorStateDef` asset. Mutating fields like `def.wrapMode` mutates the shared `AnimatorController` asset and therefore affects every `Animator` using this controller. ```typescript -const playData = animator.findAnimatorState("xxx"); -if (!playData) { +const state = animator.findAnimatorState("xxx"); +if (!state) { // State not found in any layer return; } -// Mutating the shared AnimatorState asset (affects all Animators using this controller). -playData.state.wrapMode = WrapMode.Once; -playData.state.wrapMode = WrapMode.Loop; +// Per-instance override (this Animator only) +state.speed = 0.5; + +// Shared asset configuration (broadcast to every Animator using this controller) +state.def.wrapMode = WrapMode.Once; ``` ### Animation Culling diff --git a/docs/zh/animation/animator.mdx b/docs/zh/animation/animator.mdx index b3964fd146..fedb909512 100644 --- a/docs/zh/animation/animator.mdx +++ b/docs/zh/animation/animator.mdx @@ -117,22 +117,20 @@ animator.speed = 1; ``` -如果你只想针对某一个 `动画状态` 进行暂停,可以通过将它的速度设置为 0 来实现。 +如果你只想针对当前 `Animator` 上的某个 `动画状态` 进行暂停,将它的 per-instance 速度设为 0 即可。 ```typescript -const playData = animator.findAnimatorState("xxx"); -if (!playData) { +const state = animator.findAnimatorState("xxx"); +if (!state) { // 任何一个动画层都没有该状态 return; } // 仅暂停当前 Animator 实例的该状态播放。 -playData.speed = 0; +state.speed = 0; -// 想要恢复时再写一次目标速度即可,例如: -// playData.speed = 1; -// 或者跟随当前 asset 配置: -// playData.speed = playData.state.speed; +// 想要恢复时写回任意非零速度即可: +// state.speed = 1; ``` ### 过渡指定动画状态 @@ -160,23 +158,24 @@ if (currentState) { ### 获取动画状态 -你可以使用 [findAnimatorState](/apis/core/#Animator-findAnimatorState) 方法来获取指定名称 `动画状态` 的播放数据。返回类型为 `AnimatorStatePlayData | null`(当任何一个动画层都没有该状态时返回 `null`)。 +[findAnimatorState](/apis/core/#Animator-findAnimatorState) 返回当前 `Animator` 独有的 `AnimatorState` 视图(`AnimatorState | null`)。对该视图的写入(如 `state.speed`)只影响当前 Animator,控制器上的共享 `AnimatorStateDef` 资产不受影响。模式与 `Renderer.getInstanceMaterial` 一致。 -返回的 `AnimatorStatePlayData` 提供两套语义不同的访问入口: - -- `playData.speed`:每个 `Animator` 实例独立的播放速度。**未写入前**实时绑定到 `state.speed`,一旦写入即由该实例自行持有,之后修改 `state.speed` 不会再传递到此实例;想再次跟随 asset 时直接写回 `playData.state.speed` 即可。读写只影响当前 `Animator` 实例,不会影响其他共享同一 `AnimatorController` 的 `Animator` 实例。 -- `playData.state`:底层共享的 `AnimatorState` 资产。对 `wrapMode` 等字段的修改会改变共享的 `AnimatorController` 资产,因此会影响所有使用该控制器的 `Animator`。 +- `state.speed`:每个 `Animator` 独立的播放速度。未写入前透传到共享默认值,写入后由当前实例独占。 +- `state.name` / `state.clip` / `state.wrapMode` / `state.clipStartTime` / `state.clipEndTime`:从共享 `AnimatorStateDef` 转发的只读字段。 +- `state.def`:底层共享的 `AnimatorStateDef` 资产。对 `def.wrapMode` 等字段的修改会改变共享 `AnimatorController` 资产,因此会影响所有使用该控制器的 `Animator`。 ```typescript -const playData = animator.findAnimatorState("xxx"); -if (!playData) { +const state = animator.findAnimatorState("xxx"); +if (!state) { // 任何一个动画层都没有该状态 return; } -// 修改共享的 AnimatorState 资产(影响所有使用该控制器的 Animator) -playData.state.wrapMode = WrapMode.Once; -playData.state.wrapMode = WrapMode.Loop; +// per-instance 覆盖(只影响当前 Animator) +state.speed = 0.5; + +// 修改共享 asset(影响所有使用该控制器的 Animator) +state.def.wrapMode = WrapMode.Once; ``` ### 动画裁剪 diff --git a/e2e/case/animator-additive.ts b/e2e/case/animator-additive.ts index b9c4becee6..13e8fb6e99 100644 --- a/e2e/case/animator-additive.ts +++ b/e2e/case/animator-additive.ts @@ -60,7 +60,7 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { if (!playData) { throw new Error(`Animator state not found: ${name}`); } - const clip = playData.state.clip; + const clip = playData.clip; const newState = animatorStateMachine.addState(name); newState.clipStartTime = 1; newState.clip = clip; diff --git a/e2e/case/animator-event.ts b/e2e/case/animator-event.ts index 193889eedf..eb83a74734 100644 --- a/e2e/case/animator-event.ts +++ b/e2e/case/animator-event.ts @@ -56,7 +56,7 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { if (!state) { throw new Error("Animator state not found: walk"); } - const clip = state.state.clip; + const clip = state.clip; const event0 = new AnimationEvent(); event0.functionName = "event0"; diff --git a/e2e/case/animator-stateMachine.ts b/e2e/case/animator-stateMachine.ts index 34f1b7a899..7b3ee258a9 100644 --- a/e2e/case/animator-stateMachine.ts +++ b/e2e/case/animator-stateMachine.ts @@ -67,62 +67,62 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState.state; + toWalkTransition.destinationState = walkState.def.def; toWalkTransition.duration = 0.2; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.state.addTransition(toWalkTransition); + idleState.def.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - toWalkTransition.exitTime * idleState.state._getDuration() + + toWalkTransition.exitTime * idleState.def._getDuration() + //@ts-ignore - toWalkTransition.duration * walkState.state._getDuration(); + toWalkTransition.duration * walkState.def._getDuration(); - const exitTransition = idleState.state.addExitTransition(); + const exitTransition = idleState.def.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState.state; + toRunTransition.destinationState = runState.def.def; toRunTransition.duration = 0.3; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.state.addTransition(toRunTransition); + walkState.def.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * walkState.state._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * walkState.def._getDuration() + //@ts-ignore - toRunTransition.duration * runState.state._getDuration(); + toRunTransition.duration * runState.def._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState.state; + toIdleTransition.destinationState = idleState.def.def; toIdleTransition.duration = 0.3; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.state.addTransition(toIdleTransition); + walkState.def.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * walkState.state._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * walkState.def._getDuration() + //@ts-ignore - toIdleTransition.duration * idleState.state._getDuration(); + toIdleTransition.duration * idleState.def._getDuration(); // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = walkState.state; + runToWalkTransition.destinationState = walkState.def.def; runToWalkTransition.duration = 0.3; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.state.addTransition(runToWalkTransition); + runState.def.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (runToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * runState.def._getDuration() + //@ts-ignore - runToWalkTransition.duration * walkState.state._getDuration(); + runToWalkTransition.duration * walkState.def._getDuration(); - stateMachine.addEntryStateTransition(idleState.state); + stateMachine.addEntryStateTransition(idleState.def); - const anyTransition = stateMachine.addAnyStateTransition(idleState.state); + const anyTransition = stateMachine.addAnyStateTransition(idleState.def); anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); anyTransition.duration = 0.3; let anyToIdleTime = // @ts-ignore - (anyTransition.exitTime - toIdleTransition.duration) * walkState.state._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * walkState.def._getDuration() + // @ts-ignore - anyTransition.duration * idleState.state._getDuration(); + anyTransition.duration * idleState.def._getDuration(); engine.time.maximumDeltaTime = 10000; updateForE2E(engine, (idleToWalkTime + walkToRunTime) * 1000, 1); diff --git a/e2e/case/animator-stateMachineScript.ts b/e2e/case/animator-stateMachineScript.ts index a6d4ebbb92..9164ad46fa 100644 --- a/e2e/case/animator-stateMachineScript.ts +++ b/e2e/case/animator-stateMachineScript.ts @@ -4,7 +4,7 @@ */ import { Animator, - AnimatorState, + AnimatorStateDef, Camera, Color, DirectLight, @@ -60,18 +60,18 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { throw new Error("Animator state not found: walk"); } - state.state.addStateMachineScript( + state.def.addStateMachineScript( class extends StateMachineScript { - onStateEnter(animator: Animator, animatorState: AnimatorState, layerIndex: number): void { + onStateEnter(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void { textRenderer.text = "0"; console.log("onStateEnter: ", animatorState); } - onStateUpdate(animator: Animator, animatorState: AnimatorState, layerIndex: number): void { + onStateUpdate(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void { console.log("onStateUpdate: ", animatorState); } - onStateExit(animator: Animator, animatorState: AnimatorState, layerIndex: number): void { + onStateExit(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void { textRenderer.text = "1"; console.log("onStateExit: ", animatorState); } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index bd4e2c9fa0..7493a081f4 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -70,17 +70,17 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); stateMachine.clearEntryStateTransitions(); - // 清理各状态的 transitions 并恢复默认属性 (mutate shared AnimatorState) + // 清理各状态的 transitions 并恢复默认属性 (mutate shared AnimatorStateDef) const stateNames = ["Survey", "Walk", "Run"]; for (const name of stateNames) { - const playData = animator.findAnimatorState(name); - if (playData) { - const state = playData.state; - state.clearTransitions(); - state.speed = 1; - state.clipStartTime = 0; - state.clipEndTime = 1; - state.wrapMode = WrapMode.Loop; + const view = animator.findAnimatorState(name); + if (view) { + const def = view.def; + def.clearTransitions(); + def.speed = 1; + def.clipStartTime = 0; + def.clipEndTime = 1; + def.wrapMode = WrapMode.Loop; } } }); @@ -102,29 +102,29 @@ describe("Animator test", function () { animator.play("Run"); let animatorLayerData = animator["_animatorLayersData"]; - const srcPlayData = animatorLayerData[0]?.srcPlayData; + const srcRuntime = animatorLayerData[0]?.srcRuntime; const speed = 1; let expectedSpeed = speed * 0.5; animator.speed = expectedSpeed; - let playedTime = srcPlayData._playedTime; + let playedTime = srcRuntime.playedTime; // @ts-ignore animator.engine.time._frameCount++; animator.update(5); expect(animator.speed).to.eq(expectedSpeed); - expect(srcPlayData._playedTime).to.eq(playedTime + 5 * expectedSpeed); + expect(srcRuntime.playedTime).to.eq(playedTime + 5 * expectedSpeed); expectedSpeed = speed * 2; animator.speed = expectedSpeed; - playedTime = srcPlayData._playedTime; + playedTime = srcRuntime.playedTime; animator.update(10); expect(animator.speed).to.eq(expectedSpeed); - expect(srcPlayData._playedTime).to.eq(playedTime + 10 * expectedSpeed); + expect(srcRuntime.playedTime).to.eq(playedTime + 10 * expectedSpeed); expectedSpeed = speed * 0; animator.speed = expectedSpeed; - playedTime = srcPlayData._playedTime; + playedTime = srcRuntime.playedTime; animator.update(15); expect(animator.speed).to.eq(expectedSpeed); - expect(srcPlayData._playedTime).to.eq(playedTime + 15 * expectedSpeed); + expect(srcRuntime.playedTime).to.eq(playedTime + 15 * expectedSpeed); }); it("play animation", () => { @@ -159,9 +159,9 @@ describe("Animator test", function () { animator.play("Run"); let animatorLayerData = animator["_animatorLayersData"]; - const srcPlayData = animatorLayerData[0]?.srcPlayData; + const srcRuntime = animatorLayerData[0]?.srcRuntime; animator.update(5); - const curveOwner = srcPlayData._stateData.curveLayerOwner[0].curveOwner; + const curveOwner = srcRuntime.stateData.curveLayerOwner[0].curveOwner; const initValue = curveOwner.defaultValue; const currentValue = curveOwner.referenceTargetValue; expect(Quaternion.equals(initValue, currentValue)).to.eq(true); @@ -202,12 +202,12 @@ describe("Animator test", function () { animator.play(stateName); const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); let animatorState = animator.findAnimatorState(stateName, layerIndex); - expect(animatorState.state).to.eq(currentAnimatorState); + expect(animatorState.def).to.eq(currentAnimatorState); animator.play(expectedStateName); animatorState = animator.findAnimatorState(expectedStateName, layerIndex); - expect(animatorState.state).not.to.eq(currentAnimatorState); - expect(animatorState.state.name).to.eq(expectedStateName); + expect(animatorState.def).not.to.eq(currentAnimatorState); + expect(animatorState.def.name).to.eq(expectedStateName); }); it("animation getCurrentAnimatorState", () => { @@ -250,26 +250,26 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); - const srcPlayData = layerData.srcPlayData; - expect(srcPlayData.state.name).to.eq("Run"); - expect(srcPlayData._playedTime).to.eq(0.3); + const srcRuntime = layerData.srcRuntime; + expect(srcRuntime.state.name).to.eq("Run"); + expect(srcRuntime.playedTime).to.eq(0.3); // @ts-ignore - expect(srcPlayData._clipTime).to.eq(0.3 + 0.1 * runState.state._getDuration()); + expect(srcRuntime.clipTime).to.eq(0.3 + 0.1 * runState.def._getDuration()); }); it("animation cross fade by transition", () => { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const transition = new AnimatorStateTransition(); - transition.destinationState = runState.state; + transition.destinationState = runState.def; transition.duration = 1; transition.exitTime = 1; - walkState.state.addTransition(transition); + walkState.def.addTransition(transition); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.state.clip.length - 0.1); + animator.update(walkState.def.clip.length - 0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); @@ -312,7 +312,7 @@ describe("Animator test", function () { additiveLayer.mask = mask; additiveLayer.blendingMode = AnimatorLayerBlendingMode.Additive; animatorController.addLayer(additiveLayer); - const clip = animator.findAnimatorState("Run").state.clip; + const clip = animator.findAnimatorState("Run").def.clip; const newState = animatorStateMachine.addState("Run"); newState.clipStartTime = 1; newState.clip = clip; @@ -351,7 +351,7 @@ describe("Animator test", function () { animator.play("Walk"); class TestScript extends Script { - event0(): void { } + event0(): void {} } const testScript = animator.entity.addComponent(TestScript); @@ -362,7 +362,7 @@ describe("Animator test", function () { event0.time = 0; const state = animator.findAnimatorState("Walk"); - state.state.clip.addEvent(event0); + state.clip.addEvent(event0); animator.update(10); expect(testScriptSpy).toHaveBeenCalledTimes(1); }); @@ -373,11 +373,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - idleState.state.clearTransitions(); + idleState.def.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.state.clearTransitions(); + walkState.def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.state.clearTransitions(); + runState.def.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -385,68 +385,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState.state; + toWalkTransition.destinationState = walkState.def; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.9; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.state.addTransition(toWalkTransition); + idleState.def.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - (toWalkTransition.exitTime * idleState.state._getDuration()) / idleSpeed + + (toWalkTransition.exitTime * idleState.def._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * walkState.state._getDuration(); + toWalkTransition.duration * walkState.def._getDuration(); - const exitTransition = idleState.state.addExitTransition(); + const exitTransition = idleState.def.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState.state; + toRunTransition.destinationState = runState.def; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.9; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.state.addTransition(toRunTransition); + walkState.def.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * walkState.state._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * walkState.def._getDuration() + //@ts-ignore - toRunTransition.duration * runState.state._getDuration(); + toRunTransition.duration * runState.def._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState.state; + toIdleTransition.destinationState = idleState.def; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.9; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.state.addTransition(toIdleTransition); + walkState.def.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * walkState.state._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * walkState.def._getDuration() + //@ts-ignore - (toIdleTransition.duration * idleState.state._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState.def._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = walkState.state; + runToWalkTransition.destinationState = walkState.def; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.9; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.state.addTransition(runToWalkTransition); + runState.def.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (runToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * runState.def._getDuration() + //@ts-ignore - runToWalkTransition.duration * walkState.state._getDuration(); + runToWalkTransition.duration * walkState.def._getDuration(); - stateMachine.addEntryStateTransition(idleState.state); + stateMachine.addEntryStateTransition(idleState.def); - const anyTransition = stateMachine.addAnyStateTransition(idleState.state); + const anyTransition = stateMachine.addAnyStateTransition(idleState.def); 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.state._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * walkState.def._getDuration() + // @ts-ignore - (anyTransition.duration * idleState.state._getDuration()) / idleSpeed; + (anyTransition.duration * idleState.def._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -498,11 +498,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - idleState.state.clearTransitions(); + idleState.def.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.state.clearTransitions(); + walkState.def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.state.clearTransitions(); + runState.def.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -510,68 +510,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState.state; + toWalkTransition.destinationState = walkState.def; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.1; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.state.addTransition(toWalkTransition); + idleState.def.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - ((1 - toWalkTransition.exitTime) * idleState.state._getDuration()) / idleSpeed + + ((1 - toWalkTransition.exitTime) * idleState.def._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * walkState.state._getDuration(); + toWalkTransition.duration * walkState.def._getDuration(); - const exitTransition = idleState.state.addExitTransition(); + const exitTransition = idleState.def.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState.state; + toRunTransition.destinationState = runState.def; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.1; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.state.addTransition(toRunTransition); + walkState.def.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (1 - toRunTransition.exitTime - toWalkTransition.duration) * walkState.state._getDuration() + + (1 - toRunTransition.exitTime - toWalkTransition.duration) * walkState.def._getDuration() + //@ts-ignore - toRunTransition.duration * runState.state._getDuration(); + toRunTransition.duration * runState.def._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState.state; + toIdleTransition.destinationState = idleState.def; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.1; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.state.addTransition(toIdleTransition); + walkState.def.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (1 - toIdleTransition.exitTime - toRunTransition.duration) * walkState.state._getDuration() + + (1 - toIdleTransition.exitTime - toRunTransition.duration) * walkState.def._getDuration() + //@ts-ignore - (toIdleTransition.duration * idleState.state._getDuration()) / idleSpeed; + (toIdleTransition.duration * idleState.def._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = walkState.state; + runToWalkTransition.destinationState = walkState.def; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.1; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.state.addTransition(runToWalkTransition); + runState.def.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (1 - runToWalkTransition.exitTime - toRunTransition.duration) * runState.state._getDuration() + + (1 - runToWalkTransition.exitTime - toRunTransition.duration) * runState.def._getDuration() + //@ts-ignore - runToWalkTransition.duration * walkState.state._getDuration(); + runToWalkTransition.duration * walkState.def._getDuration(); - stateMachine.addEntryStateTransition(idleState.state); + stateMachine.addEntryStateTransition(idleState.def); - const anyTransition = stateMachine.addAnyStateTransition(idleState.state); + const anyTransition = stateMachine.addAnyStateTransition(idleState.def); 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.state._getDuration() + + (1 - anyTransition.exitTime - toIdleTransition.duration) * walkState.def._getDuration() + // @ts-ignore - (anyTransition.duration * idleState.state._getDuration()) / idleSpeed; + (anyTransition.duration * idleState.def._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -615,10 +615,10 @@ describe("Animator test", function () { it("transitionOffset", () => { const walkState = animator.findAnimatorState("Walk"); - walkState.state.clearTransitions(); + walkState.def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.state.clearTransitions(); - const toRunTransition = walkState.state.addTransition(runState.state); + runState.def.clearTransitions(); + const toRunTransition = walkState.def.addTransition(runState.def); toRunTransition.exitTime = 0; toRunTransition.duration = 1; toRunTransition.offset = 0.5; @@ -627,40 +627,40 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.01); - const destPlayData = animator["_animatorLayersData"][0].destPlayData; - const destState = destPlayData.state; + const destRuntime = animator["_animatorLayersData"][0].destRuntime; + const destState = destRuntime.state; const transitionDuration = toRunTransition.duration * destState._getDuration(); - const crossWeight = animator["_animatorLayersData"][0].destPlayData._playedTime / transitionDuration; + const crossWeight = animator["_animatorLayersData"][0].destRuntime.playedTime / transitionDuration; expect(crossWeight).to.lessThan(0.01); }); it("clipStartTime crossFade", () => { const walkState = animator.findAnimatorState("Walk"); - walkState.state.wrapMode = WrapMode.Once; - walkState.state.clipStartTime = 0.8; - walkState.state.clearTransitions(); + walkState.def.wrapMode = WrapMode.Once; + walkState.def.clipStartTime = 0.8; + walkState.def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.state.clearTransitions(); - const toRunTransition = walkState.state.addTransition(runState.state); + runState.def.clearTransitions(); + const toRunTransition = walkState.def.addTransition(runState.def); toRunTransition.exitTime = 0.5; toRunTransition.duration = 1; - runState.state.clipStartTime = 0.5; + runState.def.clipStartTime = 0.5; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - const destPlayData = animator["_animatorLayersData"][0].destPlayData; - expect(destPlayData.state?.name).to.eq("Run"); + const destRuntime = animator["_animatorLayersData"][0].destRuntime; + expect(destRuntime.state?.name).to.eq("Run"); }); it("transition to exit but no entry", () => { const animatorLayerData = animator["_animatorLayersData"]; const walkState = animator.findAnimatorState("Walk"); - walkState.state.wrapMode = WrapMode.Once; - walkState.state.clearTransitions(); - walkState.state.addExitTransition(); + walkState.def.wrapMode = WrapMode.Once; + walkState.def.clearTransitions(); + walkState.def.addExitTransition(); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -793,8 +793,8 @@ describe("Animator test", function () { animator.animatorController = animatorController; class TestScript extends StateMachineScript { - onStateEnter(animator) { } - onStateExit(animator) { } + onStateEnter(animator) {} + onStateExit(animator) {} } const testScript = state1.addStateMachineScript(TestScript); @@ -827,15 +827,15 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Run"); // For test clipStartTime is not 0 and transition duration is 0 - walkState.state.clipStartTime = 0.5; - walkState.state.addStateMachineScript( + walkState.def.clipStartTime = 0.5; + walkState.def.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator) { animator.setParameterValue("playRun", 0); } } ); - const transition = stateMachine.addAnyStateTransition(animator.findAnimatorState("Run").state); + const transition = stateMachine.addAnyStateTransition(animator.findAnimatorState("Run").def); transition.addCondition("playRun", AnimatorConditionMode.Equals, 1); // For test clipStartTime is not 0 and transition duration is 0 transition.duration = 0; @@ -846,9 +846,9 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.5); - expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData._playedTime).to.eq(0.5); - expect(layerData.srcPlayData._clipTime).to.eq(walkState.state.clip.length * 0.5 + 0.5); + expect(layerData.srcRuntime.state.name).to.eq("Run"); + expect(layerData.srcRuntime.playedTime).to.eq(0.5); + expect(layerData.srcRuntime.clipTime).to.eq(walkState.def.clip.length * 0.5 + 0.5); }); it("hasExitTime", () => { @@ -861,13 +861,13 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const idleState = animator.findAnimatorState("Survey"); idleState.speed = 1; - idleState.state.clearTransitions(); + idleState.def.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.state.clipStartTime = 0; - walkState.state.clearTransitions(); + walkState.def.clipStartTime = 0; + walkState.def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.state.clearTransitions(); - const walkToRunTransition = walkState.state.addTransition(runState.state); + runState.def.clearTransitions(); + const walkToRunTransition = walkState.def.addTransition(runState.def); walkToRunTransition.hasExitTime = true; walkToRunTransition.exitTime = 0.5; walkToRunTransition.duration = 0; @@ -875,10 +875,10 @@ describe("Animator test", function () { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.state.clip.length * 0.5); - expect(layerData.destPlayData.state.name).to.eq("Run"); - expect(layerData.destPlayData._playedTime).to.eq(0); - const anyToIdleTransition = stateMachine.addAnyStateTransition(idleState.state); + animator.update(walkState.def.clip.length * 0.5); + expect(layerData.destRuntime.state.name).to.eq("Run"); + expect(layerData.destRuntime.playedTime).to.eq(0); + const anyToIdleTransition = stateMachine.addAnyStateTransition(idleState.def); anyToIdleTransition.hasExitTime = false; anyToIdleTransition.duration = 0.2; anyToIdleTransition.addCondition("triggerIdle", AnimatorConditionMode.If, true); @@ -886,13 +886,13 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData._playedTime).to.eq(0.1); + expect(layerData.srcRuntime.state.name).to.eq("Run"); + expect(layerData.srcRuntime.playedTime).to.eq(0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update(idleState.state.clip.length * 0.2 - 0.1); - expect(layerData.srcPlayData.state.name).to.eq("Survey"); - expect(layerData.srcPlayData._clipTime).to.eq(idleState.state.clip.length * 0.2); + animator.update(idleState.def.clip.length * 0.2 - 0.1); + expect(layerData.srcRuntime.state.name).to.eq("Survey"); + expect(layerData.srcRuntime.clipTime).to.eq(idleState.def.clip.length * 0.2); }); it("setTriggerParameter", () => { @@ -905,16 +905,16 @@ describe("Animator test", function () { stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.state.clearTransitions(); + walkState.def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.state.clipStartTime = 0; - runState.state.clearTransitions(); - const walkToRunTransition = walkState.state.addTransition(runState.state); + runState.def.clipStartTime = 0; + runState.def.clearTransitions(); + const walkToRunTransition = walkState.def.addTransition(runState.def); walkToRunTransition.hasExitTime = false; walkToRunTransition.duration = 0.1; walkToRunTransition.addCondition("triggerRun", AnimatorConditionMode.If, true); - const runToWalkTransition = runState.state.addTransition(walkState.state); + const runToWalkTransition = runState.def.addTransition(walkState.def); runToWalkTransition.hasExitTime = true; runToWalkTransition.exitTime = 0.7; runToWalkTransition.duration = 0.3; @@ -926,28 +926,28 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.state.name).to.eq("Walk"); - expect(layerData.srcPlayData._playedTime).to.eq(0.1); - expect(layerData.destPlayData.state.name).to.eq("Run"); - expect(layerData.destPlayData._playedTime).to.eq(0.1); + expect(layerData.srcRuntime.state.name).to.eq("Walk"); + expect(layerData.srcRuntime.playedTime).to.eq(0.1); + expect(layerData.destRuntime.state.name).to.eq("Run"); + expect(layerData.destRuntime.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.state.clip.length * 0.1 - 0.1); - expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData._playedTime).to.eq(runState.state.clip.length * 0.1); + animator.update(runState.def.clip.length * 0.1 - 0.1); + expect(layerData.srcRuntime.state.name).to.eq("Run"); + expect(layerData.srcRuntime.playedTime).to.eq(runState.def.clip.length * 0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update(runState.state.clip.length * 0.6); - expect(layerData.destPlayData.state.name).to.eq("Walk"); - expect(layerData.destPlayData._playedTime).to.eq(0); + animator.update(runState.def.clip.length * 0.6); + expect(layerData.destRuntime.state.name).to.eq("Walk"); + expect(layerData.destRuntime.playedTime).to.eq(0); expect(animator.getParameterValue("triggerWalk")).to.eq(false); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.state.clip.length * 0.3); - expect(layerData.srcPlayData.state.name).to.eq("Walk"); - expect(layerData.srcPlayData._playedTime).to.eq(walkState.state.clip.length * 0.3); + animator.update(walkState.def.clip.length * 0.3); + expect(layerData.srcRuntime.state.name).to.eq("Walk"); + expect(layerData.srcRuntime.playedTime).to.eq(walkState.def.clip.length * 0.3); }); it("fixedDuration", () => { @@ -957,11 +957,11 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); const walkState = animator.findAnimatorState("Walk"); - walkState.state.clearTransitions(); + walkState.def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.state.clipStartTime = runState.state.clipEndTime = 0; - runState.state.clearTransitions(); - const walkToRunTransition = walkState.state.addTransition(runState.state); + runState.def.clipStartTime = runState.def.clipEndTime = 0; + runState.def.clearTransitions(); + const walkToRunTransition = walkState.def.addTransition(runState.def); walkToRunTransition.hasExitTime = false; walkToRunTransition.isFixedDuration = true; walkToRunTransition.duration = 0.1; @@ -971,9 +971,9 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcPlayData.state.name).to.eq("Run"); - expect(layerData.srcPlayData._playedTime).to.eq(0.1); - expect(layerData.srcPlayData._clipTime).to.eq(0); + expect(layerData.srcRuntime.state.name).to.eq("Run"); + expect(layerData.srcRuntime.playedTime).to.eq(0.1); + expect(layerData.srcRuntime.clipTime).to.eq(0); }); it("transitionIndex", () => { @@ -1034,13 +1034,13 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.6); - expect(animatorLayerData[0]?.srcPlayData.state.name).to.eq("state1"); + expect(animatorLayerData[0]?.srcRuntime.state.name).to.eq("state1"); transition2.mute = false; // @ts-ignore animator.engine.time._frameCount++; animator.update(0.3); - expect(animatorLayerData[0]?.srcPlayData.state.name).to.eq("state2"); + expect(animatorLayerData[0]?.srcRuntime.state.name).to.eq("state2"); }); it("Clone", () => { @@ -1096,7 +1096,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition(idleState.state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState.def); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1113,7 +1113,7 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.state.name).to.eq("Run"); + expect(layerData.destRuntime.state.name).to.eq("Run"); // Trigger interrupt during crossFade animator.setParameterValue("interrupt", true); @@ -1122,7 +1122,7 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destPlayData.state.name).to.eq("Survey"); + expect(layerData.destRuntime.state.name).to.eq("Survey"); }); it("noExitTime transition scan should ignore exitTime transitions", () => { @@ -1134,12 +1134,12 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - walkState.state.clipStartTime = 0; - walkState.state.clipEndTime = 1; - walkState.state.clearTransitions(); + walkState.def.clipStartTime = 0; + walkState.def.clipEndTime = 1; + walkState.def.clearTransitions(); // A noExitTime transition that fails (ensures noExitTimeCount > 0). - const noExitFailTransition = walkState.state.addTransition(idleState.state); + const noExitFailTransition = walkState.def.addTransition(idleState.def); noExitFailTransition.hasExitTime = false; noExitFailTransition.duration = 0; noExitFailTransition.addCondition("never", AnimatorConditionMode.If, true); @@ -1148,26 +1148,26 @@ describe("Animator test", function () { const exitTimeTransition = new AnimatorStateTransition(); exitTimeTransition.exitTime = 0.5; exitTimeTransition.duration = 0; - exitTimeTransition.destinationState = runState.state; + exitTimeTransition.destinationState = runState.def; exitTimeTransition.addCondition("goRun", AnimatorConditionMode.If, true); - walkState.state.addTransition(exitTimeTransition); + walkState.def.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.state.clip.length * 0.25; + const preExitDeltaTime = walkState.def.clip.length * 0.25; // @ts-ignore animator.engine.time._frameCount++; animator.update(preExitDeltaTime); - expect(layerData.srcPlayData.state.name).to.eq("Walk"); - expect(layerData.destPlayData).to.be.null; + expect(layerData.srcRuntime.state.name).to.eq("Walk"); + expect(layerData.destRuntime).to.be.null; // Update past exitTime, should transition to Run. // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.state.clip.length * 0.5); + animator.update(walkState.def.clip.length * 0.5); expect(animator.getCurrentAnimatorState(0).name).to.eq("Run"); }); @@ -1179,17 +1179,17 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition(idleState.state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState.def); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); // Play Walk with Once mode, let it finish to reach Finished state - walkState.state.wrapMode = WrapMode.Once; + walkState.def.wrapMode = WrapMode.Once; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.state.clip.length + 0.1); + animator.update(walkState.def.clip.length + 0.1); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); @@ -1203,7 +1203,7 @@ describe("Animator test", function () { animator.update(0.1); expect(layerData.layerState).to.eq(LayerState.FixedCrossFading); - expect(layerData.destPlayData.state.name).to.eq("Run"); + expect(layerData.destRuntime.state.name).to.eq("Run"); // Trigger interrupt during FixedCrossFading animator.setParameterValue("interrupt", true); @@ -1212,7 +1212,7 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destPlayData.state.name).to.eq("Survey"); + expect(layerData.destRuntime.state.name).to.eq("Survey"); }); it("anyState interrupt should skip transition to same destination state", () => { @@ -1222,7 +1222,7 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); // AnyState -> Run (always true, noExitTime) - const anyToRun = stateMachine.addAnyStateTransition(runState.state); + const anyToRun = stateMachine.addAnyStateTransition(runState.def); anyToRun.hasExitTime = false; anyToRun.duration = 0.2; anyToRun.addCondition("alwaysTrue", AnimatorConditionMode.If, true); @@ -1239,7 +1239,7 @@ describe("Animator test", function () { // Should be in CrossFading state, dest = Run expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.state.name).to.eq("Run"); + expect(layerData.destRuntime.state.name).to.eq("Run"); // Update again - anyState -> Run should be skipped because dest is already Run // @ts-ignore @@ -1248,7 +1248,7 @@ describe("Animator test", function () { // Should still be CrossFading to Run (not interrupted/reset) expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destPlayData.state.name).to.eq("Run"); + expect(layerData.destRuntime.state.name).to.eq("Run"); }); it("zero-duration crossFade should not be interrupted by anyState transition", () => { @@ -1258,7 +1258,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (always true, noExitTime) - const anyToIdle = stateMachine.addAnyStateTransition(idleState.state); + const anyToIdle = stateMachine.addAnyStateTransition(idleState.def); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1274,26 +1274,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.state.name).to.eq("Run"); + expect(layerData.srcRuntime.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.state.clearTransitions(); + walkState.def.clearTransitions(); // Add a noExitTime transition - const t1 = walkState.state.addTransition(runState.state); + const t1 = walkState.def.addTransition(runState.def); t1.hasExitTime = false; // Add a hasExitTime transition - const t2 = walkState.state.addTransition(idleState.state); + const t2 = walkState.def.addTransition(idleState.def); t2.hasExitTime = true; t2.exitTime = 0.5; // @ts-ignore - const collection = walkState.state._transitionCollection; + const collection = walkState.def._transitionCollection; expect(collection.noExitTimeCount).to.eq(1); expect(collection.count).to.eq(2); @@ -1320,8 +1320,8 @@ describe("Animator test", function () { const survey = cloneAnimator.findAnimatorState("Survey"); expect(survey).to.not.eq(null); - expect(survey.state.name).to.eq("Survey"); - expect(survey.speed).to.eq(survey.state.speed); // live-bound default + expect(survey.name).to.eq("Survey"); + expect(survey.speed).to.eq(survey.def.speed); // live-bound default // Same handle returned on subsequent calls (verifies caching) expect(cloneAnimator.findAnimatorState("Survey")).to.eq(survey); @@ -1336,7 +1336,7 @@ describe("Animator test", function () { animator.update(0.001); // Same handle observed via getCurrentAnimatorState - expect(animator.getCurrentAnimatorState(0)).to.eq(handle.state); + expect(animator.getCurrentAnimatorState(0)).to.eq(handle); expect(handle.speed).to.eq(0.5); }); @@ -1359,10 +1359,10 @@ describe("Animator test", function () { animator.update(0.1); // @ts-ignore - const srcPlayData = animator._animatorLayersData[0].srcPlayData; - expect(srcPlayData.state.name).to.eq("Survey"); // ensure crossfade actually completed back to Survey + const srcRuntime = animator._animatorLayersData[0].srcRuntime; + expect(srcRuntime.state.name).to.eq("Survey"); // ensure crossfade actually completed back to Survey expect(animator.findAnimatorState("Survey").speed).to.eq(0.5); - expect(srcPlayData.speed).to.eq(0.5); + expect(srcRuntime.speed).to.eq(0.5); }); it("per-instance speed is per-Animator (clone isolation)", () => { @@ -1379,7 +1379,7 @@ describe("Animator test", function () { expect(sharedSurvey.speed).to.eq(1); }); - it("crossFade phase uses playData.speed for time progression", () => { + it("crossFade phase uses runtime.speed for time progression", () => { // Set high per-instance speed on src state animator.findAnimatorState("Survey").speed = 4; animator.play("Survey"); @@ -1389,17 +1389,17 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - const srcPlayedBefore = layerData.srcPlayData._playedTime; + const srcPlayedBefore = layerData.srcRuntime.playedTime; - // Start crossFade — during crossFade, src should still advance per playData.speed=4 + // Start crossFade — during crossFade, src should still advance per runtime.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 srcPlayedAfter = layerData.srcRuntime.playedTime; const advanced = srcPlayedAfter - srcPlayedBefore; - // With playData.speed=4 and dt=0.05, expect ~0.2 (4 * 0.05). With state.speed=1 it'd be ~0.05. + // With runtime.speed=4 and dt=0.05, expect ~0.2 (4 * 0.05). With state.speed=1 it'd be ~0.05. expect(advanced).to.be.closeTo(0.2, 0.05); }); @@ -1407,7 +1407,7 @@ describe("Animator test", function () { const sm = animator.animatorController.layers[0].stateMachine; const oldSurvey = animator.findAnimatorState("Survey"); expect(oldSurvey).not.to.eq(null); - const oldStateRef = oldSurvey.state; + const oldStateRef = oldSurvey.def; const originalIndex = sm.states.indexOf(oldStateRef); // Simulate dynamic controller mutation: remove and re-add same-name state @@ -1417,7 +1417,7 @@ describe("Animator test", function () { const newHandle = animator.findAnimatorState("Survey"); expect(newHandle).not.to.eq(null); - expect(newHandle.state).to.eq(newStateRef); + expect(newHandle.def).to.eq(newStateRef); expect(newHandle).not.to.eq(oldSurvey); // Restore original Survey state so subsequent tests still see the @@ -1577,7 +1577,7 @@ describe("Animator test", function () { it("_reset detaches stateData clipChangedListeners so they do not accumulate on the AnimatorState", () => { const survey = animator.findAnimatorState("Survey"); expect(survey).not.to.eq(null); - const surveyState = survey.state; + const surveyState = survey.def; // @ts-ignore — read internal listener list size const listenersBefore = surveyState._updateFlagManager._listeners.length; @@ -1609,15 +1609,15 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - const srcBefore = layerData.srcPlayData; - const playedBefore = srcBefore._playedTime; + const srcBefore = layerData.srcRuntime; + 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); + expect(layerData.srcRuntime).to.eq(srcBefore); + expect(layerData.srcRuntime.playedTime).to.eq(playedBefore); + expect(layerData.destRuntime).to.eq(null); }); it("crossFade to currently-fading dest state is no-op", () => { @@ -1633,22 +1633,22 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - const destBefore = layerData.destPlayData; - const destPlayedBefore = destBefore._playedTime; + const destBefore = layerData.destRuntime; + 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); + expect(layerData.destRuntime).to.eq(destBefore); + expect(layerData.destRuntime.playedTime).to.eq(destPlayedBefore); }); it("state-machine self-transition is also a no-op (alias-guard policy)", () => { const walk = animator.findAnimatorState("Walk"); - walk.state.clearTransitions(); + walk.def.clearTransitions(); animator.animatorController.addParameter("restart", false); - const selfTransition = walk.state.addTransition(walk.state); + const selfTransition = walk.def.addTransition(walk.def); selfTransition.hasExitTime = false; selfTransition.duration = 0.1; selfTransition.addCondition("restart", AnimatorConditionMode.If, true); @@ -1660,8 +1660,8 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - const srcBefore = layerData.srcPlayData; - const playedBefore = srcBefore._playedTime; + const srcBefore = layerData.srcRuntime; + const playedBefore = srcBefore.playedTime; // Trigger the self-transition animator.setParameterValue("restart", true); @@ -1671,13 +1671,13 @@ describe("Animator test", function () { // 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.state.name).to.eq("Walk"); - expect(layerData.srcPlayData._playedTime).to.be.greaterThan(playedBefore); - expect(layerData.destPlayData).to.eq(null); + expect(layerData.srcRuntime).to.eq(srcBefore); + expect(layerData.srcRuntime.state.name).to.eq("Walk"); + expect(layerData.srcRuntime.playedTime).to.be.greaterThan(playedBefore); + expect(layerData.destRuntime).to.eq(null); }); - it("play during crossFade clears stale destPlayData", () => { + it("play during crossFade clears stale destRuntime", () => { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -1693,13 +1693,13 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - expect(layerData.destPlayData).to.eq(null); + expect(layerData.destRuntime).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?.state.name).to.eq("Run"); + expect(layerData.destRuntime?.state.name).to.eq("Run"); }); it("crossFade to nonexistent state is a safe no-op", () => { @@ -1756,22 +1756,22 @@ describe("Animator test", function () { // @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(Number.isNaN(layerData.srcRuntime.playedTime)).to.eq(false); + expect(Number.isNaN(layerData.destRuntime?.playedTime ?? 0)).to.eq(false); // Walk dest should have progressed - expect(layerData.destPlayData?._playedTime).to.be.greaterThan(0); + expect(layerData.destRuntime?.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.state.clearTransitions(); - walk.state.clearTransitions(); + survey.def.clearTransitions(); + walk.def.clearTransitions(); animator.animatorController.addParameter("goWalk", false); survey.speed = 0; // pause source per-instance - const transition = survey.state.addTransition(walk.state); + const transition = survey.def.addTransition(walk.def); transition.hasExitTime = false; transition.duration = 0.3; transition.addCondition("goWalk", AnimatorConditionMode.If, true); @@ -1788,11 +1788,11 @@ describe("Animator test", function () { // @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?.state.name).to.eq("Walk"); + expect(Number.isNaN(layerData.srcRuntime.playedTime)).to.eq(false); + expect(Number.isNaN(layerData.destRuntime?.playedTime ?? 0)).to.eq(false); + expect(layerData.destRuntime?.state.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); + expect(layerData.destRuntime?.playedTime).to.be.greaterThan(0); }); }); From 47fc4ba96ec310737f91a467a7dab6cf5ffc9ecf Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 15:59:28 +0800 Subject: [PATCH 61/92] refactor(animation): demote AnimatorState.def to @internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared AnimatorStateDef is already reachable from animator.animatorController.layers[i].stateMachine.findStateByName — the canonical path for editor / state-machine assembly. Keeping a second short-circuit (state.def) on the per-instance view adds nothing for users while reintroducing the dual-mutation-path problem we set out to remove: state.speed = 0.5 is per-instance, state.def.speed = 0.5 broadcasts to every Animator on the controller, and code review can't tell which is intended without context. Mark the field as @internal so it does not appear in the public .d.ts. Engine-internal call sites (Animator's state-machine evaluation, AnimatorStateRuntime, AnimatorLayerData identity rebuild, AnimatorState itself for getter forwarding) go through state._def. Tests still need def access for state-machine assembly assertions; rather than channeling them through the controller path (which would require restructuring most of the file), they cast to (state as any)._def, making the intent ("test code reaching into internal") explicit. e2e cases are real user-facing examples, so they switch to the canonical state-machine path: stateMachine.findStateByName(name) directly returns AnimatorStateDef and there is no view in the way. This also fixes the walkState.def.def double-hop typo introduced by an earlier mechanical rename. Docs (en/zh) drop the state.def bullet and the state.def.wrapMode example. The shared-asset mutation example now shows the controller long path, reinforcing the design rule that broad-effect changes deserve a visually longer reach. The only public surface on AnimatorState is now: readonly name / clip / wrapMode / clipStartTime / clipEndTime speed (per-instance) --- docs/en/animation/animator.mdx | 10 +- docs/zh/animation/animator.mdx | 10 +- e2e/case/animator-additive.ts | 6 +- e2e/case/animator-stateMachine.ts | 51 ++-- e2e/case/animator-stateMachineScript.ts | 8 +- packages/core/src/animation/Animator.ts | 20 +- packages/core/src/animation/AnimatorState.ts | 41 +-- .../animation/internal/AnimatorLayerData.ts | 2 +- .../internal/AnimatorStateRuntime.ts | 6 +- tests/src/core/Animator.test.ts | 246 +++++++++--------- 10 files changed, 209 insertions(+), 191 deletions(-) diff --git a/docs/en/animation/animator.mdx b/docs/en/animation/animator.mdx index 1ff7d185bc..0311db9f7b 100644 --- a/docs/en/animation/animator.mdx +++ b/docs/en/animation/animator.mdx @@ -157,7 +157,8 @@ if (currentState) { - `state.speed` — per-Animator playback speed. Until written, reads through to the shared default; once written, the instance owns its own speed. - `state.name`, `state.clip`, `state.wrapMode`, `state.clipStartTime`, `state.clipEndTime` — read-only forwards from the shared `AnimatorStateDef`. -- `state.def` — the underlying shared `AnimatorStateDef` asset. Mutating fields like `def.wrapMode` mutates the shared `AnimatorController` asset and therefore affects every `Animator` using this controller. + +To mutate the shared asset (broadcast to every Animator using this controller — typical for editor / asset-construction code), reach it through the controller path. The longer path is a deliberate visual reminder that the change is global. ```typescript const state = animator.findAnimatorState("xxx"); @@ -169,8 +170,11 @@ if (!state) { // Per-instance override (this Animator only) state.speed = 0.5; -// Shared asset configuration (broadcast to every Animator using this controller) -state.def.wrapMode = WrapMode.Once; +// Read-only access to shared asset fields +console.log(state.clip.length, state.wrapMode); + +// Mutating the shared asset (broadcast to every Animator using this controller) +animator.animatorController.layers[0].stateMachine.findStateByName("xxx").wrapMode = WrapMode.Once; ``` ### Animation Culling diff --git a/docs/zh/animation/animator.mdx b/docs/zh/animation/animator.mdx index fedb909512..fd69436c92 100644 --- a/docs/zh/animation/animator.mdx +++ b/docs/zh/animation/animator.mdx @@ -162,7 +162,8 @@ if (currentState) { - `state.speed`:每个 `Animator` 独立的播放速度。未写入前透传到共享默认值,写入后由当前实例独占。 - `state.name` / `state.clip` / `state.wrapMode` / `state.clipStartTime` / `state.clipEndTime`:从共享 `AnimatorStateDef` 转发的只读字段。 -- `state.def`:底层共享的 `AnimatorStateDef` 资产。对 `def.wrapMode` 等字段的修改会改变共享 `AnimatorController` 资产,因此会影响所有使用该控制器的 `Animator`。 + +要修改共享 asset(影响所有使用该控制器的 `Animator`——编辑器/资产搭建场景常见),通过控制器路径访问。这条更长的路径本身就是一个视觉提醒:你做的是全局变更。 ```typescript const state = animator.findAnimatorState("xxx"); @@ -174,8 +175,11 @@ if (!state) { // per-instance 覆盖(只影响当前 Animator) state.speed = 0.5; -// 修改共享 asset(影响所有使用该控制器的 Animator) -state.def.wrapMode = WrapMode.Once; +// 只读访问共享 asset 字段 +console.log(state.clip.length, state.wrapMode); + +// 修改共享 asset(广播到所有使用该控制器的 Animator) +animator.animatorController.layers[0].stateMachine.findStateByName("xxx").wrapMode = WrapMode.Once; ``` ### 动画裁剪 diff --git a/e2e/case/animator-additive.ts b/e2e/case/animator-additive.ts index 13e8fb6e99..462f8f4f51 100644 --- a/e2e/case/animator-additive.ts +++ b/e2e/case/animator-additive.ts @@ -56,11 +56,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const additivePoseNames = animations.filter((clip) => clip.name.includes("pose")).map((clip) => clip.name); additivePoseNames.forEach((name) => { - const playData = animator.findAnimatorState(name); - if (!playData) { + const state = animator.findAnimatorState(name); + if (!state) { throw new Error(`Animator state not found: ${name}`); } - const clip = playData.clip; + const clip = state.clip; const newState = animatorStateMachine.addState(name); newState.clipStartTime = 1; newState.clip = clip; diff --git a/e2e/case/animator-stateMachine.ts b/e2e/case/animator-stateMachine.ts index 7b3ee258a9..3a32a3b899 100644 --- a/e2e/case/animator-stateMachine.ts +++ b/e2e/case/animator-stateMachine.ts @@ -54,10 +54,11 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const animator = defaultSceneRoot.getComponent(Animator)!; animator.animatorController.addParameter("playerSpeed", 1); const stateMachine = animator.animatorController.layers[0].stateMachine; - const idleState = animator.findAnimatorState("idle"); - const walkState = animator.findAnimatorState("walk"); - const runState = animator.findAnimatorState("run"); - if (!idleState || !walkState || !runState) { + // State-machine assembly works on the shared AnimatorStateDef assets via the controller path. + const idleDef = stateMachine.findStateByName("idle"); + const walkDef = stateMachine.findStateByName("walk"); + const runDef = stateMachine.findStateByName("run"); + if (!idleDef || !walkDef || !runDef) { throw new Error("Required animator states not found: idle/walk/run"); } let idleToWalkTime = 0; @@ -67,62 +68,62 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState.def.def; + toWalkTransition.destinationState = walkDef; toWalkTransition.duration = 0.2; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.def.addTransition(toWalkTransition); + idleDef.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - toWalkTransition.exitTime * idleState.def._getDuration() + + toWalkTransition.exitTime * idleDef._getDuration() + //@ts-ignore - toWalkTransition.duration * walkState.def._getDuration(); + toWalkTransition.duration * walkDef._getDuration(); - const exitTransition = idleState.def.addExitTransition(); + const exitTransition = idleDef.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState.def.def; + toRunTransition.destinationState = runDef; toRunTransition.duration = 0.3; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.def.addTransition(toRunTransition); + walkDef.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * walkState.def._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * walkDef._getDuration() + //@ts-ignore - toRunTransition.duration * runState.def._getDuration(); + toRunTransition.duration * runDef._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState.def.def; + toIdleTransition.destinationState = idleDef; toIdleTransition.duration = 0.3; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.def.addTransition(toIdleTransition); + walkDef.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * walkState.def._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * walkDef._getDuration() + //@ts-ignore - toIdleTransition.duration * idleState.def._getDuration(); + toIdleTransition.duration * idleDef._getDuration(); // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = walkState.def.def; + runToWalkTransition.destinationState = walkDef; runToWalkTransition.duration = 0.3; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.def.addTransition(runToWalkTransition); + runDef.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (runToWalkTransition.exitTime - toRunTransition.duration) * runState.def._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * runDef._getDuration() + //@ts-ignore - runToWalkTransition.duration * walkState.def._getDuration(); + runToWalkTransition.duration * walkDef._getDuration(); - stateMachine.addEntryStateTransition(idleState.def); + stateMachine.addEntryStateTransition(idleDef); - const anyTransition = stateMachine.addAnyStateTransition(idleState.def); + const anyTransition = stateMachine.addAnyStateTransition(idleDef); anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); anyTransition.duration = 0.3; let anyToIdleTime = // @ts-ignore - (anyTransition.exitTime - toIdleTransition.duration) * walkState.def._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * walkDef._getDuration() + // @ts-ignore - anyTransition.duration * idleState.def._getDuration(); + anyTransition.duration * idleDef._getDuration(); engine.time.maximumDeltaTime = 10000; updateForE2E(engine, (idleToWalkTime + walkToRunTime) * 1000, 1); diff --git a/e2e/case/animator-stateMachineScript.ts b/e2e/case/animator-stateMachineScript.ts index 9164ad46fa..3fc1a5bb98 100644 --- a/e2e/case/animator-stateMachineScript.ts +++ b/e2e/case/animator-stateMachineScript.ts @@ -55,12 +55,14 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { rootEntity.addChild(defaultSceneRoot); const animator = defaultSceneRoot.getComponent(Animator); - const state = animator.findAnimatorState("walk"); - if (!state) { + // Attaching a StateMachineScript mutates the shared AnimatorStateDef on the controller, + // so reach it through the controller path rather than via a per-Animator state view. + const walkDef = animator.animatorController.layers[0].stateMachine.findStateByName("walk"); + if (!walkDef) { throw new Error("Animator state not found: walk"); } - state.def.addStateMachineScript( + walkDef.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void { textRenderer.text = "0"; diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 9a47d1aa83..cbb8f7940a 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -659,7 +659,7 @@ export class Animator extends Component { aniUpdate: boolean ): void { const { srcRuntime } = layerData; - const state = srcRuntime.state.def; + const state = srcRuntime.state._def; const playSpeed = srcRuntime.state.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; @@ -791,8 +791,8 @@ export class Animator extends Component { ) { const { srcRuntime, destRuntime, layerIndex } = layerData; const { speed } = this; - const srcState = srcRuntime.state.def; - const destState = destRuntime.state.def; + const srcState = srcRuntime.state._def; + const destState = destRuntime.state._def; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, destState, deltaTime, aniUpdate)) { @@ -922,7 +922,7 @@ export class Animator extends Component { aniUpdate: boolean ) { const { destRuntime } = layerData; - const state = destRuntime.state.def; + const state = destRuntime.state._def; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, state, deltaTime, aniUpdate)) { @@ -1034,7 +1034,7 @@ export class Animator extends Component { aniUpdate: boolean ): void { const runtime = layerData.srcRuntime; - const state = runtime.state.def; + const state = runtime.state._def; const actualSpeed = runtime.state.speed * this.speed; const actualDeltaTime = actualSpeed * deltaTime; @@ -1104,7 +1104,7 @@ export class Animator extends Component { private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorStateDef): void { if (layerData.layerState === LayerState.Playing) { const srcRuntime = layerData.srcRuntime; - if (srcRuntime.state.def !== playState) { + if (srcRuntime.state._def !== playState) { const { curveLayerOwner } = srcRuntime.stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { curveLayerOwner[i]?.curveOwner.revertDefaultValue(); @@ -1128,7 +1128,7 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ): AnimatorStateTransition { - const state = runtime.state.def; + const state = runtime.state._def; const clipDuration = state.clip.length; let targetTransition: AnimatorStateTransition = null; const startTime = state.clipStartTime * clipDuration; @@ -1469,8 +1469,8 @@ export class Animator extends Component { // like speed survive transitions). Supporting self cross-fade would require // a separate transient playback track per active fade. if ( - animatorLayerData.srcRuntime?.state.def === crossState || - animatorLayerData.destRuntime?.state.def === crossState + animatorLayerData.srcRuntime?.state._def === crossState || + animatorLayerData.destRuntime?.state._def === crossState ) { return false; } @@ -1515,7 +1515,7 @@ export class Animator extends Component { deltaTime: number ): void { const { isForward, clipTime } = runtime; - const state = runtime.state.def; + const state = runtime.state._def; const startTime = state._getClipActualStartTime(); const endTime = state._getClipActualEndTime(); diff --git a/packages/core/src/animation/AnimatorState.ts b/packages/core/src/animation/AnimatorState.ts index 77702ae710..e70950b0d1 100644 --- a/packages/core/src/animation/AnimatorState.ts +++ b/packages/core/src/animation/AnimatorState.ts @@ -7,19 +7,24 @@ import { WrapMode } from "./enums/WrapMode"; * Per-Animator runtime view of an `AnimatorStateDef`. * * `findAnimatorState` returns this view: each Animator gets its own instance - * bound to the shared `AnimatorStateDef` asset on the controller. Writes on - * the per-instance fields (currently only `speed`) only affect this Animator; - * reads of asset fields (`name`, `clip`, `wrapMode`, ...) forward to the shared - * def. + * bound to a shared `AnimatorStateDef` asset on the controller. Writes on + * `speed` only affect this Animator; reads of asset fields (`name`, `clip`, + * `wrapMode`, ...) forward to the shared def. * * Lifecycle: lazy-created by `Animator.findAnimatorState` on first access and * persists for the layer's lifetime so per-instance overrides survive * transitions out of and back into the state. + * + * The underlying `AnimatorStateDef` is intentionally not part of the public + * surface: it stays reachable through `animator.animatorController.layers[i] + * .stateMachine.findStateByName(name)` for the rare editor / asset-construction + * case where you really need to mutate the shared asset — the longer path is + * a visual reminder that the change broadcasts to every Animator using the + * same controller. */ export class AnimatorState { - /** The shared AnimatorStateDef asset this view is bound to. */ - readonly def: AnimatorStateDef; - + /** @internal */ + _def: AnimatorStateDef; /** @internal */ _runtime: AnimatorStateRuntime; @@ -27,38 +32,40 @@ export class AnimatorState { /** The state's name (from the shared asset). */ get name(): string { - return this.def.name; + return this._def.name; } /** The animation clip (from the shared asset). */ get clip(): AnimationClip { - return this.def.clip; + return this._def.clip; } /** The wrap mode (from the shared asset). */ get wrapMode(): WrapMode { - return this.def.wrapMode; + return this._def.wrapMode; } /** Normalized clip start time (from the shared asset). */ get clipStartTime(): number { - return this.def.clipStartTime; + return this._def.clipStartTime; } /** Normalized clip end time (from the shared asset). */ get clipEndTime(): number { - return this.def.clipEndTime; + return this._def.clipEndTime; } /** * Per-instance playback speed for this state. * - * Read: returns the per-instance override if set, otherwise reads through to `def.speed`. - * Write: claims per-instance ownership; later changes to `def.speed` no longer flow through. - * The per-instance value persists across state transitions on the owning Animator. + * Read: returns the per-instance override if set, otherwise reads through + * to the shared default. + * Write: claims per-instance ownership; later changes to the shared default + * no longer flow through. The per-instance value persists across state + * transitions on the owning Animator. */ get speed(): number { - return this._speed ?? this.def.speed; + return this._speed ?? this._def.speed; } set speed(value: number) { @@ -67,6 +74,6 @@ export class AnimatorState { /** @internal */ constructor(def: AnimatorStateDef) { - this.def = def; + this._def = def; } } diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index cad232901c..a0b1838717 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -36,7 +36,7 @@ export class AnimatorLayerData { const map = this.stateMap; const name = def.name; let state = map[name]; - if (state?.def !== def) { + if (state?._def !== def) { state = new AnimatorState(def); new AnimatorStateRuntime(state); map[name] = state; diff --git a/packages/core/src/animation/internal/AnimatorStateRuntime.ts b/packages/core/src/animation/internal/AnimatorStateRuntime.ts index 75e9eaa2ef..7e38b8af54 100644 --- a/packages/core/src/animation/internal/AnimatorStateRuntime.ts +++ b/packages/core/src/animation/internal/AnimatorStateRuntime.ts @@ -39,7 +39,7 @@ export class AnimatorStateRuntime { * Does NOT touch user-written per-instance overrides on `state`. */ resetForPlay(stateData: AnimatorStateData, offsetFrameTime: number): void { - const def = this.state.def; + const def = this.state._def; this.stateData = stateData; this.offsetFrameTime = offsetFrameTime; this.playedTime = 0; @@ -64,7 +64,7 @@ export class AnimatorStateRuntime { update(deltaTime: number): void { this.playedTime += deltaTime; - const def = this.state.def; + const def = this.state._def; let time = this.playedTime + this.offsetFrameTime; const duration = def._getDuration(); this.playState = AnimatorStatePlayState.Playing; @@ -87,7 +87,7 @@ export class AnimatorStateRuntime { } private _correctTime(): void { - const def = this.state.def; + const def = this.state._def; // Reverse playback resumed at clipTime=0 would step into negatives; jump to // clipEnd so the next sample continues seamlessly from the end of the clip. if (this.clipTime === 0) { diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 7493a081f4..1c9d1fcc2a 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -75,7 +75,7 @@ describe("Animator test", function () { for (const name of stateNames) { const view = animator.findAnimatorState(name); if (view) { - const def = view.def; + const def = (view as any)._def; def.clearTransitions(); def.speed = 1; def.clipStartTime = 0; @@ -202,12 +202,12 @@ describe("Animator test", function () { animator.play(stateName); const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); let animatorState = animator.findAnimatorState(stateName, layerIndex); - expect(animatorState.def).to.eq(currentAnimatorState); + expect((animatorState as any)._def).to.eq(currentAnimatorState); animator.play(expectedStateName); animatorState = animator.findAnimatorState(expectedStateName, layerIndex); - expect(animatorState.def).not.to.eq(currentAnimatorState); - expect(animatorState.def.name).to.eq(expectedStateName); + expect((animatorState as any)._def).not.to.eq(currentAnimatorState); + expect((animatorState as any)._def.name).to.eq(expectedStateName); }); it("animation getCurrentAnimatorState", () => { @@ -254,22 +254,22 @@ describe("Animator test", function () { expect(srcRuntime.state.name).to.eq("Run"); expect(srcRuntime.playedTime).to.eq(0.3); // @ts-ignore - expect(srcRuntime.clipTime).to.eq(0.3 + 0.1 * runState.def._getDuration()); + expect(srcRuntime.clipTime).to.eq(0.3 + 0.1 * (runState as any)._def._getDuration()); }); it("animation cross fade by transition", () => { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const transition = new AnimatorStateTransition(); - transition.destinationState = runState.def; + transition.destinationState = (runState as any)._def; transition.duration = 1; transition.exitTime = 1; - walkState.def.addTransition(transition); + (walkState as any)._def.addTransition(transition); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.def.clip.length - 0.1); + animator.update((walkState as any)._def.clip.length - 0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); @@ -312,7 +312,7 @@ describe("Animator test", function () { additiveLayer.mask = mask; additiveLayer.blendingMode = AnimatorLayerBlendingMode.Additive; animatorController.addLayer(additiveLayer); - const clip = animator.findAnimatorState("Run").def.clip; + const clip = (animator.findAnimatorState("Run") as any)._def.clip; const newState = animatorStateMachine.addState("Run"); newState.clipStartTime = 1; newState.clip = clip; @@ -373,11 +373,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - idleState.def.clearTransitions(); + (idleState as any)._def.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.def.clearTransitions(); + (walkState as any)._def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.def.clearTransitions(); + (runState as any)._def.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -385,68 +385,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState.def; + toWalkTransition.destinationState = (walkState as any)._def; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.9; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.def.addTransition(toWalkTransition); + (idleState as any)._def.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - (toWalkTransition.exitTime * idleState.def._getDuration()) / idleSpeed + + (toWalkTransition.exitTime * (idleState as any)._def._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * walkState.def._getDuration(); + toWalkTransition.duration * (walkState as any)._def._getDuration(); - const exitTransition = idleState.def.addExitTransition(); + const exitTransition = (idleState as any)._def.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState.def; + toRunTransition.destinationState = (runState as any)._def; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.9; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.def.addTransition(toRunTransition); + (walkState as any)._def.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * walkState.def._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._def._getDuration() + //@ts-ignore - toRunTransition.duration * runState.def._getDuration(); + toRunTransition.duration * (runState as any)._def._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState.def; + toIdleTransition.destinationState = (idleState as any)._def; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.9; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.def.addTransition(toIdleTransition); + (walkState as any)._def.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * walkState.def._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._def._getDuration() + //@ts-ignore - (toIdleTransition.duration * idleState.def._getDuration()) / idleSpeed; + (toIdleTransition.duration * (idleState as any)._def._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = walkState.def; + runToWalkTransition.destinationState = (walkState as any)._def; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.9; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.def.addTransition(runToWalkTransition); + (runState as any)._def.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (runToWalkTransition.exitTime - toRunTransition.duration) * runState.def._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._def._getDuration() + //@ts-ignore - runToWalkTransition.duration * walkState.def._getDuration(); + runToWalkTransition.duration * (walkState as any)._def._getDuration(); - stateMachine.addEntryStateTransition(idleState.def); + stateMachine.addEntryStateTransition((idleState as any)._def); - const anyTransition = stateMachine.addAnyStateTransition(idleState.def); + const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._def); 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.def._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * (walkState as any)._def._getDuration() + // @ts-ignore - (anyTransition.duration * idleState.def._getDuration()) / idleSpeed; + (anyTransition.duration * (idleState as any)._def._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -498,11 +498,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - idleState.def.clearTransitions(); + (idleState as any)._def.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.def.clearTransitions(); + (walkState as any)._def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.def.clearTransitions(); + (runState as any)._def.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -510,68 +510,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkState.def; + toWalkTransition.destinationState = (walkState as any)._def; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.1; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleState.def.addTransition(toWalkTransition); + (idleState as any)._def.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - ((1 - toWalkTransition.exitTime) * idleState.def._getDuration()) / idleSpeed + + ((1 - toWalkTransition.exitTime) * (idleState as any)._def._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * walkState.def._getDuration(); + toWalkTransition.duration * (walkState as any)._def._getDuration(); - const exitTransition = idleState.def.addExitTransition(); + const exitTransition = (idleState as any)._def.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runState.def; + toRunTransition.destinationState = (runState as any)._def; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.1; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkState.def.addTransition(toRunTransition); + (walkState as any)._def.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (1 - toRunTransition.exitTime - toWalkTransition.duration) * walkState.def._getDuration() + + (1 - toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._def._getDuration() + //@ts-ignore - toRunTransition.duration * runState.def._getDuration(); + toRunTransition.duration * (runState as any)._def._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleState.def; + toIdleTransition.destinationState = (idleState as any)._def; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.1; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkState.def.addTransition(toIdleTransition); + (walkState as any)._def.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (1 - toIdleTransition.exitTime - toRunTransition.duration) * walkState.def._getDuration() + + (1 - toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._def._getDuration() + //@ts-ignore - (toIdleTransition.duration * idleState.def._getDuration()) / idleSpeed; + (toIdleTransition.duration * (idleState as any)._def._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = walkState.def; + runToWalkTransition.destinationState = (walkState as any)._def; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.1; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runState.def.addTransition(runToWalkTransition); + (runState as any)._def.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (1 - runToWalkTransition.exitTime - toRunTransition.duration) * runState.def._getDuration() + + (1 - runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._def._getDuration() + //@ts-ignore - runToWalkTransition.duration * walkState.def._getDuration(); + runToWalkTransition.duration * (walkState as any)._def._getDuration(); - stateMachine.addEntryStateTransition(idleState.def); + stateMachine.addEntryStateTransition((idleState as any)._def); - const anyTransition = stateMachine.addAnyStateTransition(idleState.def); + const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._def); 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.def._getDuration() + + (1 - anyTransition.exitTime - toIdleTransition.duration) * (walkState as any)._def._getDuration() + // @ts-ignore - (anyTransition.duration * idleState.def._getDuration()) / idleSpeed; + (anyTransition.duration * (idleState as any)._def._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -615,10 +615,10 @@ describe("Animator test", function () { it("transitionOffset", () => { const walkState = animator.findAnimatorState("Walk"); - walkState.def.clearTransitions(); + (walkState as any)._def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.def.clearTransitions(); - const toRunTransition = walkState.def.addTransition(runState.def); + (runState as any)._def.clearTransitions(); + const toRunTransition = (walkState as any)._def.addTransition((runState as any)._def); toRunTransition.exitTime = 0; toRunTransition.duration = 1; toRunTransition.offset = 0.5; @@ -636,15 +636,15 @@ describe("Animator test", function () { it("clipStartTime crossFade", () => { const walkState = animator.findAnimatorState("Walk"); - walkState.def.wrapMode = WrapMode.Once; - walkState.def.clipStartTime = 0.8; - walkState.def.clearTransitions(); + (walkState as any)._def.wrapMode = WrapMode.Once; + (walkState as any)._def.clipStartTime = 0.8; + (walkState as any)._def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.def.clearTransitions(); - const toRunTransition = walkState.def.addTransition(runState.def); + (runState as any)._def.clearTransitions(); + const toRunTransition = (walkState as any)._def.addTransition((runState as any)._def); toRunTransition.exitTime = 0.5; toRunTransition.duration = 1; - runState.def.clipStartTime = 0.5; + (runState as any)._def.clipStartTime = 0.5; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -658,9 +658,9 @@ describe("Animator test", function () { const animatorLayerData = animator["_animatorLayersData"]; const walkState = animator.findAnimatorState("Walk"); - walkState.def.wrapMode = WrapMode.Once; - walkState.def.clearTransitions(); - walkState.def.addExitTransition(); + (walkState as any)._def.wrapMode = WrapMode.Once; + (walkState as any)._def.clearTransitions(); + (walkState as any)._def.addExitTransition(); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -827,15 +827,15 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Run"); // For test clipStartTime is not 0 and transition duration is 0 - walkState.def.clipStartTime = 0.5; - walkState.def.addStateMachineScript( + (walkState as any)._def.clipStartTime = 0.5; + (walkState as any)._def.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator) { animator.setParameterValue("playRun", 0); } } ); - const transition = stateMachine.addAnyStateTransition(animator.findAnimatorState("Run").def); + const transition = stateMachine.addAnyStateTransition((animator.findAnimatorState("Run") as any)._def); transition.addCondition("playRun", AnimatorConditionMode.Equals, 1); // For test clipStartTime is not 0 and transition duration is 0 transition.duration = 0; @@ -848,7 +848,7 @@ describe("Animator test", function () { expect(layerData.srcRuntime.state.name).to.eq("Run"); expect(layerData.srcRuntime.playedTime).to.eq(0.5); - expect(layerData.srcRuntime.clipTime).to.eq(walkState.def.clip.length * 0.5 + 0.5); + expect(layerData.srcRuntime.clipTime).to.eq((walkState as any)._def.clip.length * 0.5 + 0.5); }); it("hasExitTime", () => { @@ -861,13 +861,13 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const idleState = animator.findAnimatorState("Survey"); idleState.speed = 1; - idleState.def.clearTransitions(); + (idleState as any)._def.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.def.clipStartTime = 0; - walkState.def.clearTransitions(); + (walkState as any)._def.clipStartTime = 0; + (walkState as any)._def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.def.clearTransitions(); - const walkToRunTransition = walkState.def.addTransition(runState.def); + (runState as any)._def.clearTransitions(); + const walkToRunTransition = (walkState as any)._def.addTransition((runState as any)._def); walkToRunTransition.hasExitTime = true; walkToRunTransition.exitTime = 0.5; walkToRunTransition.duration = 0; @@ -875,10 +875,10 @@ describe("Animator test", function () { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.def.clip.length * 0.5); + animator.update((walkState as any)._def.clip.length * 0.5); expect(layerData.destRuntime.state.name).to.eq("Run"); expect(layerData.destRuntime.playedTime).to.eq(0); - const anyToIdleTransition = stateMachine.addAnyStateTransition(idleState.def); + const anyToIdleTransition = stateMachine.addAnyStateTransition((idleState as any)._def); anyToIdleTransition.hasExitTime = false; anyToIdleTransition.duration = 0.2; anyToIdleTransition.addCondition("triggerIdle", AnimatorConditionMode.If, true); @@ -890,9 +890,9 @@ describe("Animator test", function () { expect(layerData.srcRuntime.playedTime).to.eq(0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update(idleState.def.clip.length * 0.2 - 0.1); + animator.update((idleState as any)._def.clip.length * 0.2 - 0.1); expect(layerData.srcRuntime.state.name).to.eq("Survey"); - expect(layerData.srcRuntime.clipTime).to.eq(idleState.def.clip.length * 0.2); + expect(layerData.srcRuntime.clipTime).to.eq((idleState as any)._def.clip.length * 0.2); }); it("setTriggerParameter", () => { @@ -905,16 +905,16 @@ describe("Animator test", function () { stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Walk"); - walkState.def.clearTransitions(); + (walkState as any)._def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.def.clipStartTime = 0; - runState.def.clearTransitions(); - const walkToRunTransition = walkState.def.addTransition(runState.def); + (runState as any)._def.clipStartTime = 0; + (runState as any)._def.clearTransitions(); + const walkToRunTransition = (walkState as any)._def.addTransition((runState as any)._def); walkToRunTransition.hasExitTime = false; walkToRunTransition.duration = 0.1; walkToRunTransition.addCondition("triggerRun", AnimatorConditionMode.If, true); - const runToWalkTransition = runState.def.addTransition(walkState.def); + const runToWalkTransition = (runState as any)._def.addTransition((walkState as any)._def); runToWalkTransition.hasExitTime = true; runToWalkTransition.exitTime = 0.7; runToWalkTransition.duration = 0.3; @@ -934,20 +934,20 @@ describe("Animator test", function () { expect(animator.getParameterValue("triggerWalk")).to.eq(true); // @ts-ignore animator.engine.time._frameCount++; - animator.update(runState.def.clip.length * 0.1 - 0.1); + animator.update((runState as any)._def.clip.length * 0.1 - 0.1); expect(layerData.srcRuntime.state.name).to.eq("Run"); - expect(layerData.srcRuntime.playedTime).to.eq(runState.def.clip.length * 0.1); + expect(layerData.srcRuntime.playedTime).to.eq((runState as any)._def.clip.length * 0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update(runState.def.clip.length * 0.6); + animator.update((runState as any)._def.clip.length * 0.6); expect(layerData.destRuntime.state.name).to.eq("Walk"); expect(layerData.destRuntime.playedTime).to.eq(0); expect(animator.getParameterValue("triggerWalk")).to.eq(false); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.def.clip.length * 0.3); + animator.update((walkState as any)._def.clip.length * 0.3); expect(layerData.srcRuntime.state.name).to.eq("Walk"); - expect(layerData.srcRuntime.playedTime).to.eq(walkState.def.clip.length * 0.3); + expect(layerData.srcRuntime.playedTime).to.eq((walkState as any)._def.clip.length * 0.3); }); it("fixedDuration", () => { @@ -957,11 +957,11 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); const walkState = animator.findAnimatorState("Walk"); - walkState.def.clearTransitions(); + (walkState as any)._def.clearTransitions(); const runState = animator.findAnimatorState("Run"); - runState.def.clipStartTime = runState.def.clipEndTime = 0; - runState.def.clearTransitions(); - const walkToRunTransition = walkState.def.addTransition(runState.def); + (runState as any)._def.clipStartTime = (runState as any)._def.clipEndTime = 0; + (runState as any)._def.clearTransitions(); + const walkToRunTransition = (walkState as any)._def.addTransition((runState as any)._def); walkToRunTransition.hasExitTime = false; walkToRunTransition.isFixedDuration = true; walkToRunTransition.duration = 0.1; @@ -1096,7 +1096,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition(idleState.def); + const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._def); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1134,12 +1134,12 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - walkState.def.clipStartTime = 0; - walkState.def.clipEndTime = 1; - walkState.def.clearTransitions(); + (walkState as any)._def.clipStartTime = 0; + (walkState as any)._def.clipEndTime = 1; + (walkState as any)._def.clearTransitions(); // A noExitTime transition that fails (ensures noExitTimeCount > 0). - const noExitFailTransition = walkState.def.addTransition(idleState.def); + const noExitFailTransition = (walkState as any)._def.addTransition((idleState as any)._def); noExitFailTransition.hasExitTime = false; noExitFailTransition.duration = 0; noExitFailTransition.addCondition("never", AnimatorConditionMode.If, true); @@ -1148,16 +1148,16 @@ describe("Animator test", function () { const exitTimeTransition = new AnimatorStateTransition(); exitTimeTransition.exitTime = 0.5; exitTimeTransition.duration = 0; - exitTimeTransition.destinationState = runState.def; + exitTimeTransition.destinationState = (runState as any)._def; exitTimeTransition.addCondition("goRun", AnimatorConditionMode.If, true); - walkState.def.addTransition(exitTimeTransition); + (walkState as any)._def.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.def.clip.length * 0.25; + const preExitDeltaTime = (walkState as any)._def.clip.length * 0.25; // @ts-ignore animator.engine.time._frameCount++; animator.update(preExitDeltaTime); @@ -1167,7 +1167,7 @@ describe("Animator test", function () { // Update past exitTime, should transition to Run. // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.def.clip.length * 0.5); + animator.update((walkState as any)._def.clip.length * 0.5); expect(animator.getCurrentAnimatorState(0).name).to.eq("Run"); }); @@ -1179,17 +1179,17 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition(idleState.def); + const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._def); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); // Play Walk with Once mode, let it finish to reach Finished state - walkState.def.wrapMode = WrapMode.Once; + (walkState as any)._def.wrapMode = WrapMode.Once; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update(walkState.def.clip.length + 0.1); + animator.update((walkState as any)._def.clip.length + 0.1); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); @@ -1222,7 +1222,7 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); // AnyState -> Run (always true, noExitTime) - const anyToRun = stateMachine.addAnyStateTransition(runState.def); + const anyToRun = stateMachine.addAnyStateTransition((runState as any)._def); anyToRun.hasExitTime = false; anyToRun.duration = 0.2; anyToRun.addCondition("alwaysTrue", AnimatorConditionMode.If, true); @@ -1258,7 +1258,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (always true, noExitTime) - const anyToIdle = stateMachine.addAnyStateTransition(idleState.def); + const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._def); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1281,19 +1281,19 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - walkState.def.clearTransitions(); + (walkState as any)._def.clearTransitions(); // Add a noExitTime transition - const t1 = walkState.def.addTransition(runState.def); + const t1 = (walkState as any)._def.addTransition((runState as any)._def); t1.hasExitTime = false; // Add a hasExitTime transition - const t2 = walkState.def.addTransition(idleState.def); + const t2 = (walkState as any)._def.addTransition((idleState as any)._def); t2.hasExitTime = true; t2.exitTime = 0.5; // @ts-ignore - const collection = walkState.def._transitionCollection; + const collection = (walkState as any)._def._transitionCollection; expect(collection.noExitTimeCount).to.eq(1); expect(collection.count).to.eq(2); @@ -1321,7 +1321,7 @@ describe("Animator test", function () { const survey = cloneAnimator.findAnimatorState("Survey"); expect(survey).to.not.eq(null); expect(survey.name).to.eq("Survey"); - expect(survey.speed).to.eq(survey.def.speed); // live-bound default + expect(survey.speed).to.eq((survey as any)._def.speed); // live-bound default // Same handle returned on subsequent calls (verifies caching) expect(cloneAnimator.findAnimatorState("Survey")).to.eq(survey); @@ -1407,7 +1407,7 @@ describe("Animator test", function () { const sm = animator.animatorController.layers[0].stateMachine; const oldSurvey = animator.findAnimatorState("Survey"); expect(oldSurvey).not.to.eq(null); - const oldStateRef = oldSurvey.def; + const oldStateRef = (oldSurvey as any)._def; const originalIndex = sm.states.indexOf(oldStateRef); // Simulate dynamic controller mutation: remove and re-add same-name state @@ -1417,7 +1417,7 @@ describe("Animator test", function () { const newHandle = animator.findAnimatorState("Survey"); expect(newHandle).not.to.eq(null); - expect(newHandle.def).to.eq(newStateRef); + expect((newHandle as any)._def).to.eq(newStateRef); expect(newHandle).not.to.eq(oldSurvey); // Restore original Survey state so subsequent tests still see the @@ -1577,7 +1577,7 @@ describe("Animator test", function () { it("_reset detaches stateData clipChangedListeners so they do not accumulate on the AnimatorState", () => { const survey = animator.findAnimatorState("Survey"); expect(survey).not.to.eq(null); - const surveyState = survey.def; + const surveyState = (survey as any)._def; // @ts-ignore — read internal listener list size const listenersBefore = surveyState._updateFlagManager._listeners.length; @@ -1645,10 +1645,10 @@ describe("Animator test", function () { it("state-machine self-transition is also a no-op (alias-guard policy)", () => { const walk = animator.findAnimatorState("Walk"); - walk.def.clearTransitions(); + (walk as any)._def.clearTransitions(); animator.animatorController.addParameter("restart", false); - const selfTransition = walk.def.addTransition(walk.def); + const selfTransition = (walk as any)._def.addTransition((walk as any)._def); selfTransition.hasExitTime = false; selfTransition.duration = 0.1; selfTransition.addCondition("restart", AnimatorConditionMode.If, true); @@ -1765,13 +1765,13 @@ describe("Animator test", function () { 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.def.clearTransitions(); - walk.def.clearTransitions(); + (survey as any)._def.clearTransitions(); + (walk as any)._def.clearTransitions(); animator.animatorController.addParameter("goWalk", false); survey.speed = 0; // pause source per-instance - const transition = survey.def.addTransition(walk.def); + const transition = (survey as any)._def.addTransition((walk as any)._def); transition.hasExitTime = false; transition.duration = 0.3; transition.addCondition("goWalk", AnimatorConditionMode.If, true); From a4a69745eaf76923640f4ac9a257088c49386753 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 18:02:54 +0800 Subject: [PATCH 62/92] refactor(animation): rename per-instance view to AnimatorStateInstance - Restore AnimatorState as the shared asset (original name, unchanged semantics). Editor/asset code reaches it through the controller path. - Add AnimatorStateInstance: the per-Animator view returned by findAnimatorState / getCurrentAnimatorState. Writes only affect this Animator; reads of unwritten fields forward to the shared asset. - Rename internal AnimatorStatePlayData (deleted in upstream PR) to AnimatorStateRuntime; it owns the playback runtime and a 1:1 AnimatorStateInstance pair. AnimatorLayerData caches one instance per (layer, state-name). - Update Animator API return types to AnimatorStateInstance | null. - Tests/e2e cases: state-machine wiring uses the controller path (shared asset). Per-Animator playback tweaks use the view. - Docs (en/zh): explain the asset vs instance split and the Renderer.getInstanceMaterial pattern parallel. --- docs/en/animation/animator.mdx | 16 +- docs/zh/animation/animator.mdx | 6 +- e2e/case/animator-stateMachine.ts | 2 +- e2e/case/animator-stateMachineScript.ts | 10 +- packages/core/src/animation/Animator.ts | 94 +++--- packages/core/src/animation/AnimatorState.ts | 277 +++++++++++++++--- .../core/src/animation/AnimatorStateDef.ts | 260 ---------------- .../src/animation/AnimatorStateInstance.ts | 79 +++++ .../src/animation/AnimatorStateMachine.ts | 28 +- .../src/animation/AnimatorStateTransition.ts | 4 +- .../AnimatorStateTransitionCollection.ts | 6 +- .../core/src/animation/StateMachineScript.ts | 10 +- packages/core/src/animation/index.ts | 2 +- .../animation/internal/AnimatorLayerData.ts | 30 +- .../animation/internal/AnimatorStateData.ts | 4 +- .../internal/AnimatorStateRuntime.ts | 39 +-- .../loader/src/AnimatorControllerLoader.ts | 9 +- .../parser/GLTFAnimatorControllerParser.ts | 2 +- tests/src/core/Animator.test.ts | 248 ++++++++-------- 19 files changed, 559 insertions(+), 567 deletions(-) delete mode 100644 packages/core/src/animation/AnimatorStateDef.ts create mode 100644 packages/core/src/animation/AnimatorStateInstance.ts diff --git a/docs/en/animation/animator.mdx b/docs/en/animation/animator.mdx index 0311db9f7b..85896dac47 100644 --- a/docs/en/animation/animator.mdx +++ b/docs/en/animation/animator.mdx @@ -139,24 +139,22 @@ animator.crossFade("OtherStateName", 0.3); ### Get Current Playing Animation State -You can use the [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) method to get the currently playing `AnimatorState` . The parameter is the index `layerIndex` of the `AnimatorControllerLayer` where the `AnimatorState` is located. For details, see the [API documentation](/apis/core/#Animator-getCurrentAnimatorState). The return type is `AnimatorState | null` — `null` is returned when the specified layer doesn't exist or no state is currently playing on it. Once you obtain a non-null state you can set its properties, such as changing the default loop playback to play once. +Use [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) to get the currently playing per-Animator state container. The parameter is the index `layerIndex` of the `AnimatorControllerLayer`. The return type is `AnimatorStateInstance | null` — `null` when the specified layer doesn't exist or no state is currently playing on it. ```typescript -const currentState = animator.getCurrentAnimatorState(0); -if (currentState) { - // Play once - currentState.wrapMode = WrapMode.Once; - // Loop playback - currentState.wrapMode = WrapMode.Loop; +const current = animator.getCurrentAnimatorState(0); +if (current) { + // Per-instance speed (this Animator only) + current.speed = 0.5; } ``` ### Get Animation State -[findAnimatorState](/apis/core/#Animator-findAnimatorState) returns the per-Animator `AnimatorState` view for the named state (`AnimatorState | null`). Writes on the view (e.g. `state.speed`) only affect this Animator — the shared `AnimatorStateDef` asset on the controller is untouched. Mirrors the `Renderer.getInstanceMaterial` pattern. +[findAnimatorState](/apis/core/#Animator-findAnimatorState) returns the per-Animator state container for the named state (`AnimatorStateInstance | null`). Writes on the instance (e.g. `state.speed`) only affect this Animator — the shared `AnimatorState` asset on the controller is untouched. Mirrors the `Renderer.getInstanceMaterial` pattern. - `state.speed` — per-Animator playback speed. Until written, reads through to the shared default; once written, the instance owns its own speed. -- `state.name`, `state.clip`, `state.wrapMode`, `state.clipStartTime`, `state.clipEndTime` — read-only forwards from the shared `AnimatorStateDef`. +- `state.name`, `state.clip`, `state.wrapMode`, `state.clipStartTime`, `state.clipEndTime` — read-only forwards from the shared `AnimatorState`. To mutate the shared asset (broadcast to every Animator using this controller — typical for editor / asset-construction code), reach it through the controller path. The longer path is a deliberate visual reminder that the change is global. diff --git a/docs/zh/animation/animator.mdx b/docs/zh/animation/animator.mdx index fd69436c92..ced40e8950 100644 --- a/docs/zh/animation/animator.mdx +++ b/docs/zh/animation/animator.mdx @@ -144,7 +144,7 @@ animator.crossFade("OtherStateName", 0.3); ### 获取当前在播放的动画状态 -你可以使用 [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState)  方法来获取当前正在播放的 `动画状态`。参数为 `动画状态` 所在 `动画层` 的序号`layerIndex`, 详见[API 文档](/apis/core/#Animator-getCurrentAnimatorState)。返回类型为 `AnimatorState | null`,当指定 `动画层` 不存在或该层当前没有播放任何状态时返回 `null`。获取到非空状态后可以设置其属性,比如将默认的循环播放改为一次。 +你可以使用 [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState)  方法来获取当前正在播放的 `动画状态`。参数为 `动画状态` 所在 `动画层` 的序号`layerIndex`, 详见[API 文档](/apis/core/#Animator-getCurrentAnimatorState)。返回类型为 `AnimatorStateInstance | null`,当指定 `动画层` 不存在或该层当前没有播放任何状态时返回 `null`。获取到非空状态后可以设置其属性,比如将默认的循环播放改为一次。 ```typescript const currentState = animator.getCurrentAnimatorState(0); @@ -158,10 +158,10 @@ if (currentState) { ### 获取动画状态 -[findAnimatorState](/apis/core/#Animator-findAnimatorState) 返回当前 `Animator` 独有的 `AnimatorState` 视图(`AnimatorState | null`)。对该视图的写入(如 `state.speed`)只影响当前 Animator,控制器上的共享 `AnimatorStateDef` 资产不受影响。模式与 `Renderer.getInstanceMaterial` 一致。 +[findAnimatorState](/apis/core/#Animator-findAnimatorState) 返回当前 `Animator` 独有的 `AnimatorState` 视图(`AnimatorStateInstance | null`)。对该视图的写入(如 `state.speed`)只影响当前 Animator,控制器上的共享 `AnimatorState` 资产不受影响。模式与 `Renderer.getInstanceMaterial` 一致。 - `state.speed`:每个 `Animator` 独立的播放速度。未写入前透传到共享默认值,写入后由当前实例独占。 -- `state.name` / `state.clip` / `state.wrapMode` / `state.clipStartTime` / `state.clipEndTime`:从共享 `AnimatorStateDef` 转发的只读字段。 +- `state.name` / `state.clip` / `state.wrapMode` / `state.clipStartTime` / `state.clipEndTime`:从共享 `AnimatorState` 转发的只读字段。 要修改共享 asset(影响所有使用该控制器的 `Animator`——编辑器/资产搭建场景常见),通过控制器路径访问。这条更长的路径本身就是一个视觉提醒:你做的是全局变更。 diff --git a/e2e/case/animator-stateMachine.ts b/e2e/case/animator-stateMachine.ts index 3a32a3b899..35822c44de 100644 --- a/e2e/case/animator-stateMachine.ts +++ b/e2e/case/animator-stateMachine.ts @@ -54,7 +54,7 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const animator = defaultSceneRoot.getComponent(Animator)!; animator.animatorController.addParameter("playerSpeed", 1); const stateMachine = animator.animatorController.layers[0].stateMachine; - // State-machine assembly works on the shared AnimatorStateDef assets via the controller path. + // State-machine assembly works on the shared AnimatorState assets via the controller path. const idleDef = stateMachine.findStateByName("idle"); const walkDef = stateMachine.findStateByName("walk"); const runDef = stateMachine.findStateByName("run"); diff --git a/e2e/case/animator-stateMachineScript.ts b/e2e/case/animator-stateMachineScript.ts index 3fc1a5bb98..d768c3bcff 100644 --- a/e2e/case/animator-stateMachineScript.ts +++ b/e2e/case/animator-stateMachineScript.ts @@ -4,7 +4,7 @@ */ import { Animator, - AnimatorStateDef, + AnimatorState, Camera, Color, DirectLight, @@ -55,7 +55,7 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { rootEntity.addChild(defaultSceneRoot); const animator = defaultSceneRoot.getComponent(Animator); - // Attaching a StateMachineScript mutates the shared AnimatorStateDef on the controller, + // Attaching a StateMachineScript mutates the shared AnimatorState on the controller, // so reach it through the controller path rather than via a per-Animator state view. const walkDef = animator.animatorController.layers[0].stateMachine.findStateByName("walk"); if (!walkDef) { @@ -64,16 +64,16 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { walkDef.addStateMachineScript( class extends StateMachineScript { - onStateEnter(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void { + onStateEnter(animator: Animator, animatorState: AnimatorState, layerIndex: number): void { textRenderer.text = "0"; console.log("onStateEnter: ", animatorState); } - onStateUpdate(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void { + onStateUpdate(animator: Animator, animatorState: AnimatorState, layerIndex: number): void { console.log("onStateUpdate: ", animatorState); } - onStateExit(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void { + onStateExit(animator: Animator, animatorState: AnimatorState, layerIndex: number): void { textRenderer.text = "1"; console.log("onStateExit: ", animatorState); } diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index cbb8f7940a..ecd774949e 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -23,7 +23,7 @@ import { AnimationCurveLayerOwner } from "./internal/AnimationCurveLayerOwner"; import { AnimationEventHandler } from "./internal/AnimationEventHandler"; import { AnimatorLayerData } from "./internal/AnimatorLayerData"; import { AnimatorStateData } from "./internal/AnimatorStateData"; -import { AnimatorStateDef } from "./AnimatorStateDef"; +import { AnimatorStateInstance } from "./AnimatorStateInstance"; import { AnimatorStateRuntime } from "./internal/AnimatorStateRuntime"; import { AnimationCurveOwner } from "./internal/animationCurveOwner/AnimationCurveOwner"; @@ -203,36 +203,36 @@ export class Animator extends Component { } /** - * Get the per-Animator state view currently playing on the target layer. + * Get the per-Animator state instance currently playing on the target layer. * - * Writes on the returned `AnimatorState` (e.g. `state.speed`) only affect - * this Animator; the shared `AnimatorStateDef` asset is untouched. + * Writes on the returned `AnimatorStateInstance` (e.g. `instance.speed`) + * only affect this Animator; the shared `AnimatorState` asset is untouched. * * @param layerIndex - The layer index - * @returns Per-instance state view, or null if the layer is missing or no state is playing + * @returns Per-instance state container, or null if the layer is missing or no state is playing */ - getCurrentAnimatorState(layerIndex: number): AnimatorState | null { - return this._animatorLayersData[layerIndex]?.srcRuntime?.state ?? null; + getCurrentAnimatorState(layerIndex: number): AnimatorStateInstance | null { + return this._animatorLayersData[layerIndex]?.srcRuntime?.instance ?? null; } /** - * Get or lazily create the per-Animator state view for a named state. + * Get or lazily create the per-Animator state instance for a named state. * * Mirrors the `Renderer.getInstanceMaterial` pattern: the shared - * `AnimatorStateDef` on the controller stays shared, while overrides on the - * returned view (e.g. `state.speed`) only affect this Animator. The returned - * view persists for the layer's lifetime, so overrides survive transitions - * out of and back into the state. + * `AnimatorState` on the controller stays shared, while overrides on the + * returned instance (e.g. `instance.speed`) only affect this Animator. The + * returned instance persists for the layer's lifetime, so overrides survive + * transitions out of and back into the state. * * @param stateName - The state name * @param layerIndex - The layer index (default -1, searches all layers) - * @returns Per-instance state view, or null if no state matches + * @returns Per-instance state container, or null if no state matches */ - findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorState | null { + findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStateInstance | null { this._resetIfControllerUpdated(); const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); if (!state || foundLayer < 0) return null; - return this._getAnimatorLayerData(foundLayer).getOrCreateRuntime(state).state; + return this._getAnimatorLayerData(foundLayer).getOrCreateRuntime(state).instance; } /** @@ -413,7 +413,7 @@ export class Animator extends Component { private _getAnimatorStateInfo(stateName: string, layerIndex: number): IAnimatorStateInfo { const { _animatorController: animatorController, _tempAnimatorStateInfo: stateInfo } = this; - let state: AnimatorStateDef = null; + let state: AnimatorState = null; if (animatorController) { const layers = animatorController.layers; if (layerIndex === -1) { @@ -437,7 +437,7 @@ export class Animator extends Component { private _getAnimatorStateData( stateName: string, - animatorState: AnimatorStateDef, + animatorState: AnimatorState, animatorLayerData: AnimatorLayerData, layerIndex: number ): AnimatorStateData { @@ -459,7 +459,7 @@ export class Animator extends Component { } private _saveAnimatorStateData( - animatorState: AnimatorStateDef, + animatorState: AnimatorState, animatorStateData: AnimatorStateData, animatorLayerData: AnimatorLayerData, layerIndex: number @@ -511,7 +511,7 @@ export class Animator extends Component { } } - private _saveAnimatorEventHandlers(state: AnimatorStateDef, animatorStateData: AnimatorStateData): void { + private _saveAnimatorEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void { const eventHandlerPool = this._animationEventHandlerPool; const scripts = []; const { eventHandlers } = animatorStateData; @@ -659,9 +659,9 @@ export class Animator extends Component { aniUpdate: boolean ): void { const { srcRuntime } = layerData; - const state = srcRuntime.state._def; + const state = srcRuntime.instance._state; - const playSpeed = srcRuntime.state.speed * this.speed; + const playSpeed = srcRuntime.instance.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; srcRuntime.updateOrientation(playDeltaTime); @@ -757,7 +757,7 @@ export class Animator extends Component { additive: boolean, aniUpdate: boolean ): void { - const curveBindings = runtime.state.clip._curveBindings; + const curveBindings = runtime.instance.clip._curveBindings; const finished = runtime.playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { @@ -791,16 +791,16 @@ export class Animator extends Component { ) { const { srcRuntime, destRuntime, layerIndex } = layerData; const { speed } = this; - const srcState = srcRuntime.state._def; - const destState = destRuntime.state._def; + const srcState = srcRuntime.instance._state; + const destState = destRuntime.instance._state; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, destState, deltaTime, aniUpdate)) { return; } - const srcPlaySpeed = srcRuntime.state.speed * speed; - const dstPlaySpeed = destRuntime.state.speed * speed; + const srcPlaySpeed = srcRuntime.instance.speed * speed; + const dstPlaySpeed = destRuntime.instance.speed * speed; const dstPlayDeltaTime = dstPlaySpeed * deltaTime; srcRuntime.updateOrientation(srcPlaySpeed * deltaTime); @@ -882,8 +882,8 @@ export class Animator extends Component { aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const { _curveBindings: srcCurves } = srcRuntime.state.clip; - const { state: destState } = destRuntime; + const { _curveBindings: srcCurves } = srcRuntime.instance.clip; + const destState = destRuntime.instance._state; const { _curveBindings: destCurves } = destState.clip; const finished = destRuntime.playState === AnimatorStatePlayState.Finished; @@ -922,14 +922,14 @@ export class Animator extends Component { aniUpdate: boolean ) { const { destRuntime } = layerData; - const state = destRuntime.state._def; + const state = destRuntime.instance._state; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, state, deltaTime, aniUpdate)) { return; } - const playSpeed = destRuntime.state.speed * this.speed; + const playSpeed = destRuntime.instance.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; destRuntime.updateOrientation(playDeltaTime); @@ -996,7 +996,7 @@ export class Animator extends Component { aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const { state } = destRuntime; + const state = destRuntime.instance._state; const { _curveBindings: curveBindings } = state.clip; const { clipTime: destClipTime, playState: playState } = destRuntime; @@ -1034,8 +1034,8 @@ export class Animator extends Component { aniUpdate: boolean ): void { const runtime = layerData.srcRuntime; - const state = runtime.state._def; - const actualSpeed = runtime.state.speed * this.speed; + const state = runtime.instance._state; + const actualSpeed = runtime.instance.speed * this.speed; const actualDeltaTime = actualSpeed * deltaTime; runtime.updateOrientation(actualDeltaTime); @@ -1076,7 +1076,7 @@ export class Animator extends Component { } const { curveLayerOwner } = runtime.stateData; - const { _curveBindings: curveBindings } = runtime.state.clip; + const { _curveBindings: curveBindings } = runtime.instance.clip; for (let i = curveBindings.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; @@ -1101,10 +1101,10 @@ export class Animator extends Component { layerData.crossFadeTransition = null; } - private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorStateDef): void { + private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorState): void { if (layerData.layerState === LayerState.Playing) { const srcRuntime = layerData.srcRuntime; - if (srcRuntime.state._def !== playState) { + if (srcRuntime.instance._state !== playState) { const { curveLayerOwner } = srcRuntime.stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { curveLayerOwner[i]?.curveOwner.revertDefaultValue(); @@ -1128,7 +1128,7 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ): AnimatorStateTransition { - const state = runtime.state._def; + const state = runtime.instance._state; const clipDuration = state.clip.length; let targetTransition: AnimatorStateTransition = null; const startTime = state.clipStartTime * clipDuration; @@ -1212,7 +1212,7 @@ export class Animator extends Component { private _tryCrossFadeInterrupt( layerData: AnimatorLayerData, transitionDuration: number, - currentDestState: AnimatorStateDef, + currentDestState: AnimatorState, deltaTime: number, aniUpdate: boolean ): boolean { @@ -1233,7 +1233,7 @@ export class Animator extends Component { layerData: AnimatorLayerData, transitionCollection: AnimatorStateTransitionCollection, aniUpdate: boolean, - excludeDestState?: AnimatorStateDef + excludeDestState?: AnimatorState ): AnimatorStateTransition { for (let i = 0, n = transitionCollection.noExitTimeCount; i < n; ++i) { const transition = transitionCollection.get(i); @@ -1254,7 +1254,7 @@ export class Animator extends Component { private _checkSubTransition( layerData: AnimatorLayerData, - state: AnimatorStateDef, + state: AnimatorState, transitionCollection: AnimatorStateTransitionCollection, lastClipTime: number, curClipTime: number, @@ -1291,7 +1291,7 @@ export class Animator extends Component { private _checkBackwardsSubTransition( layerData: AnimatorLayerData, - state: AnimatorStateDef, + state: AnimatorState, transitionCollection: AnimatorStateTransitionCollection, lastClipTime: number, curClipTime: number, @@ -1344,7 +1344,7 @@ export class Animator extends Component { } } - private _preparePlay(state: AnimatorStateDef, layerIndex: number, normalizedTimeOffset: number = 0): boolean { + private _preparePlay(state: AnimatorState, layerIndex: number, normalizedTimeOffset: number = 0): boolean { const name = state.name; if (!state.clip) { Logger.warn(`The state named ${name} has no AnimationClip data.`); @@ -1469,8 +1469,8 @@ export class Animator extends Component { // like speed survive transitions). Supporting self cross-fade would require // a separate transient playback track per active fade. if ( - animatorLayerData.srcRuntime?.state._def === crossState || - animatorLayerData.destRuntime?.state._def === crossState + animatorLayerData.srcRuntime?.instance._state === crossState || + animatorLayerData.destRuntime?.instance._state === crossState ) { return false; } @@ -1515,7 +1515,7 @@ export class Animator extends Component { deltaTime: number ): void { const { isForward, clipTime } = runtime; - const state = runtime.state._def; + const state = runtime.instance._state; const startTime = state._getClipActualStartTime(); const endTime = state._getClipActualEndTime(); @@ -1622,7 +1622,7 @@ export class Animator extends Component { private _fireAnimationEventsAndCallScripts( layerIndex: number, runtime: AnimatorStateRuntime, - state: AnimatorStateDef, + state: AnimatorState, lastClipTime: number, lastPlayState: AnimatorStatePlayState, deltaTime: number @@ -1650,5 +1650,5 @@ export class Animator extends Component { interface IAnimatorStateInfo { layerIndex: number; - state: AnimatorStateDef; + state: AnimatorState; } diff --git a/packages/core/src/animation/AnimatorState.ts b/packages/core/src/animation/AnimatorState.ts index e70950b0d1..3962c6da82 100644 --- a/packages/core/src/animation/AnimatorState.ts +++ b/packages/core/src/animation/AnimatorState.ts @@ -1,79 +1,260 @@ +import { Engine } from "../Engine"; +import { UpdateFlagManager } from "../UpdateFlagManager"; import { AnimationClip } from "./AnimationClip"; -import { AnimatorStateDef } from "./AnimatorStateDef"; -import { AnimatorStateRuntime } from "./internal/AnimatorStateRuntime"; +import type { Animator } from "./Animator"; +import { AnimatorStateTransition } from "./AnimatorStateTransition"; +import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; import { WrapMode } from "./enums/WrapMode"; +import { StateMachineScript } from "./StateMachineScript"; /** - * Per-Animator runtime view of an `AnimatorStateDef`. - * - * `findAnimatorState` returns this view: each Animator gets its own instance - * bound to a shared `AnimatorStateDef` asset on the controller. Writes on - * `speed` only affect this Animator; reads of asset fields (`name`, `clip`, - * `wrapMode`, ...) forward to the shared def. - * - * Lifecycle: lazy-created by `Animator.findAnimatorState` on first access and - * persists for the layer's lifetime so per-instance overrides survive - * transitions out of and back into the state. - * - * The underlying `AnimatorStateDef` is intentionally not part of the public - * surface: it stays reachable through `animator.animatorController.layers[i] - * .stateMachine.findStateByName(name)` for the rare editor / asset-construction - * case where you really need to mutate the shared asset — the longer path is - * a visual reminder that the change broadcasts to every Animator using the - * same controller. + * States are the basic building blocks of a state machine. Each state contains a AnimationClip which will play while the character is in that state. */ export class AnimatorState { + /** The speed of the clip. 1 is normal speed, default 1. */ + speed: number = 1.0; + /** The wrap mode used in the state. */ + wrapMode: WrapMode = WrapMode.Loop; + /** @internal */ - _def: AnimatorStateDef; + _updateFlagManager: UpdateFlagManager = new UpdateFlagManager(); /** @internal */ - _runtime: AnimatorStateRuntime; + _transitionCollection: AnimatorStateTransitionCollection = new AnimatorStateTransitionCollection(); - private _speed: number | undefined; + private _onStateEnterScripts: StateMachineScript[] = []; + private _onStateUpdateScripts: StateMachineScript[] = []; + private _onStateExitScripts: StateMachineScript[] = []; + private _engine: Engine; + private _clipStartTime = 0; + private _clipEndTime = 1; + private _clip: AnimationClip; - /** The state's name (from the shared asset). */ - get name(): string { - return this._def.name; + /** + * The transitions that are going out of the state. + */ + get transitions(): Readonly { + return this._transitionCollection.transitions; } - /** The animation clip (from the shared asset). */ + /** + * The clip that is being played by this animator state. + */ get clip(): AnimationClip { - return this._def.clip; + return this._clip; } - /** The wrap mode (from the shared asset). */ - get wrapMode(): WrapMode { - return this._def.wrapMode; + set clip(clip: AnimationClip) { + const lastClip = this._clip; + if (lastClip === clip) { + return; + } + + if (lastClip) { + lastClip._updateFlagManager.removeListener(this._onClipChanged); + } + + this._clip = clip; + this._clipEndTime = Math.min(this._clipEndTime, 1); + + this._onClipChanged(); + + clip && clip._updateFlagManager.addListener(this._onClipChanged); } - /** Normalized clip start time (from the shared asset). */ + /** + * The normalized start time of the clip, the range is 0 to 1, default is 0. + */ get clipStartTime(): number { - return this._def.clipStartTime; + return this._clipStartTime; + } + + set clipStartTime(time: number) { + this._clipStartTime = Math.max(time, 0); } - /** Normalized clip end time (from the shared asset). */ + /** + * The normalized end time of the clip, the range is 0 to 1, default is 1. + */ get clipEndTime(): number { - return this._def.clipEndTime; + return this._clipEndTime; + } + + set clipEndTime(time: number) { + this._clipEndTime = Math.min(time, 1); + } + + /** + * @param name - The state's name + */ + constructor(public readonly name: string) { + this._onClipChanged = this._onClipChanged.bind(this); } /** - * Per-instance playback speed for this state. - * - * Read: returns the per-instance override if set, otherwise reads through - * to the shared default. - * Write: claims per-instance ownership; later changes to the shared default - * no longer flow through. The per-instance value persists across state - * transitions on the owning Animator. + * Add an outgoing transition. + * @param transition - The transition */ - get speed(): number { - return this._speed ?? this._def.speed; + addTransition(transition: AnimatorStateTransition): AnimatorStateTransition; + /** + * Add an outgoing transition to the destination state. + * @param animatorState - The destination state + */ + addTransition(animatorState: AnimatorState): AnimatorStateTransition; + + addTransition(transitionOrAnimatorState: AnimatorStateTransition | AnimatorState): AnimatorStateTransition { + return this._transitionCollection.add(transitionOrAnimatorState); } - set speed(value: number) { - this._speed = value; + /** + * Add an outgoing transition to exit of the stateMachine. + * @param exitTime - The time at which the transition can take effect. This is represented in normalized time. + */ + addExitTransition(exitTime: number = 1.0): AnimatorStateTransition { + const transition = new AnimatorStateTransition(); + transition._isExit = true; + transition.exitTime = exitTime; + + return this._transitionCollection.add(transition); + } + /** + * Remove a transition from the state. + * @param transition - The transition + */ + removeTransition(transition: AnimatorStateTransition): void { + this._transitionCollection.remove(transition); + if (transition._isExit) { + transition._isExit = false; + } } - /** @internal */ - constructor(def: AnimatorStateDef) { - this._def = def; + /** + * Adds a state machine script class of type T to the AnimatorState. + * @param scriptType - The state machine script class of type T + */ + addStateMachineScript(scriptType: new () => T): T { + const script = new scriptType(); + script._engine = this._engine; + script._state = this; + + const { prototype } = StateMachineScript; + if (script.onStateEnter !== prototype.onStateEnter) { + this._onStateEnterScripts.push(script); + } + if (script.onStateUpdate !== prototype.onStateUpdate) { + this._onStateUpdateScripts.push(script); + } + if (script.onStateExit !== prototype.onStateExit) { + this._onStateExitScripts.push(script); + } + + return script; + } + + /** + * Clears all transitions from the state. + */ + clearTransitions(): void { + this._transitionCollection.clear(); + } + + /** + * @internal + */ + _callOnEnter(animator: Animator, layerIndex: number): void { + const scripts = this._onStateEnterScripts; + for (let i = 0, n = scripts.length; i < n; i++) { + scripts[i].onStateEnter(animator, this, layerIndex); + } + } + + /** + * @internal + */ + _callOnUpdate(animator: Animator, layerIndex: number): void { + const scripts = this._onStateUpdateScripts; + for (let i = 0, n = scripts.length; i < n; i++) { + scripts[i].onStateUpdate(animator, this, layerIndex); + } + } + + /** + * @internal + */ + _callOnExit(animator: Animator, layerIndex: number): void { + const scripts = this._onStateExitScripts; + for (let i = 0, n = scripts.length; i < n; i++) { + scripts[i].onStateExit(animator, this, layerIndex); + } + } + + /** + * @internal + */ + _getDuration(): number { + if (this.clip) { + return (this._clipEndTime - this._clipStartTime) * this.clip.length; + } + return null; + } + + /** + * @internal + */ + _removeStateMachineScript(script: StateMachineScript): void { + const { prototype } = StateMachineScript; + if (script.onStateEnter !== prototype.onStateEnter) { + const index = this._onStateEnterScripts.indexOf(script); + index !== -1 && this._onStateEnterScripts.splice(index, 1); + } + if (script.onStateUpdate !== prototype.onStateUpdate) { + const index = this._onStateUpdateScripts.indexOf(script); + index !== -1 && this._onStateUpdateScripts.splice(index, 1); + } + if (script.onStateExit !== prototype.onStateExit) { + const index = this._onStateExitScripts.indexOf(script); + index !== -1 && this._onStateExitScripts.splice(index, 1); + } + } + + /** + * @internal + */ + _onClipChanged(): void { + this._updateFlagManager.dispatch(); + } + + /** + * @internal + */ + _getClipActualStartTime(): number { + return this._clipStartTime * this.clip.length; + } + + /** + * @internal + */ + _getClipActualEndTime(): number { + return this._clipEndTime * this.clip.length; + } + + /** + * @internal + */ + _setEngine(engine: Engine): void { + this._engine = engine; + const { + _onStateEnterScripts: enterScripts, + _onStateUpdateScripts: updateScripts, + _onStateExitScripts: exitScripts + } = this; + for (let i = 0, n = enterScripts.length; i < n; i++) { + enterScripts[i]._engine = engine; + } + for (let i = 0, n = updateScripts.length; i < n; i++) { + updateScripts[i]._engine = engine; + } + for (let i = 0, n = exitScripts.length; i < n; i++) { + exitScripts[i]._engine = engine; + } } } diff --git a/packages/core/src/animation/AnimatorStateDef.ts b/packages/core/src/animation/AnimatorStateDef.ts deleted file mode 100644 index 47dbf16710..0000000000 --- a/packages/core/src/animation/AnimatorStateDef.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { Engine } from "../Engine"; -import { UpdateFlagManager } from "../UpdateFlagManager"; -import { AnimationClip } from "./AnimationClip"; -import type { Animator } from "./Animator"; -import { AnimatorStateTransition } from "./AnimatorStateTransition"; -import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; -import { WrapMode } from "./enums/WrapMode"; -import { StateMachineScript } from "./StateMachineScript"; - -/** - * States are the basic building blocks of a state machine. Each state contains a AnimationClip which will play while the character is in that state. - */ -export class AnimatorStateDef { - /** The speed of the clip. 1 is normal speed, default 1. */ - speed: number = 1.0; - /** The wrap mode used in the state. */ - wrapMode: WrapMode = WrapMode.Loop; - - /** @internal */ - _updateFlagManager: UpdateFlagManager = new UpdateFlagManager(); - /** @internal */ - _transitionCollection: AnimatorStateTransitionCollection = new AnimatorStateTransitionCollection(); - - private _onStateEnterScripts: StateMachineScript[] = []; - private _onStateUpdateScripts: StateMachineScript[] = []; - private _onStateExitScripts: StateMachineScript[] = []; - private _engine: Engine; - private _clipStartTime = 0; - private _clipEndTime = 1; - private _clip: AnimationClip; - - /** - * The transitions that are going out of the state. - */ - get transitions(): Readonly { - return this._transitionCollection.transitions; - } - - /** - * The clip that is being played by this animator state. - */ - get clip(): AnimationClip { - return this._clip; - } - - set clip(clip: AnimationClip) { - const lastClip = this._clip; - if (lastClip === clip) { - return; - } - - if (lastClip) { - lastClip._updateFlagManager.removeListener(this._onClipChanged); - } - - this._clip = clip; - this._clipEndTime = Math.min(this._clipEndTime, 1); - - this._onClipChanged(); - - clip && clip._updateFlagManager.addListener(this._onClipChanged); - } - - /** - * The normalized start time of the clip, the range is 0 to 1, default is 0. - */ - get clipStartTime(): number { - return this._clipStartTime; - } - - set clipStartTime(time: number) { - this._clipStartTime = Math.max(time, 0); - } - - /** - * The normalized end time of the clip, the range is 0 to 1, default is 1. - */ - get clipEndTime(): number { - return this._clipEndTime; - } - - set clipEndTime(time: number) { - this._clipEndTime = Math.min(time, 1); - } - - /** - * @param name - The state's name - */ - constructor(public readonly name: string) { - this._onClipChanged = this._onClipChanged.bind(this); - } - - /** - * Add an outgoing transition. - * @param transition - The transition - */ - addTransition(transition: AnimatorStateTransition): AnimatorStateTransition; - /** - * Add an outgoing transition to the destination state. - * @param animatorState - The destination state - */ - addTransition(animatorState: AnimatorStateDef): AnimatorStateTransition; - - addTransition(transitionOrAnimatorState: AnimatorStateTransition | AnimatorStateDef): AnimatorStateTransition { - return this._transitionCollection.add(transitionOrAnimatorState); - } - - /** - * Add an outgoing transition to exit of the stateMachine. - * @param exitTime - The time at which the transition can take effect. This is represented in normalized time. - */ - addExitTransition(exitTime: number = 1.0): AnimatorStateTransition { - const transition = new AnimatorStateTransition(); - transition._isExit = true; - transition.exitTime = exitTime; - - return this._transitionCollection.add(transition); - } - /** - * Remove a transition from the state. - * @param transition - The transition - */ - removeTransition(transition: AnimatorStateTransition): void { - this._transitionCollection.remove(transition); - if (transition._isExit) { - transition._isExit = false; - } - } - - /** - * Adds a state machine script class of type T to the AnimatorState. - * @param scriptType - The state machine script class of type T - */ - addStateMachineScript(scriptType: new () => T): T { - const script = new scriptType(); - script._engine = this._engine; - script._state = this; - - const { prototype } = StateMachineScript; - if (script.onStateEnter !== prototype.onStateEnter) { - this._onStateEnterScripts.push(script); - } - if (script.onStateUpdate !== prototype.onStateUpdate) { - this._onStateUpdateScripts.push(script); - } - if (script.onStateExit !== prototype.onStateExit) { - this._onStateExitScripts.push(script); - } - - return script; - } - - /** - * Clears all transitions from the state. - */ - clearTransitions(): void { - this._transitionCollection.clear(); - } - - /** - * @internal - */ - _callOnEnter(animator: Animator, layerIndex: number): void { - const scripts = this._onStateEnterScripts; - for (let i = 0, n = scripts.length; i < n; i++) { - scripts[i].onStateEnter(animator, this, layerIndex); - } - } - - /** - * @internal - */ - _callOnUpdate(animator: Animator, layerIndex: number): void { - const scripts = this._onStateUpdateScripts; - for (let i = 0, n = scripts.length; i < n; i++) { - scripts[i].onStateUpdate(animator, this, layerIndex); - } - } - - /** - * @internal - */ - _callOnExit(animator: Animator, layerIndex: number): void { - const scripts = this._onStateExitScripts; - for (let i = 0, n = scripts.length; i < n; i++) { - scripts[i].onStateExit(animator, this, layerIndex); - } - } - - /** - * @internal - */ - _getDuration(): number { - if (this.clip) { - return (this._clipEndTime - this._clipStartTime) * this.clip.length; - } - return null; - } - - /** - * @internal - */ - _removeStateMachineScript(script: StateMachineScript): void { - const { prototype } = StateMachineScript; - if (script.onStateEnter !== prototype.onStateEnter) { - const index = this._onStateEnterScripts.indexOf(script); - index !== -1 && this._onStateEnterScripts.splice(index, 1); - } - if (script.onStateUpdate !== prototype.onStateUpdate) { - const index = this._onStateUpdateScripts.indexOf(script); - index !== -1 && this._onStateUpdateScripts.splice(index, 1); - } - if (script.onStateExit !== prototype.onStateExit) { - const index = this._onStateExitScripts.indexOf(script); - index !== -1 && this._onStateExitScripts.splice(index, 1); - } - } - - /** - * @internal - */ - _onClipChanged(): void { - this._updateFlagManager.dispatch(); - } - - /** - * @internal - */ - _getClipActualStartTime(): number { - return this._clipStartTime * this.clip.length; - } - - /** - * @internal - */ - _getClipActualEndTime(): number { - return this._clipEndTime * this.clip.length; - } - - /** - * @internal - */ - _setEngine(engine: Engine): void { - this._engine = engine; - const { - _onStateEnterScripts: enterScripts, - _onStateUpdateScripts: updateScripts, - _onStateExitScripts: exitScripts - } = this; - for (let i = 0, n = enterScripts.length; i < n; i++) { - enterScripts[i]._engine = engine; - } - for (let i = 0, n = updateScripts.length; i < n; i++) { - updateScripts[i]._engine = engine; - } - for (let i = 0, n = exitScripts.length; i < n; i++) { - exitScripts[i]._engine = engine; - } - } -} diff --git a/packages/core/src/animation/AnimatorStateInstance.ts b/packages/core/src/animation/AnimatorStateInstance.ts new file mode 100644 index 0000000000..dd62757277 --- /dev/null +++ b/packages/core/src/animation/AnimatorStateInstance.ts @@ -0,0 +1,79 @@ +import { AnimationClip } from "./AnimationClip"; +import { AnimatorState } from "./AnimatorState"; +import { AnimatorStateRuntime } from "./internal/AnimatorStateRuntime"; +import { WrapMode } from "./enums/WrapMode"; + +/** + * Per-Animator runtime view of a shared `AnimatorState` asset. + * + * `findAnimatorState` returns this view: each Animator gets its own instance + * bound to a shared `AnimatorState` asset on the controller. Writes on + * `speed` only affect this Animator; reads of asset fields (`name`, `clip`, + * `wrapMode`, ...) forward to the shared state. + * + * Lifecycle: lazy-created by `Animator.findAnimatorState` on first access and + * persists for the layer's lifetime so per-instance overrides survive + * transitions out of and back into the state. + * + * The underlying `AnimatorState` asset is intentionally not part of the + * public surface: it stays reachable through + * `animator.animatorController.layers[i].stateMachine.findStateByName(name)` + * for the rare editor / asset-construction case where you really need to + * mutate the shared asset — the longer path is a visual reminder that the + * change broadcasts to every Animator using the same controller. + */ +export class AnimatorStateInstance { + /** @internal */ + _state: AnimatorState; + /** @internal */ + _runtime: AnimatorStateRuntime; + + private _speed: number | undefined; + + /** The state's name (from the shared asset). */ + get name(): string { + return this._state.name; + } + + /** The animation clip (from the shared asset). */ + get clip(): AnimationClip { + return this._state.clip; + } + + /** The wrap mode (from the shared asset). */ + get wrapMode(): WrapMode { + return this._state.wrapMode; + } + + /** Normalized clip start time (from the shared asset). */ + get clipStartTime(): number { + return this._state.clipStartTime; + } + + /** Normalized clip end time (from the shared asset). */ + get clipEndTime(): number { + return this._state.clipEndTime; + } + + /** + * Per-instance playback speed for this state. + * + * Read: returns the per-instance override if set, otherwise reads through + * to the shared default. + * Write: claims per-instance ownership; later changes to the shared default + * no longer flow through. The per-instance value persists across state + * transitions on the owning Animator. + */ + get speed(): number { + return this._speed ?? this._state.speed; + } + + set speed(value: number) { + this._speed = value; + } + + /** @internal */ + constructor(state: AnimatorState) { + this._state = state; + } +} diff --git a/packages/core/src/animation/AnimatorStateMachine.ts b/packages/core/src/animation/AnimatorStateMachine.ts index 97c47c4642..7a2914280a 100644 --- a/packages/core/src/animation/AnimatorStateMachine.ts +++ b/packages/core/src/animation/AnimatorStateMachine.ts @@ -1,9 +1,9 @@ import { Engine } from "../Engine"; -import { AnimatorStateDef } from "./AnimatorStateDef"; +import { AnimatorState } from "./AnimatorState"; import { AnimatorStateTransition } from "./AnimatorStateTransition"; import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; export interface AnimatorStateMap { - [key: string]: AnimatorStateDef; + [key: string]: AnimatorState; } /** @@ -11,7 +11,7 @@ export interface AnimatorStateMap { */ export class AnimatorStateMachine { /** The list of states. */ - readonly states: AnimatorStateDef[] = []; + readonly states: AnimatorState[] = []; private _engine: Engine; @@ -19,7 +19,7 @@ export class AnimatorStateMachine { * The state will be played automatically. * @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered. */ - defaultState: AnimatorStateDef; + defaultState: AnimatorState; /** @internal */ _entryTransitionCollection = new AnimatorStateTransitionCollection(); @@ -46,10 +46,10 @@ export class AnimatorStateMachine { * Add a state to the state machine. * @param name - The name of the new state */ - addState(name: string): AnimatorStateDef { + addState(name: string): AnimatorState { let state = this.findStateByName(name); if (!state) { - state = new AnimatorStateDef(name); + state = new AnimatorState(name); state._setEngine(this._engine); this.states.push(state); this._statesMap[name] = state; @@ -63,7 +63,7 @@ export class AnimatorStateMachine { * Remove a state from the state machine. * @param state - The state */ - removeState(state: AnimatorStateDef): void { + removeState(state: AnimatorState): void { const { name } = state; const index = this.states.indexOf(state); if (index > -1) { @@ -76,7 +76,7 @@ export class AnimatorStateMachine { * Get the state by name. * @param name - The layer's name */ - findStateByName(name: string): AnimatorStateDef { + findStateByName(name: string): AnimatorState { return this._statesMap[name]; } @@ -106,11 +106,9 @@ export class AnimatorStateMachine { * @param animatorState - The destination state */ - addEntryStateTransition(animatorState: AnimatorStateDef): AnimatorStateTransition; + addEntryStateTransition(animatorState: AnimatorState): AnimatorStateTransition; - addEntryStateTransition( - transitionOrAnimatorState: AnimatorStateTransition | AnimatorStateDef - ): AnimatorStateTransition { + addEntryStateTransition(transitionOrAnimatorState: AnimatorStateTransition | AnimatorState): AnimatorStateTransition { return this._entryTransitionCollection.add(transitionOrAnimatorState); } @@ -131,11 +129,9 @@ export class AnimatorStateMachine { * Add an any transition to the destination state, the default value of any transition's hasExitTime is false. * @param animatorState - The destination state */ - addAnyStateTransition(animatorState: AnimatorStateDef): AnimatorStateTransition; + addAnyStateTransition(animatorState: AnimatorState): AnimatorStateTransition; - addAnyStateTransition( - transitionOrAnimatorState: AnimatorStateTransition | AnimatorStateDef - ): AnimatorStateTransition { + addAnyStateTransition(transitionOrAnimatorState: AnimatorStateTransition | AnimatorState): AnimatorStateTransition { return this._anyStateTransitionCollection.add(transitionOrAnimatorState); } diff --git a/packages/core/src/animation/AnimatorStateTransition.ts b/packages/core/src/animation/AnimatorStateTransition.ts index ec6ce32c2c..651924643f 100644 --- a/packages/core/src/animation/AnimatorStateTransition.ts +++ b/packages/core/src/animation/AnimatorStateTransition.ts @@ -1,6 +1,6 @@ import { AnimatorCondition } from "./AnimatorCondition"; import { AnimatorControllerParameterValue } from "./AnimatorControllerParameter"; -import { AnimatorStateDef } from "./AnimatorStateDef"; +import { AnimatorState } from "./AnimatorState"; import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; import { AnimatorConditionMode } from "./enums/AnimatorConditionMode"; @@ -15,7 +15,7 @@ export class AnimatorStateTransition { /** ExitTime represents the exact time at which the transition can take effect. This is represented in normalized time. */ exitTime = 1.0; /** The destination state of the transition. */ - destinationState: AnimatorStateDef; + destinationState: AnimatorState; /** Mutes the transition. The transition will never occur. */ mute = false; /** Determines whether the duration of the transition is reported in a fixed duration in seconds or as a normalized time. */ diff --git a/packages/core/src/animation/AnimatorStateTransitionCollection.ts b/packages/core/src/animation/AnimatorStateTransitionCollection.ts index ba36f482c0..6ea486b508 100644 --- a/packages/core/src/animation/AnimatorStateTransitionCollection.ts +++ b/packages/core/src/animation/AnimatorStateTransitionCollection.ts @@ -1,4 +1,4 @@ -import { AnimatorStateDef } from "./AnimatorStateDef"; +import { AnimatorState } from "./AnimatorState"; import { AnimatorStateTransition } from "./AnimatorStateTransition"; /** @@ -24,9 +24,9 @@ export class AnimatorStateTransitionCollection { return this.transitions[index]; } - add(transitionOrAnimatorState: AnimatorStateTransition | AnimatorStateDef): AnimatorStateTransition { + add(transitionOrAnimatorState: AnimatorStateTransition | AnimatorState): AnimatorStateTransition { let transition: AnimatorStateTransition; - if (transitionOrAnimatorState instanceof AnimatorStateDef) { + if (transitionOrAnimatorState instanceof AnimatorState) { transition = new AnimatorStateTransition(); transition.hasExitTime = false; transition.destinationState = transitionOrAnimatorState; diff --git a/packages/core/src/animation/StateMachineScript.ts b/packages/core/src/animation/StateMachineScript.ts index 7fc29c4dbd..775d69eaef 100644 --- a/packages/core/src/animation/StateMachineScript.ts +++ b/packages/core/src/animation/StateMachineScript.ts @@ -1,5 +1,5 @@ import { Animator } from "../animation/Animator"; -import { AnimatorStateDef } from "../animation/AnimatorStateDef"; +import { AnimatorState } from "../animation/AnimatorState"; import { EngineObject } from "../base/EngineObject"; /** @@ -7,7 +7,7 @@ import { EngineObject } from "../base/EngineObject"; */ export class StateMachineScript extends EngineObject { /** @internal */ - _state: AnimatorStateDef; + _state: AnimatorState; constructor() { super(null); @@ -19,7 +19,7 @@ export class StateMachineScript extends EngineObject { * @param animatorState - The state be evaluated * @param layerIndex - The index of the layer where the state is located */ - onStateEnter(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void {} + onStateEnter(animator: Animator, animatorState: AnimatorState, layerIndex: number): void {} /** * onStateUpdate is called on each Update frame between onStateEnter and onStateExit callbacks. @@ -27,7 +27,7 @@ export class StateMachineScript extends EngineObject { * @param animatorState - The state be evaluated * @param layerIndex - The index of the layer where the state is located */ - onStateUpdate(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void {} + onStateUpdate(animator: Animator, animatorState: AnimatorState, layerIndex: number): void {} /** * onStateExit is called when a transition ends and the state machine finishes evaluating this state. @@ -35,7 +35,7 @@ export class StateMachineScript extends EngineObject { * @param animatorState - The state be evaluated * @param layerIndex - The index of the layer where the state is located */ - onStateExit(animator: Animator, animatorState: AnimatorStateDef, layerIndex: number): void {} + onStateExit(animator: Animator, animatorState: AnimatorState, layerIndex: number): void {} protected override _onDestroy(): void { super._onDestroy(); diff --git a/packages/core/src/animation/index.ts b/packages/core/src/animation/index.ts index d6b3c8db3f..1bbffce883 100644 --- a/packages/core/src/animation/index.ts +++ b/packages/core/src/animation/index.ts @@ -11,7 +11,7 @@ export { Animator } from "./Animator"; export { AnimatorController } from "./AnimatorController"; export { AnimatorControllerLayer } from "./AnimatorControllerLayer"; export { AnimatorState } from "./AnimatorState"; -export { AnimatorStateDef } from "./AnimatorStateDef"; +export { AnimatorStateInstance } from "./AnimatorStateInstance"; export { AnimatorStateMachine } from "./AnimatorStateMachine"; export { AnimatorStateTransition } from "./AnimatorStateTransition"; export { AnimatorConditionMode } from "./enums/AnimatorConditionMode"; diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index a0b1838717..b0759c9b76 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -1,6 +1,6 @@ import { AnimatorControllerLayer } from "../AnimatorControllerLayer"; import { AnimatorState } from "../AnimatorState"; -import { AnimatorStateDef } from "../AnimatorStateDef"; +import { AnimatorStateInstance } from "../AnimatorStateInstance"; import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { LayerState } from "../enums/LayerState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; @@ -15,8 +15,8 @@ export class AnimatorLayerData { layer: AnimatorControllerLayer; curveOwnerPool: Record> = Object.create(null); animatorStateDataMap: Record = Object.create(null); - /** Per-state user-facing view containers. Lazy populated. */ - stateMap: Record = Object.create(null); + /** Per-state user-facing instance containers. Lazy populated. */ + instanceMap: Record = Object.create(null); /** Currently playing state's runtime; null when standby. */ srcRuntime: AnimatorStateRuntime | null = null; /** Cross-fade target state's runtime; null when not cross-fading. */ @@ -28,20 +28,20 @@ export class AnimatorLayerData { crossLayerOwnerCollection: AnimationCurveLayerOwner[] = []; /** - * Get or lazily create the persistent (state-view, runtime) pair for a def. - * Rebuilds when the cached view is bound to a different def object - * (same-name remove + re-add). + * Get or lazily create the persistent (instance, runtime) pair for a shared + * `AnimatorState` asset. Rebuilds when the cached instance is bound to a + * different asset object (same-name remove + re-add). */ - getOrCreateRuntime(def: AnimatorStateDef): AnimatorStateRuntime { - const map = this.stateMap; - const name = def.name; - let state = map[name]; - if (state?._def !== def) { - state = new AnimatorState(def); - new AnimatorStateRuntime(state); - map[name] = state; + getOrCreateRuntime(state: AnimatorState): AnimatorStateRuntime { + const map = this.instanceMap; + const name = state.name; + let instance = map[name]; + if (instance?._state !== state) { + instance = new AnimatorStateInstance(state); + new AnimatorStateRuntime(instance); + map[name] = instance; } - return state._runtime; + return instance._runtime; } /** After cross-fade completes, promote destRuntime to srcRuntime. */ diff --git a/packages/core/src/animation/internal/AnimatorStateData.ts b/packages/core/src/animation/internal/AnimatorStateData.ts index c89a40f345..45e2099eb0 100644 --- a/packages/core/src/animation/internal/AnimatorStateData.ts +++ b/packages/core/src/animation/internal/AnimatorStateData.ts @@ -1,4 +1,4 @@ -import { AnimatorStateDef } from "../AnimatorStateDef"; +import { AnimatorState } from "../AnimatorState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimationEventHandler } from "./AnimationEventHandler"; @@ -11,7 +11,7 @@ export class AnimatorStateData { curveLayerOwner: AnimationCurveLayerOwner[] = []; eventHandlers: AnimationEventHandler[] = []; - constructor(readonly state: AnimatorStateDef) {} + constructor(readonly state: AnimatorState) {} /** Detach the clipChangedListener from state's UpdateFlagManager. No-op if not attached. */ dispose(): void { diff --git a/packages/core/src/animation/internal/AnimatorStateRuntime.ts b/packages/core/src/animation/internal/AnimatorStateRuntime.ts index 7e38b8af54..279b281ead 100644 --- a/packages/core/src/animation/internal/AnimatorStateRuntime.ts +++ b/packages/core/src/animation/internal/AnimatorStateRuntime.ts @@ -1,4 +1,5 @@ import { AnimatorState } from "../AnimatorState"; +import { AnimatorStateInstance } from "../AnimatorStateInstance"; import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState"; import { WrapMode } from "../enums/WrapMode"; import { AnimatorStateData } from "./AnimatorStateData"; @@ -6,16 +7,16 @@ import { AnimatorStateData } from "./AnimatorStateData"; /** * @internal * - * Engine-owned runtime playback state for a single (Animator, AnimatorStateDef) pair. + * Engine-owned runtime playback state for a single (Animator, AnimatorState) pair. * - * Lives alongside an `AnimatorState` (user-facing per-instance view) and tracks - * evaluation-time fields: how much has played, current clip time, play state, - * event index, orientation, etc. Mutated by the Animator's update loop; not - * exposed to user code. + * Lives alongside an `AnimatorStateInstance` (user-facing per-instance view) + * and tracks evaluation-time fields: how much has played, current clip time, + * play state, event index, orientation, etc. Mutated by the Animator's update + * loop; not exposed to user code. */ export class AnimatorStateRuntime { /** The user-facing per-instance view this runtime is bound to. */ - readonly state: AnimatorState; + readonly instance: AnimatorStateInstance; /** Curve owners + event handlers (shared per-Animator per-state). */ stateData: AnimatorStateData; @@ -29,26 +30,26 @@ export class AnimatorStateRuntime { private _changedOrientation: boolean = false; - constructor(state: AnimatorState) { - this.state = state; - state._runtime = this; + constructor(instance: AnimatorStateInstance) { + this.instance = instance; + instance._runtime = this; } /** * Reset runtime fields when (re-)entering this state. - * Does NOT touch user-written per-instance overrides on `state`. + * Does NOT touch user-written per-instance overrides on `instance`. */ resetForPlay(stateData: AnimatorStateData, offsetFrameTime: number): void { - const def = this.state._def; + const state = this.instance._state; this.stateData = stateData; this.offsetFrameTime = offsetFrameTime; this.playedTime = 0; this.playState = AnimatorStatePlayState.UnStarted; - this.clipTime = def.clipStartTime * def.clip.length; + this.clipTime = state.clipStartTime * state.clip.length; this.currentEventIndex = 0; this.isForward = true; this._changedOrientation = false; - def._transitionCollection.needResetCurrentCheckIndex = true; + state._transitionCollection.needResetCurrentCheckIndex = true; } updateOrientation(deltaTime: number): void { @@ -64,11 +65,11 @@ export class AnimatorStateRuntime { update(deltaTime: number): void { this.playedTime += deltaTime; - const def = this.state._def; + const state = this.instance._state; let time = this.playedTime + this.offsetFrameTime; - const duration = def._getDuration(); + const duration = state._getDuration(); this.playState = AnimatorStatePlayState.Playing; - if (def.wrapMode === WrapMode.Loop) { + if (state.wrapMode === WrapMode.Loop) { time = duration ? time % duration : 0; } else { if (Math.abs(time) >= duration) { @@ -78,7 +79,7 @@ export class AnimatorStateRuntime { } time < 0 && (time += duration); - this.clipTime = time + def.clipStartTime * def.clip.length; + this.clipTime = time + state.clipStartTime * state.clip.length; if (this._changedOrientation) { !this.isForward && this._correctTime(); @@ -87,11 +88,11 @@ export class AnimatorStateRuntime { } private _correctTime(): void { - const def = this.state._def; + const state = this.instance._state; // Reverse playback resumed at clipTime=0 would step into negatives; jump to // clipEnd so the next sample continues seamlessly from the end of the clip. if (this.clipTime === 0) { - this.clipTime = def.clipEndTime * def.clip.length; + this.clipTime = state.clipEndTime * state.clip.length; } } } diff --git a/packages/loader/src/AnimatorControllerLoader.ts b/packages/loader/src/AnimatorControllerLoader.ts index 6ba846a7af..b28a89ddb9 100644 --- a/packages/loader/src/AnimatorControllerLoader.ts +++ b/packages/loader/src/AnimatorControllerLoader.ts @@ -8,7 +8,7 @@ import { AnimatorController, AnimatorControllerLayer, AnimatorStateTransition, - AnimatorStateDef, + AnimatorState, AnimatorConditionMode, AnimatorControllerParameterValue, WrapMode @@ -36,7 +36,7 @@ class AnimatorControllerLoader extends Loader { if (stateMachineData) { const { states, transitions, entryTransitions, anyTransitions } = stateMachineData; const stateMachine = layer.stateMachine; - const statesMap: Record = {}; + const statesMap: Record = {}; const transitionsMap: Record = {}; states.forEach((stateData: IStateData, stateIndex: number) => { const { @@ -119,10 +119,7 @@ class AnimatorControllerLoader extends Loader { }); } - private _createTransition( - transitionData: ITransitionData, - destinationState: AnimatorStateDef - ): AnimatorStateTransition { + private _createTransition(transitionData: ITransitionData, destinationState: AnimatorState): AnimatorStateTransition { const transition = new AnimatorStateTransition(); transition.hasExitTime = transitionData.hasExitTime; transition.isFixedDuration = transitionData.isFixedDuration; diff --git a/packages/loader/src/gltf/parser/GLTFAnimatorControllerParser.ts b/packages/loader/src/gltf/parser/GLTFAnimatorControllerParser.ts index 47bbdda64b..c37921e29d 100644 --- a/packages/loader/src/gltf/parser/GLTFAnimatorControllerParser.ts +++ b/packages/loader/src/gltf/parser/GLTFAnimatorControllerParser.ts @@ -41,7 +41,7 @@ export class GLTFAnimatorControllerParser extends GLTFParser { const name = animationClip.name; const uniqueName = animatorStateMachine.makeUniqueStateName(name); if (uniqueName !== name) { - console.warn(`AnimatorStateDef name is existed, name: ${name} reset to ${uniqueName}`); + console.warn(`AnimatorState name is existed, name: ${name} reset to ${uniqueName}`); } const animatorState = animatorStateMachine.addState(uniqueName); animatorState.clip = animationClip; diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 1c9d1fcc2a..7a622869b6 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -70,12 +70,12 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); stateMachine.clearEntryStateTransitions(); - // 清理各状态的 transitions 并恢复默认属性 (mutate shared AnimatorStateDef) + // 清理各状态的 transitions 并恢复默认属性 (mutate shared AnimatorState) const stateNames = ["Survey", "Walk", "Run"]; for (const name of stateNames) { const view = animator.findAnimatorState(name); if (view) { - const def = (view as any)._def; + const def = (view as any)._state; def.clearTransitions(); def.speed = 1; def.clipStartTime = 0; @@ -202,12 +202,12 @@ describe("Animator test", function () { animator.play(stateName); const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); let animatorState = animator.findAnimatorState(stateName, layerIndex); - expect((animatorState as any)._def).to.eq(currentAnimatorState); + expect((animatorState as any)._state).to.eq(currentAnimatorState); animator.play(expectedStateName); animatorState = animator.findAnimatorState(expectedStateName, layerIndex); - expect((animatorState as any)._def).not.to.eq(currentAnimatorState); - expect((animatorState as any)._def.name).to.eq(expectedStateName); + expect((animatorState as any)._state).not.to.eq(currentAnimatorState); + expect((animatorState as any)._state.name).to.eq(expectedStateName); }); it("animation getCurrentAnimatorState", () => { @@ -254,22 +254,22 @@ describe("Animator test", function () { expect(srcRuntime.state.name).to.eq("Run"); expect(srcRuntime.playedTime).to.eq(0.3); // @ts-ignore - expect(srcRuntime.clipTime).to.eq(0.3 + 0.1 * (runState as any)._def._getDuration()); + expect(srcRuntime.clipTime).to.eq(0.3 + 0.1 * (runState as any)._state._getDuration()); }); it("animation cross fade by transition", () => { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const transition = new AnimatorStateTransition(); - transition.destinationState = (runState as any)._def; + transition.destinationState = (runState as any)._state; transition.duration = 1; transition.exitTime = 1; - (walkState as any)._def.addTransition(transition); + (walkState as any)._state.addTransition(transition); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._def.clip.length - 0.1); + animator.update((walkState as any)._state.clip.length - 0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); @@ -312,7 +312,7 @@ describe("Animator test", function () { additiveLayer.mask = mask; additiveLayer.blendingMode = AnimatorLayerBlendingMode.Additive; animatorController.addLayer(additiveLayer); - const clip = (animator.findAnimatorState("Run") as any)._def.clip; + const clip = (animator.findAnimatorState("Run") as any)._state.clip; const newState = animatorStateMachine.addState("Run"); newState.clipStartTime = 1; newState.clip = clip; @@ -373,11 +373,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - (idleState as any)._def.clearTransitions(); + (idleState as any)._state.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._def.clearTransitions(); + (walkState as any)._state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._def.clearTransitions(); + (runState as any)._state.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -385,68 +385,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = (walkState as any)._def; + toWalkTransition.destinationState = (walkState as any)._state; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.9; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - (idleState as any)._def.addTransition(toWalkTransition); + (idleState as any)._state.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - (toWalkTransition.exitTime * (idleState as any)._def._getDuration()) / idleSpeed + + (toWalkTransition.exitTime * (idleState as any)._state._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * (walkState as any)._def._getDuration(); + toWalkTransition.duration * (walkState as any)._state._getDuration(); - const exitTransition = (idleState as any)._def.addExitTransition(); + const exitTransition = (idleState as any)._state.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = (runState as any)._def; + toRunTransition.destinationState = (runState as any)._state; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.9; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - (walkState as any)._def.addTransition(toRunTransition); + (walkState as any)._state.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._def._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._state._getDuration() + //@ts-ignore - toRunTransition.duration * (runState as any)._def._getDuration(); + toRunTransition.duration * (runState as any)._state._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = (idleState as any)._def; + toIdleTransition.destinationState = (idleState as any)._state; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.9; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - (walkState as any)._def.addTransition(toIdleTransition); + (walkState as any)._state.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._def._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._state._getDuration() + //@ts-ignore - (toIdleTransition.duration * (idleState as any)._def._getDuration()) / idleSpeed; + (toIdleTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = (walkState as any)._def; + runToWalkTransition.destinationState = (walkState as any)._state; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.9; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - (runState as any)._def.addTransition(runToWalkTransition); + (runState as any)._state.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._def._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._state._getDuration() + //@ts-ignore - runToWalkTransition.duration * (walkState as any)._def._getDuration(); + runToWalkTransition.duration * (walkState as any)._state._getDuration(); - stateMachine.addEntryStateTransition((idleState as any)._def); + stateMachine.addEntryStateTransition((idleState as any)._state); - const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._def); + const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._state); 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)._def._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * (walkState as any)._state._getDuration() + // @ts-ignore - (anyTransition.duration * (idleState as any)._def._getDuration()) / idleSpeed; + (anyTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -498,11 +498,11 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); const idleSpeed = 2; idleState.speed = idleSpeed; - (idleState as any)._def.clearTransitions(); + (idleState as any)._state.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._def.clearTransitions(); + (walkState as any)._state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._def.clearTransitions(); + (runState as any)._state.clearTransitions(); let idleToWalkTime = 0; let walkToRunTime = 0; let runToWalkTime = 0; @@ -510,68 +510,68 @@ describe("Animator test", function () { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = (walkState as any)._def; + toWalkTransition.destinationState = (walkState as any)._state; toWalkTransition.duration = 0.2; toWalkTransition.exitTime = 0.1; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - (idleState as any)._def.addTransition(toWalkTransition); + (idleState as any)._state.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - ((1 - toWalkTransition.exitTime) * (idleState as any)._def._getDuration()) / idleSpeed + + ((1 - toWalkTransition.exitTime) * (idleState as any)._state._getDuration()) / idleSpeed + //@ts-ignore - toWalkTransition.duration * (walkState as any)._def._getDuration(); + toWalkTransition.duration * (walkState as any)._state._getDuration(); - const exitTransition = (idleState as any)._def.addExitTransition(); + const exitTransition = (idleState as any)._state.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = (runState as any)._def; + toRunTransition.destinationState = (runState as any)._state; toRunTransition.duration = 0.3; toRunTransition.exitTime = 0.1; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - (walkState as any)._def.addTransition(toRunTransition); + (walkState as any)._state.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (1 - toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._def._getDuration() + + (1 - toRunTransition.exitTime - toWalkTransition.duration) * (walkState as any)._state._getDuration() + //@ts-ignore - toRunTransition.duration * (runState as any)._def._getDuration(); + toRunTransition.duration * (runState as any)._state._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = (idleState as any)._def; + toIdleTransition.destinationState = (idleState as any)._state; toIdleTransition.duration = 0.3; toIdleTransition.exitTime = 0.1; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - (walkState as any)._def.addTransition(toIdleTransition); + (walkState as any)._state.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (1 - toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._def._getDuration() + + (1 - toIdleTransition.exitTime - toRunTransition.duration) * (walkState as any)._state._getDuration() + //@ts-ignore - (toIdleTransition.duration * (idleState as any)._def._getDuration()) / idleSpeed; + (toIdleTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = (walkState as any)._def; + runToWalkTransition.destinationState = (walkState as any)._state; runToWalkTransition.duration = 0.3; runToWalkTransition.exitTime = 0.1; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - (runState as any)._def.addTransition(runToWalkTransition); + (runState as any)._state.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (1 - runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._def._getDuration() + + (1 - runToWalkTransition.exitTime - toRunTransition.duration) * (runState as any)._state._getDuration() + //@ts-ignore - runToWalkTransition.duration * (walkState as any)._def._getDuration(); + runToWalkTransition.duration * (walkState as any)._state._getDuration(); - stateMachine.addEntryStateTransition((idleState as any)._def); + stateMachine.addEntryStateTransition((idleState as any)._state); - const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._def); + const anyTransition = stateMachine.addAnyStateTransition((idleState as any)._state); 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)._def._getDuration() + + (1 - anyTransition.exitTime - toIdleTransition.duration) * (walkState as any)._state._getDuration() + // @ts-ignore - (anyTransition.duration * (idleState as any)._def._getDuration()) / idleSpeed; + (anyTransition.duration * (idleState as any)._state._getDuration()) / idleSpeed; // @ts-ignore animator.engine.time._frameCount++; @@ -615,10 +615,10 @@ describe("Animator test", function () { it("transitionOffset", () => { const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._def.clearTransitions(); + (walkState as any)._state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._def.clearTransitions(); - const toRunTransition = (walkState as any)._def.addTransition((runState as any)._def); + (runState as any)._state.clearTransitions(); + const toRunTransition = (walkState as any)._state.addTransition((runState as any)._state); toRunTransition.exitTime = 0; toRunTransition.duration = 1; toRunTransition.offset = 0.5; @@ -636,15 +636,15 @@ describe("Animator test", function () { it("clipStartTime crossFade", () => { const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._def.wrapMode = WrapMode.Once; - (walkState as any)._def.clipStartTime = 0.8; - (walkState as any)._def.clearTransitions(); + (walkState as any)._state.wrapMode = WrapMode.Once; + (walkState as any)._state.clipStartTime = 0.8; + (walkState as any)._state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._def.clearTransitions(); - const toRunTransition = (walkState as any)._def.addTransition((runState as any)._def); + (runState as any)._state.clearTransitions(); + const toRunTransition = (walkState as any)._state.addTransition((runState as any)._state); toRunTransition.exitTime = 0.5; toRunTransition.duration = 1; - (runState as any)._def.clipStartTime = 0.5; + (runState as any)._state.clipStartTime = 0.5; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -658,9 +658,9 @@ describe("Animator test", function () { const animatorLayerData = animator["_animatorLayersData"]; const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._def.wrapMode = WrapMode.Once; - (walkState as any)._def.clearTransitions(); - (walkState as any)._def.addExitTransition(); + (walkState as any)._state.wrapMode = WrapMode.Once; + (walkState as any)._state.clearTransitions(); + (walkState as any)._state.addExitTransition(); animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -827,15 +827,15 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Run"); // For test clipStartTime is not 0 and transition duration is 0 - (walkState as any)._def.clipStartTime = 0.5; - (walkState as any)._def.addStateMachineScript( + (walkState as any)._state.clipStartTime = 0.5; + (walkState as any)._state.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator) { animator.setParameterValue("playRun", 0); } } ); - const transition = stateMachine.addAnyStateTransition((animator.findAnimatorState("Run") as any)._def); + const transition = stateMachine.addAnyStateTransition((animator.findAnimatorState("Run") as any)._state); transition.addCondition("playRun", AnimatorConditionMode.Equals, 1); // For test clipStartTime is not 0 and transition duration is 0 transition.duration = 0; @@ -848,7 +848,7 @@ describe("Animator test", function () { expect(layerData.srcRuntime.state.name).to.eq("Run"); expect(layerData.srcRuntime.playedTime).to.eq(0.5); - expect(layerData.srcRuntime.clipTime).to.eq((walkState as any)._def.clip.length * 0.5 + 0.5); + expect(layerData.srcRuntime.clipTime).to.eq((walkState as any)._state.clip.length * 0.5 + 0.5); }); it("hasExitTime", () => { @@ -861,13 +861,13 @@ describe("Animator test", function () { stateMachine.clearAnyStateTransitions(); const idleState = animator.findAnimatorState("Survey"); idleState.speed = 1; - (idleState as any)._def.clearTransitions(); + (idleState as any)._state.clearTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._def.clipStartTime = 0; - (walkState as any)._def.clearTransitions(); + (walkState as any)._state.clipStartTime = 0; + (walkState as any)._state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._def.clearTransitions(); - const walkToRunTransition = (walkState as any)._def.addTransition((runState as any)._def); + (runState as any)._state.clearTransitions(); + const walkToRunTransition = (walkState as any)._state.addTransition((runState as any)._state); walkToRunTransition.hasExitTime = true; walkToRunTransition.exitTime = 0.5; walkToRunTransition.duration = 0; @@ -875,10 +875,10 @@ describe("Animator test", function () { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._def.clip.length * 0.5); + animator.update((walkState as any)._state.clip.length * 0.5); expect(layerData.destRuntime.state.name).to.eq("Run"); expect(layerData.destRuntime.playedTime).to.eq(0); - const anyToIdleTransition = stateMachine.addAnyStateTransition((idleState as any)._def); + const anyToIdleTransition = stateMachine.addAnyStateTransition((idleState as any)._state); anyToIdleTransition.hasExitTime = false; anyToIdleTransition.duration = 0.2; anyToIdleTransition.addCondition("triggerIdle", AnimatorConditionMode.If, true); @@ -890,9 +890,9 @@ describe("Animator test", function () { expect(layerData.srcRuntime.playedTime).to.eq(0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update((idleState as any)._def.clip.length * 0.2 - 0.1); + animator.update((idleState as any)._state.clip.length * 0.2 - 0.1); expect(layerData.srcRuntime.state.name).to.eq("Survey"); - expect(layerData.srcRuntime.clipTime).to.eq((idleState as any)._def.clip.length * 0.2); + expect(layerData.srcRuntime.clipTime).to.eq((idleState as any)._state.clip.length * 0.2); }); it("setTriggerParameter", () => { @@ -905,16 +905,16 @@ describe("Animator test", function () { stateMachine.clearEntryStateTransitions(); stateMachine.clearAnyStateTransitions(); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._def.clearTransitions(); + (walkState as any)._state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._def.clipStartTime = 0; - (runState as any)._def.clearTransitions(); - const walkToRunTransition = (walkState as any)._def.addTransition((runState as any)._def); + (runState as any)._state.clipStartTime = 0; + (runState as any)._state.clearTransitions(); + const walkToRunTransition = (walkState as any)._state.addTransition((runState as any)._state); walkToRunTransition.hasExitTime = false; walkToRunTransition.duration = 0.1; walkToRunTransition.addCondition("triggerRun", AnimatorConditionMode.If, true); - const runToWalkTransition = (runState as any)._def.addTransition((walkState as any)._def); + const runToWalkTransition = (runState as any)._state.addTransition((walkState as any)._state); runToWalkTransition.hasExitTime = true; runToWalkTransition.exitTime = 0.7; runToWalkTransition.duration = 0.3; @@ -934,20 +934,20 @@ describe("Animator test", function () { expect(animator.getParameterValue("triggerWalk")).to.eq(true); // @ts-ignore animator.engine.time._frameCount++; - animator.update((runState as any)._def.clip.length * 0.1 - 0.1); + animator.update((runState as any)._state.clip.length * 0.1 - 0.1); expect(layerData.srcRuntime.state.name).to.eq("Run"); - expect(layerData.srcRuntime.playedTime).to.eq((runState as any)._def.clip.length * 0.1); + expect(layerData.srcRuntime.playedTime).to.eq((runState as any)._state.clip.length * 0.1); // @ts-ignore animator.engine.time._frameCount++; - animator.update((runState as any)._def.clip.length * 0.6); + animator.update((runState as any)._state.clip.length * 0.6); expect(layerData.destRuntime.state.name).to.eq("Walk"); expect(layerData.destRuntime.playedTime).to.eq(0); expect(animator.getParameterValue("triggerWalk")).to.eq(false); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._def.clip.length * 0.3); + animator.update((walkState as any)._state.clip.length * 0.3); expect(layerData.srcRuntime.state.name).to.eq("Walk"); - expect(layerData.srcRuntime.playedTime).to.eq((walkState as any)._def.clip.length * 0.3); + expect(layerData.srcRuntime.playedTime).to.eq((walkState as any)._state.clip.length * 0.3); }); it("fixedDuration", () => { @@ -957,11 +957,11 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._def.clearTransitions(); + (walkState as any)._state.clearTransitions(); const runState = animator.findAnimatorState("Run"); - (runState as any)._def.clipStartTime = (runState as any)._def.clipEndTime = 0; - (runState as any)._def.clearTransitions(); - const walkToRunTransition = (walkState as any)._def.addTransition((runState as any)._def); + (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); walkToRunTransition.hasExitTime = false; walkToRunTransition.isFixedDuration = true; walkToRunTransition.duration = 0.1; @@ -1096,7 +1096,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._def); + const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1134,12 +1134,12 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - (walkState as any)._def.clipStartTime = 0; - (walkState as any)._def.clipEndTime = 1; - (walkState as any)._def.clearTransitions(); + (walkState as any)._state.clipStartTime = 0; + (walkState as any)._state.clipEndTime = 1; + (walkState as any)._state.clearTransitions(); // A noExitTime transition that fails (ensures noExitTimeCount > 0). - const noExitFailTransition = (walkState as any)._def.addTransition((idleState as any)._def); + const noExitFailTransition = (walkState as any)._state.addTransition((idleState as any)._state); noExitFailTransition.hasExitTime = false; noExitFailTransition.duration = 0; noExitFailTransition.addCondition("never", AnimatorConditionMode.If, true); @@ -1148,16 +1148,16 @@ describe("Animator test", function () { const exitTimeTransition = new AnimatorStateTransition(); exitTimeTransition.exitTime = 0.5; exitTimeTransition.duration = 0; - exitTimeTransition.destinationState = (runState as any)._def; + exitTimeTransition.destinationState = (runState as any)._state; exitTimeTransition.addCondition("goRun", AnimatorConditionMode.If, true); - (walkState as any)._def.addTransition(exitTimeTransition); + (walkState as any)._state.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)._def.clip.length * 0.25; + const preExitDeltaTime = (walkState as any)._state.clip.length * 0.25; // @ts-ignore animator.engine.time._frameCount++; animator.update(preExitDeltaTime); @@ -1167,7 +1167,7 @@ describe("Animator test", function () { // Update past exitTime, should transition to Run. // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._def.clip.length * 0.5); + animator.update((walkState as any)._state.clip.length * 0.5); expect(animator.getCurrentAnimatorState(0).name).to.eq("Run"); }); @@ -1179,17 +1179,17 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); // AnyState -> Idle (can interrupt) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._def); + const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); // Play Walk with Once mode, let it finish to reach Finished state - (walkState as any)._def.wrapMode = WrapMode.Once; + (walkState as any)._state.wrapMode = WrapMode.Once; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; - animator.update((walkState as any)._def.clip.length + 0.1); + animator.update((walkState as any)._state.clip.length + 0.1); // @ts-ignore const layerData = animator._getAnimatorLayerData(0); @@ -1222,7 +1222,7 @@ describe("Animator test", function () { const runState = animator.findAnimatorState("Run"); // AnyState -> Run (always true, noExitTime) - const anyToRun = stateMachine.addAnyStateTransition((runState as any)._def); + const anyToRun = stateMachine.addAnyStateTransition((runState as any)._state); anyToRun.hasExitTime = false; anyToRun.duration = 0.2; anyToRun.addCondition("alwaysTrue", AnimatorConditionMode.If, true); @@ -1258,7 +1258,7 @@ describe("Animator test", function () { const idleState = animator.findAnimatorState("Survey"); // AnyState -> Idle (always true, noExitTime) - const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._def); + const anyToIdle = stateMachine.addAnyStateTransition((idleState as any)._state); anyToIdle.hasExitTime = false; anyToIdle.duration = 0.2; anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); @@ -1281,19 +1281,19 @@ describe("Animator test", function () { const walkState = animator.findAnimatorState("Walk"); const runState = animator.findAnimatorState("Run"); const idleState = animator.findAnimatorState("Survey"); - (walkState as any)._def.clearTransitions(); + (walkState as any)._state.clearTransitions(); // Add a noExitTime transition - const t1 = (walkState as any)._def.addTransition((runState as any)._def); + const t1 = (walkState as any)._state.addTransition((runState as any)._state); t1.hasExitTime = false; // Add a hasExitTime transition - const t2 = (walkState as any)._def.addTransition((idleState as any)._def); + const t2 = (walkState as any)._state.addTransition((idleState as any)._state); t2.hasExitTime = true; t2.exitTime = 0.5; // @ts-ignore - const collection = (walkState as any)._def._transitionCollection; + const collection = (walkState as any)._state._transitionCollection; expect(collection.noExitTimeCount).to.eq(1); expect(collection.count).to.eq(2); @@ -1321,7 +1321,7 @@ describe("Animator test", function () { 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)._def.speed); // live-bound default + 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); @@ -1407,7 +1407,7 @@ describe("Animator test", function () { const sm = animator.animatorController.layers[0].stateMachine; const oldSurvey = animator.findAnimatorState("Survey"); expect(oldSurvey).not.to.eq(null); - const oldStateRef = (oldSurvey as any)._def; + const oldStateRef = (oldSurvey as any)._state; const originalIndex = sm.states.indexOf(oldStateRef); // Simulate dynamic controller mutation: remove and re-add same-name state @@ -1417,7 +1417,7 @@ describe("Animator test", function () { const newHandle = animator.findAnimatorState("Survey"); expect(newHandle).not.to.eq(null); - expect((newHandle as any)._def).to.eq(newStateRef); + expect((newHandle as any)._state).to.eq(newStateRef); expect(newHandle).not.to.eq(oldSurvey); // Restore original Survey state so subsequent tests still see the @@ -1577,7 +1577,7 @@ describe("Animator test", function () { it("_reset detaches stateData clipChangedListeners so they do not accumulate on the AnimatorState", () => { const survey = animator.findAnimatorState("Survey"); expect(survey).not.to.eq(null); - const surveyState = (survey as any)._def; + const surveyState = (survey as any)._state; // @ts-ignore — read internal listener list size const listenersBefore = surveyState._updateFlagManager._listeners.length; @@ -1645,10 +1645,10 @@ describe("Animator test", function () { it("state-machine self-transition is also a no-op (alias-guard policy)", () => { const walk = animator.findAnimatorState("Walk"); - (walk as any)._def.clearTransitions(); + (walk as any)._state.clearTransitions(); animator.animatorController.addParameter("restart", false); - const selfTransition = (walk as any)._def.addTransition((walk as any)._def); + const selfTransition = (walk as any)._state.addTransition((walk as any)._state); selfTransition.hasExitTime = false; selfTransition.duration = 0.1; selfTransition.addCondition("restart", AnimatorConditionMode.If, true); @@ -1765,13 +1765,13 @@ describe("Animator test", function () { 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)._def.clearTransitions(); - (walk as any)._def.clearTransitions(); + (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)._def.addTransition((walk as any)._def); + const transition = (survey as any)._state.addTransition((walk as any)._state); transition.hasExitTime = false; transition.duration = 0.3; transition.addCondition("goWalk", AnimatorConditionMode.If, true); From f1709c4494a305d773edfc64804570877f259ead Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 18:07:52 +0800 Subject: [PATCH 63/92] refactor(animation): trim verbose comments on instance/runtime types and docs --- docs/en/animation/animator.mdx | 23 +++++-------- docs/zh/animation/animator.mdx | 31 ++++++----------- e2e/case/animator-stateMachine.ts | 1 - e2e/case/animator-stateMachineScript.ts | 2 -- packages/core/src/animation/Animator.ts | 27 +++++---------- .../src/animation/AnimatorStateInstance.ts | 34 ++++--------------- .../animation/internal/AnimatorLayerData.ts | 10 +----- .../internal/AnimatorStateRuntime.ts | 19 +++-------- 8 files changed, 38 insertions(+), 109 deletions(-) diff --git a/docs/en/animation/animator.mdx b/docs/en/animation/animator.mdx index 85896dac47..111d55d4a9 100644 --- a/docs/en/animation/animator.mdx +++ b/docs/en/animation/animator.mdx @@ -139,39 +139,32 @@ animator.crossFade("OtherStateName", 0.3); ### Get Current Playing Animation State -Use [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) to get the currently playing per-Animator state container. The parameter is the index `layerIndex` of the `AnimatorControllerLayer`. The return type is `AnimatorStateInstance | null` — `null` when the specified layer doesn't exist or no state is currently playing on it. +[getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) returns the playing `AnimatorStateInstance` on a given layer, or `null` when the layer is missing or nothing is playing. ```typescript const current = animator.getCurrentAnimatorState(0); if (current) { - // Per-instance speed (this Animator only) current.speed = 0.5; } ``` ### Get Animation State -[findAnimatorState](/apis/core/#Animator-findAnimatorState) returns the per-Animator state container for the named state (`AnimatorStateInstance | null`). Writes on the instance (e.g. `state.speed`) only affect this Animator — the shared `AnimatorState` asset on the controller is untouched. Mirrors the `Renderer.getInstanceMaterial` pattern. +[findAnimatorState](/apis/core/#Animator-findAnimatorState) returns the per-Animator `AnimatorStateInstance` (`AnimatorStateInstance | null`). Writes only affect this Animator; the shared `AnimatorState` on the controller is untouched. Same pattern as `Renderer.getInstanceMaterial`. -- `state.speed` — per-Animator playback speed. Until written, reads through to the shared default; once written, the instance owns its own speed. -- `state.name`, `state.clip`, `state.wrapMode`, `state.clipStartTime`, `state.clipEndTime` — read-only forwards from the shared `AnimatorState`. +- `speed` is per-instance overrideable; unwritten reads return the shared default. +- `name`, `clip`, `wrapMode`, `clipStartTime`, `clipEndTime` read through to the shared asset. -To mutate the shared asset (broadcast to every Animator using this controller — typical for editor / asset-construction code), reach it through the controller path. The longer path is a deliberate visual reminder that the change is global. +To mutate the shared asset (broadcasts to every Animator), go through the controller path: ```typescript const state = animator.findAnimatorState("xxx"); -if (!state) { - // State not found in any layer - return; -} +if (!state) return; -// Per-instance override (this Animator only) +// Per-instance override. state.speed = 0.5; -// Read-only access to shared asset fields -console.log(state.clip.length, state.wrapMode); - -// Mutating the shared asset (broadcast to every Animator using this controller) +// Broadcast to every Animator using this controller. animator.animatorController.layers[0].stateMachine.findStateByName("xxx").wrapMode = WrapMode.Once; ``` diff --git a/docs/zh/animation/animator.mdx b/docs/zh/animation/animator.mdx index ced40e8950..9220968a15 100644 --- a/docs/zh/animation/animator.mdx +++ b/docs/zh/animation/animator.mdx @@ -144,41 +144,32 @@ animator.crossFade("OtherStateName", 0.3); ### 获取当前在播放的动画状态 -你可以使用 [getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState)  方法来获取当前正在播放的 `动画状态`。参数为 `动画状态` 所在 `动画层` 的序号`layerIndex`, 详见[API 文档](/apis/core/#Animator-getCurrentAnimatorState)。返回类型为 `AnimatorStateInstance | null`,当指定 `动画层` 不存在或该层当前没有播放任何状态时返回 `null`。获取到非空状态后可以设置其属性,比如将默认的循环播放改为一次。 +[getCurrentAnimatorState](/apis/core/#Animator-getCurrentAnimatorState) 返回指定层当前播放的 `AnimatorStateInstance`,层不存在或未在播放时返回 `null`。 ```typescript -const currentState = animator.getCurrentAnimatorState(0); -if (currentState) { - // 播放一次 - currentState.wrapMode = WrapMode.Once; - // 循环播放 - currentState.wrapMode = WrapMode.Loop; +const current = animator.getCurrentAnimatorState(0); +if (current) { + current.speed = 0.5; } ``` ### 获取动画状态 -[findAnimatorState](/apis/core/#Animator-findAnimatorState) 返回当前 `Animator` 独有的 `AnimatorState` 视图(`AnimatorStateInstance | null`)。对该视图的写入(如 `state.speed`)只影响当前 Animator,控制器上的共享 `AnimatorState` 资产不受影响。模式与 `Renderer.getInstanceMaterial` 一致。 +[findAnimatorState](/apis/core/#Animator-findAnimatorState) 返回当前 `Animator` 独有的 `AnimatorStateInstance`(`AnimatorStateInstance | null`)。对它的写入只影响当前 Animator,共享 `AnimatorState` 资产不受影响。模式与 `Renderer.getInstanceMaterial` 一致。 -- `state.speed`:每个 `Animator` 独立的播放速度。未写入前透传到共享默认值,写入后由当前实例独占。 -- `state.name` / `state.clip` / `state.wrapMode` / `state.clipStartTime` / `state.clipEndTime`:从共享 `AnimatorState` 转发的只读字段。 +- `speed` 可逐实例覆盖;未写入前透传到共享默认值。 +- `name` / `clip` / `wrapMode` / `clipStartTime` / `clipEndTime` 从共享资产转发。 -要修改共享 asset(影响所有使用该控制器的 `Animator`——编辑器/资产搭建场景常见),通过控制器路径访问。这条更长的路径本身就是一个视觉提醒:你做的是全局变更。 +要修改共享资产(广播到所有使用该控制器的 Animator),通过控制器路径访问: ```typescript const state = animator.findAnimatorState("xxx"); -if (!state) { - // 任何一个动画层都没有该状态 - return; -} +if (!state) return; -// per-instance 覆盖(只影响当前 Animator) +// 只影响当前 Animator state.speed = 0.5; -// 只读访问共享 asset 字段 -console.log(state.clip.length, state.wrapMode); - -// 修改共享 asset(广播到所有使用该控制器的 Animator) +// 广播到所有使用该控制器的 Animator animator.animatorController.layers[0].stateMachine.findStateByName("xxx").wrapMode = WrapMode.Once; ``` diff --git a/e2e/case/animator-stateMachine.ts b/e2e/case/animator-stateMachine.ts index 35822c44de..eeb7f8ed66 100644 --- a/e2e/case/animator-stateMachine.ts +++ b/e2e/case/animator-stateMachine.ts @@ -54,7 +54,6 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const animator = defaultSceneRoot.getComponent(Animator)!; animator.animatorController.addParameter("playerSpeed", 1); const stateMachine = animator.animatorController.layers[0].stateMachine; - // State-machine assembly works on the shared AnimatorState assets via the controller path. const idleDef = stateMachine.findStateByName("idle"); const walkDef = stateMachine.findStateByName("walk"); const runDef = stateMachine.findStateByName("run"); diff --git a/e2e/case/animator-stateMachineScript.ts b/e2e/case/animator-stateMachineScript.ts index d768c3bcff..46d1858011 100644 --- a/e2e/case/animator-stateMachineScript.ts +++ b/e2e/case/animator-stateMachineScript.ts @@ -55,8 +55,6 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { rootEntity.addChild(defaultSceneRoot); const animator = defaultSceneRoot.getComponent(Animator); - // Attaching a StateMachineScript mutates the shared AnimatorState on the controller, - // so reach it through the controller path rather than via a per-Animator state view. const walkDef = animator.animatorController.layers[0].stateMachine.findStateByName("walk"); if (!walkDef) { throw new Error("Animator state not found: walk"); diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index ecd774949e..8bbbb07c5f 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -203,30 +203,21 @@ export class Animator extends Component { } /** - * Get the per-Animator state instance currently playing on the target layer. - * - * Writes on the returned `AnimatorStateInstance` (e.g. `instance.speed`) - * only affect this Animator; the shared `AnimatorState` asset is untouched. - * + * Get the playing state instance on the target layer. * @param layerIndex - The layer index - * @returns Per-instance state container, or null if the layer is missing or no state is playing + * @returns The instance, or null if the layer is missing or nothing is playing */ getCurrentAnimatorState(layerIndex: number): AnimatorStateInstance | null { return this._animatorLayersData[layerIndex]?.srcRuntime?.instance ?? null; } /** - * Get or lazily create the per-Animator state instance for a named state. - * - * Mirrors the `Renderer.getInstanceMaterial` pattern: the shared - * `AnimatorState` on the controller stays shared, while overrides on the - * returned instance (e.g. `instance.speed`) only affect this Animator. The - * returned instance persists for the layer's lifetime, so overrides survive - * transitions out of and back into the state. - * + * Get or lazy-create the per-Animator instance for a named state. + * Mirrors `Renderer.getInstanceMaterial`: writes on the instance only affect + * this Animator; the shared `AnimatorState` asset is untouched. * @param stateName - The state name * @param layerIndex - The layer index (default -1, searches all layers) - * @returns Per-instance state container, or null if no state matches + * @returns The instance, or null if no state matches */ findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStateInstance | null { this._resetIfControllerUpdated(); @@ -1464,10 +1455,8 @@ export class Animator extends Component { const animatorLayerData = this._getAnimatorLayerData(layerIndex); - // Self/active-dest cross-fade is intentionally a no-op because each def - // owns one persistent state view per layer (so per-instance overrides - // like speed survive transitions). Supporting self cross-fade would require - // a separate transient playback track per active fade. + // 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.srcRuntime?.instance._state === crossState || animatorLayerData.destRuntime?.instance._state === crossState diff --git a/packages/core/src/animation/AnimatorStateInstance.ts b/packages/core/src/animation/AnimatorStateInstance.ts index dd62757277..d0c91c3ba6 100644 --- a/packages/core/src/animation/AnimatorStateInstance.ts +++ b/packages/core/src/animation/AnimatorStateInstance.ts @@ -4,23 +4,11 @@ import { AnimatorStateRuntime } from "./internal/AnimatorStateRuntime"; import { WrapMode } from "./enums/WrapMode"; /** - * Per-Animator runtime view of a shared `AnimatorState` asset. + * Per-Animator view of a shared `AnimatorState` asset. * - * `findAnimatorState` returns this view: each Animator gets its own instance - * bound to a shared `AnimatorState` asset on the controller. Writes on - * `speed` only affect this Animator; reads of asset fields (`name`, `clip`, - * `wrapMode`, ...) forward to the shared state. - * - * Lifecycle: lazy-created by `Animator.findAnimatorState` on first access and - * persists for the layer's lifetime so per-instance overrides survive - * transitions out of and back into the state. - * - * The underlying `AnimatorState` asset is intentionally not part of the - * public surface: it stays reachable through - * `animator.animatorController.layers[i].stateMachine.findStateByName(name)` - * for the rare editor / asset-construction case where you really need to - * mutate the shared asset — the longer path is a visual reminder that the - * change broadcasts to every Animator using the same controller. + * Writes on `speed` only affect this Animator. Other fields read through to + * the shared asset. Lazy-created by `Animator.findAnimatorState` and persists + * for the layer's lifetime, so overrides survive state transitions. */ export class AnimatorStateInstance { /** @internal */ @@ -30,39 +18,29 @@ export class AnimatorStateInstance { private _speed: number | undefined; - /** The state's name (from the shared asset). */ get name(): string { return this._state.name; } - /** The animation clip (from the shared asset). */ get clip(): AnimationClip { return this._state.clip; } - /** The wrap mode (from the shared asset). */ get wrapMode(): WrapMode { return this._state.wrapMode; } - /** Normalized clip start time (from the shared asset). */ get clipStartTime(): number { return this._state.clipStartTime; } - /** Normalized clip end time (from the shared asset). */ get clipEndTime(): number { return this._state.clipEndTime; } /** - * Per-instance playback speed for this state. - * - * Read: returns the per-instance override if set, otherwise reads through - * to the shared default. - * Write: claims per-instance ownership; later changes to the shared default - * no longer flow through. The per-instance value persists across state - * transitions on the owning Animator. + * Per-instance playback speed. Unwritten reads return the shared default; + * once written, this Animator owns its own speed. */ get speed(): number { return this._speed ?? this._state.speed; diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index b0759c9b76..548ca59c11 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -15,11 +15,8 @@ export class AnimatorLayerData { layer: AnimatorControllerLayer; curveOwnerPool: Record> = Object.create(null); animatorStateDataMap: Record = Object.create(null); - /** Per-state user-facing instance containers. Lazy populated. */ instanceMap: Record = Object.create(null); - /** Currently playing state's runtime; null when standby. */ srcRuntime: AnimatorStateRuntime | null = null; - /** Cross-fade target state's runtime; null when not cross-fading. */ destRuntime: AnimatorStateRuntime | null = null; layerState: LayerState = LayerState.Standby; crossCurveMark: number = 0; @@ -27,11 +24,7 @@ export class AnimatorLayerData { crossFadeTransition: AnimatorStateTransition; crossLayerOwnerCollection: AnimationCurveLayerOwner[] = []; - /** - * Get or lazily create the persistent (instance, runtime) pair for a shared - * `AnimatorState` asset. Rebuilds when the cached instance is bound to a - * different asset object (same-name remove + re-add). - */ + /** Lazy-create the (instance, runtime) pair; rebuild if the asset was swapped. */ getOrCreateRuntime(state: AnimatorState): AnimatorStateRuntime { const map = this.instanceMap; const name = state.name; @@ -44,7 +37,6 @@ export class AnimatorLayerData { return instance._runtime; } - /** After cross-fade completes, promote destRuntime to srcRuntime. */ promoteDest(): void { this.srcRuntime = this.destRuntime; this.destRuntime = null; diff --git a/packages/core/src/animation/internal/AnimatorStateRuntime.ts b/packages/core/src/animation/internal/AnimatorStateRuntime.ts index 279b281ead..bc7ec8d47e 100644 --- a/packages/core/src/animation/internal/AnimatorStateRuntime.ts +++ b/packages/core/src/animation/internal/AnimatorStateRuntime.ts @@ -7,18 +7,11 @@ import { AnimatorStateData } from "./AnimatorStateData"; /** * @internal * - * Engine-owned runtime playback state for a single (Animator, AnimatorState) pair. - * - * Lives alongside an `AnimatorStateInstance` (user-facing per-instance view) - * and tracks evaluation-time fields: how much has played, current clip time, - * play state, event index, orientation, etc. Mutated by the Animator's update - * loop; not exposed to user code. + * Per-(Animator, AnimatorState) playback runtime. Paired 1:1 with an + * `AnimatorStateInstance` and mutated by the Animator update loop. */ export class AnimatorStateRuntime { - /** The user-facing per-instance view this runtime is bound to. */ readonly instance: AnimatorStateInstance; - - /** Curve owners + event handlers (shared per-Animator per-state). */ stateData: AnimatorStateData; playedTime: number = 0; @@ -35,10 +28,7 @@ export class AnimatorStateRuntime { instance._runtime = this; } - /** - * Reset runtime fields when (re-)entering this state. - * Does NOT touch user-written per-instance overrides on `instance`. - */ + /** Reset playback fields on (re-)enter. Per-instance overrides are preserved. */ resetForPlay(stateData: AnimatorStateData, offsetFrameTime: number): void { const state = this.instance._state; this.stateData = stateData; @@ -89,8 +79,7 @@ export class AnimatorStateRuntime { private _correctTime(): void { const state = this.instance._state; - // Reverse playback resumed at clipTime=0 would step into negatives; jump to - // clipEnd so the next sample continues seamlessly from the end of the clip. + // Reverse playback at clipTime=0 would step into negatives; jump to clipEnd. if (this.clipTime === 0) { this.clipTime = state.clipEndTime * state.clip.length; } From 12d3ee449f9f2ff87232871eb6c07858f3cc422d Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 18:11:10 +0800 Subject: [PATCH 64/92] refactor(animation): rename Def-suffixed locals in e2e cases to State --- e2e/case/animator-stateMachine.ts | 50 ++++++++++++------------- e2e/case/animator-stateMachineScript.ts | 6 +-- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/e2e/case/animator-stateMachine.ts b/e2e/case/animator-stateMachine.ts index eeb7f8ed66..4f449d8487 100644 --- a/e2e/case/animator-stateMachine.ts +++ b/e2e/case/animator-stateMachine.ts @@ -54,10 +54,10 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { const animator = defaultSceneRoot.getComponent(Animator)!; animator.animatorController.addParameter("playerSpeed", 1); const stateMachine = animator.animatorController.layers[0].stateMachine; - const idleDef = stateMachine.findStateByName("idle"); - const walkDef = stateMachine.findStateByName("walk"); - const runDef = stateMachine.findStateByName("run"); - if (!idleDef || !walkDef || !runDef) { + const idleState = stateMachine.findStateByName("idle"); + const walkState = stateMachine.findStateByName("walk"); + const runState = stateMachine.findStateByName("run"); + if (!idleState || !walkState || !runState) { throw new Error("Required animator states not found: idle/walk/run"); } let idleToWalkTime = 0; @@ -67,62 +67,62 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { // handle idle state const toWalkTransition = new AnimatorStateTransition(); - toWalkTransition.destinationState = walkDef; + toWalkTransition.destinationState = walkState; toWalkTransition.duration = 0.2; toWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0); - idleDef.addTransition(toWalkTransition); + idleState.addTransition(toWalkTransition); idleToWalkTime = //@ts-ignore - toWalkTransition.exitTime * idleDef._getDuration() + + toWalkTransition.exitTime * idleState._getDuration() + //@ts-ignore - toWalkTransition.duration * walkDef._getDuration(); + toWalkTransition.duration * walkState._getDuration(); - const exitTransition = idleDef.addExitTransition(); + const exitTransition = idleState.addExitTransition(); exitTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); // to walk state const toRunTransition = new AnimatorStateTransition(); - toRunTransition.destinationState = runDef; + toRunTransition.destinationState = runState; toRunTransition.duration = 0.3; toRunTransition.addCondition("playerSpeed", AnimatorConditionMode.Greater, 0.5); - walkDef.addTransition(toRunTransition); + walkState.addTransition(toRunTransition); walkToRunTime = //@ts-ignore - (toRunTransition.exitTime - toWalkTransition.duration) * walkDef._getDuration() + + (toRunTransition.exitTime - toWalkTransition.duration) * walkState._getDuration() + //@ts-ignore - toRunTransition.duration * runDef._getDuration(); + toRunTransition.duration * runState._getDuration(); const toIdleTransition = new AnimatorStateTransition(); - toIdleTransition.destinationState = idleDef; + toIdleTransition.destinationState = idleState; toIdleTransition.duration = 0.3; toIdleTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); - walkDef.addTransition(toIdleTransition); + walkState.addTransition(toIdleTransition); walkToIdleTime = //@ts-ignore - (toIdleTransition.exitTime - toRunTransition.duration) * walkDef._getDuration() + + (toIdleTransition.exitTime - toRunTransition.duration) * walkState._getDuration() + //@ts-ignore - toIdleTransition.duration * idleDef._getDuration(); + toIdleTransition.duration * idleState._getDuration(); // to run state const runToWalkTransition = new AnimatorStateTransition(); - runToWalkTransition.destinationState = walkDef; + runToWalkTransition.destinationState = walkState; runToWalkTransition.duration = 0.3; runToWalkTransition.addCondition("playerSpeed", AnimatorConditionMode.Less, 0.5); - runDef.addTransition(runToWalkTransition); + runState.addTransition(runToWalkTransition); runToWalkTime = //@ts-ignore - (runToWalkTransition.exitTime - toRunTransition.duration) * runDef._getDuration() + + (runToWalkTransition.exitTime - toRunTransition.duration) * runState._getDuration() + //@ts-ignore - runToWalkTransition.duration * walkDef._getDuration(); + runToWalkTransition.duration * walkState._getDuration(); - stateMachine.addEntryStateTransition(idleDef); + stateMachine.addEntryStateTransition(idleState); - const anyTransition = stateMachine.addAnyStateTransition(idleDef); + const anyTransition = stateMachine.addAnyStateTransition(idleState); anyTransition.addCondition("playerSpeed", AnimatorConditionMode.Equals, 0); anyTransition.duration = 0.3; let anyToIdleTime = // @ts-ignore - (anyTransition.exitTime - toIdleTransition.duration) * walkDef._getDuration() + + (anyTransition.exitTime - toIdleTransition.duration) * walkState._getDuration() + // @ts-ignore - anyTransition.duration * idleDef._getDuration(); + anyTransition.duration * idleState._getDuration(); engine.time.maximumDeltaTime = 10000; updateForE2E(engine, (idleToWalkTime + walkToRunTime) * 1000, 1); diff --git a/e2e/case/animator-stateMachineScript.ts b/e2e/case/animator-stateMachineScript.ts index 46d1858011..5f6d8d9f26 100644 --- a/e2e/case/animator-stateMachineScript.ts +++ b/e2e/case/animator-stateMachineScript.ts @@ -55,12 +55,12 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { rootEntity.addChild(defaultSceneRoot); const animator = defaultSceneRoot.getComponent(Animator); - const walkDef = animator.animatorController.layers[0].stateMachine.findStateByName("walk"); - if (!walkDef) { + const walkState = animator.animatorController.layers[0].stateMachine.findStateByName("walk"); + if (!walkState) { throw new Error("Animator state not found: walk"); } - walkDef.addStateMachineScript( + walkState.addStateMachineScript( class extends StateMachineScript { onStateEnter(animator: Animator, animatorState: AnimatorState, layerIndex: number): void { textRenderer.text = "0"; From 6c26f4b7e3deec591bf5578f03e87f8cddf6c82a Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 18:26:53 +0800 Subject: [PATCH 65/92] refactor(animation): drop implementation details from public JSDoc --- packages/core/src/animation/Animator.ts | 11 +++++------ packages/core/src/animation/AnimatorStateInstance.ts | 12 +++--------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 8bbbb07c5f..3b52378f36 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -203,21 +203,20 @@ export class Animator extends Component { } /** - * Get the playing state instance on the target layer. + * Get the state instance currently playing on the target layer. * @param layerIndex - The layer index - * @returns The instance, or null if the layer is missing or nothing is playing + * @returns The state instance, or null if nothing is playing */ getCurrentAnimatorState(layerIndex: number): AnimatorStateInstance | null { return this._animatorLayersData[layerIndex]?.srcRuntime?.instance ?? null; } /** - * Get or lazy-create the per-Animator instance for a named state. - * Mirrors `Renderer.getInstanceMaterial`: writes on the instance only affect - * this Animator; the shared `AnimatorState` asset is untouched. + * Get the state instance for a named state on this Animator. + * Overrides on the returned instance only affect this Animator. * @param stateName - The state name * @param layerIndex - The layer index (default -1, searches all layers) - * @returns The instance, or null if no state matches + * @returns The state instance, or null if no state matches */ findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStateInstance | null { this._resetIfControllerUpdated(); diff --git a/packages/core/src/animation/AnimatorStateInstance.ts b/packages/core/src/animation/AnimatorStateInstance.ts index d0c91c3ba6..e4d035ca71 100644 --- a/packages/core/src/animation/AnimatorStateInstance.ts +++ b/packages/core/src/animation/AnimatorStateInstance.ts @@ -4,11 +4,8 @@ import { AnimatorStateRuntime } from "./internal/AnimatorStateRuntime"; import { WrapMode } from "./enums/WrapMode"; /** - * Per-Animator view of a shared `AnimatorState` asset. - * - * Writes on `speed` only affect this Animator. Other fields read through to - * the shared asset. Lazy-created by `Animator.findAnimatorState` and persists - * for the layer's lifetime, so overrides survive state transitions. + * Per-Animator view of an `AnimatorState`. Overrides on this view only affect + * the owning Animator; other Animators using the same controller are unaffected. */ export class AnimatorStateInstance { /** @internal */ @@ -38,10 +35,7 @@ export class AnimatorStateInstance { return this._state.clipEndTime; } - /** - * Per-instance playback speed. Unwritten reads return the shared default; - * once written, this Animator owns its own speed. - */ + /** Playback speed for this Animator. */ get speed(): number { return this._speed ?? this._state.speed; } From 92529bb177d887135b500287b5ea7649068fa5bd Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 18:31:22 +0800 Subject: [PATCH 66/92] feat(animation): make wrapMode per-instance overrideable on AnimatorStateInstance Aligns wrapMode with speed: both are playback behavior parameters that can differ per Animator, while structural fields (clip, clipStartTime/EndTime, transitions, scripts) remain asset-only. Also fixes test references to runtime.state that should be runtime.instance after the earlier rename, and simplifies wrapMode writes in tests that no longer need the asset-path workaround. --- docs/en/animation/animator.mdx | 9 +-- docs/zh/animation/animator.mdx | 7 ++- .../src/animation/AnimatorStateInstance.ts | 14 +++-- .../internal/AnimatorStateRuntime.ts | 5 +- tests/src/core/Animator.test.ts | 56 +++++++++---------- 5 files changed, 50 insertions(+), 41 deletions(-) diff --git a/docs/en/animation/animator.mdx b/docs/en/animation/animator.mdx index 111d55d4a9..a253d0cd98 100644 --- a/docs/en/animation/animator.mdx +++ b/docs/en/animation/animator.mdx @@ -152,8 +152,8 @@ if (current) { [findAnimatorState](/apis/core/#Animator-findAnimatorState) returns the per-Animator `AnimatorStateInstance` (`AnimatorStateInstance | null`). Writes only affect this Animator; the shared `AnimatorState` on the controller is untouched. Same pattern as `Renderer.getInstanceMaterial`. -- `speed` is per-instance overrideable; unwritten reads return the shared default. -- `name`, `clip`, `wrapMode`, `clipStartTime`, `clipEndTime` read through to the shared asset. +- `speed`, `wrapMode` are per-instance overrideable; unwritten reads return the shared default. +- `name`, `clip`, `clipStartTime`, `clipEndTime` read through to the shared asset. To mutate the shared asset (broadcasts to every Animator), go through the controller path: @@ -161,11 +161,12 @@ To mutate the shared asset (broadcasts to every Animator), go through the contro const state = animator.findAnimatorState("xxx"); if (!state) return; -// Per-instance override. +// Per-instance overrides. state.speed = 0.5; +state.wrapMode = WrapMode.Once; // Broadcast to every Animator using this controller. -animator.animatorController.layers[0].stateMachine.findStateByName("xxx").wrapMode = WrapMode.Once; +animator.animatorController.layers[0].stateMachine.findStateByName("xxx").clip = otherClip; ``` ### Animation Culling diff --git a/docs/zh/animation/animator.mdx b/docs/zh/animation/animator.mdx index 9220968a15..5246416625 100644 --- a/docs/zh/animation/animator.mdx +++ b/docs/zh/animation/animator.mdx @@ -157,8 +157,8 @@ if (current) { [findAnimatorState](/apis/core/#Animator-findAnimatorState) 返回当前 `Animator` 独有的 `AnimatorStateInstance`(`AnimatorStateInstance | null`)。对它的写入只影响当前 Animator,共享 `AnimatorState` 资产不受影响。模式与 `Renderer.getInstanceMaterial` 一致。 -- `speed` 可逐实例覆盖;未写入前透传到共享默认值。 -- `name` / `clip` / `wrapMode` / `clipStartTime` / `clipEndTime` 从共享资产转发。 +- `speed`、`wrapMode` 可逐实例覆盖;未写入前透传到共享默认值。 +- `name` / `clip` / `clipStartTime` / `clipEndTime` 从共享资产转发。 要修改共享资产(广播到所有使用该控制器的 Animator),通过控制器路径访问: @@ -168,9 +168,10 @@ if (!state) return; // 只影响当前 Animator state.speed = 0.5; +state.wrapMode = WrapMode.Once; // 广播到所有使用该控制器的 Animator -animator.animatorController.layers[0].stateMachine.findStateByName("xxx").wrapMode = WrapMode.Once; +animator.animatorController.layers[0].stateMachine.findStateByName("xxx").clip = otherClip; ``` ### 动画裁剪 diff --git a/packages/core/src/animation/AnimatorStateInstance.ts b/packages/core/src/animation/AnimatorStateInstance.ts index e4d035ca71..5dc185e4ed 100644 --- a/packages/core/src/animation/AnimatorStateInstance.ts +++ b/packages/core/src/animation/AnimatorStateInstance.ts @@ -14,6 +14,7 @@ export class AnimatorStateInstance { _runtime: AnimatorStateRuntime; private _speed: number | undefined; + private _wrapMode: WrapMode | undefined; get name(): string { return this._state.name; @@ -23,10 +24,6 @@ export class AnimatorStateInstance { return this._state.clip; } - get wrapMode(): WrapMode { - return this._state.wrapMode; - } - get clipStartTime(): number { return this._state.clipStartTime; } @@ -44,6 +41,15 @@ export class AnimatorStateInstance { this._speed = value; } + /** Wrap mode for this Animator. */ + get wrapMode(): WrapMode { + return this._wrapMode ?? this._state.wrapMode; + } + + set wrapMode(value: WrapMode) { + this._wrapMode = value; + } + /** @internal */ constructor(state: AnimatorState) { this._state = state; diff --git a/packages/core/src/animation/internal/AnimatorStateRuntime.ts b/packages/core/src/animation/internal/AnimatorStateRuntime.ts index bc7ec8d47e..f489054996 100644 --- a/packages/core/src/animation/internal/AnimatorStateRuntime.ts +++ b/packages/core/src/animation/internal/AnimatorStateRuntime.ts @@ -55,11 +55,12 @@ export class AnimatorStateRuntime { update(deltaTime: number): void { this.playedTime += deltaTime; - const state = this.instance._state; + const instance = this.instance; + const state = instance._state; let time = this.playedTime + this.offsetFrameTime; const duration = state._getDuration(); this.playState = AnimatorStatePlayState.Playing; - if (state.wrapMode === WrapMode.Loop) { + if (instance.wrapMode === WrapMode.Loop) { time = duration ? time % duration : 0; } else { if (Math.abs(time) >= duration) { diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 7a622869b6..fceb0d66f4 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -251,7 +251,7 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); const srcRuntime = layerData.srcRuntime; - expect(srcRuntime.state.name).to.eq("Run"); + expect(srcRuntime.instance.name).to.eq("Run"); expect(srcRuntime.playedTime).to.eq(0.3); // @ts-ignore expect(srcRuntime.clipTime).to.eq(0.3 + 0.1 * (runState as any)._state._getDuration()); @@ -628,7 +628,7 @@ describe("Animator test", function () { animator.update(0.01); const destRuntime = animator["_animatorLayersData"][0].destRuntime; - const destState = destRuntime.state; + const destState = (destRuntime.instance as any)._state; const transitionDuration = toRunTransition.duration * destState._getDuration(); const crossWeight = animator["_animatorLayersData"][0].destRuntime.playedTime / transitionDuration; expect(crossWeight).to.lessThan(0.01); @@ -636,7 +636,7 @@ describe("Animator test", function () { it("clipStartTime crossFade", () => { const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.wrapMode = WrapMode.Once; + walkState.wrapMode = WrapMode.Once; (walkState as any)._state.clipStartTime = 0.8; (walkState as any)._state.clearTransitions(); const runState = animator.findAnimatorState("Run"); @@ -651,14 +651,14 @@ describe("Animator test", function () { animator.update(0.1); const destRuntime = animator["_animatorLayersData"][0].destRuntime; - expect(destRuntime.state?.name).to.eq("Run"); + expect(destRuntime.instance?.name).to.eq("Run"); }); it("transition to exit but no entry", () => { const animatorLayerData = animator["_animatorLayersData"]; const walkState = animator.findAnimatorState("Walk"); - (walkState as any)._state.wrapMode = WrapMode.Once; + walkState.wrapMode = WrapMode.Once; (walkState as any)._state.clearTransitions(); (walkState as any)._state.addExitTransition(); animator.play("Walk"); @@ -846,7 +846,7 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.5); - expect(layerData.srcRuntime.state.name).to.eq("Run"); + expect(layerData.srcRuntime.instance.name).to.eq("Run"); expect(layerData.srcRuntime.playedTime).to.eq(0.5); expect(layerData.srcRuntime.clipTime).to.eq((walkState as any)._state.clip.length * 0.5 + 0.5); }); @@ -876,7 +876,7 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update((walkState as any)._state.clip.length * 0.5); - expect(layerData.destRuntime.state.name).to.eq("Run"); + expect(layerData.destRuntime.instance.name).to.eq("Run"); expect(layerData.destRuntime.playedTime).to.eq(0); const anyToIdleTransition = stateMachine.addAnyStateTransition((idleState as any)._state); anyToIdleTransition.hasExitTime = false; @@ -886,12 +886,12 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcRuntime.state.name).to.eq("Run"); + expect(layerData.srcRuntime.instance.name).to.eq("Run"); expect(layerData.srcRuntime.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.srcRuntime.state.name).to.eq("Survey"); + expect(layerData.srcRuntime.instance.name).to.eq("Survey"); expect(layerData.srcRuntime.clipTime).to.eq((idleState as any)._state.clip.length * 0.2); }); @@ -926,27 +926,27 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcRuntime.state.name).to.eq("Walk"); + expect(layerData.srcRuntime.instance.name).to.eq("Walk"); expect(layerData.srcRuntime.playedTime).to.eq(0.1); - expect(layerData.destRuntime.state.name).to.eq("Run"); + expect(layerData.destRuntime.instance.name).to.eq("Run"); expect(layerData.destRuntime.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.srcRuntime.state.name).to.eq("Run"); + expect(layerData.srcRuntime.instance.name).to.eq("Run"); expect(layerData.srcRuntime.playedTime).to.eq((runState as any)._state.clip.length * 0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update((runState as any)._state.clip.length * 0.6); - expect(layerData.destRuntime.state.name).to.eq("Walk"); + expect(layerData.destRuntime.instance.name).to.eq("Walk"); expect(layerData.destRuntime.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.srcRuntime.state.name).to.eq("Walk"); + expect(layerData.srcRuntime.instance.name).to.eq("Walk"); expect(layerData.srcRuntime.playedTime).to.eq((walkState as any)._state.clip.length * 0.3); }); @@ -971,7 +971,7 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcRuntime.state.name).to.eq("Run"); + expect(layerData.srcRuntime.instance.name).to.eq("Run"); expect(layerData.srcRuntime.playedTime).to.eq(0.1); expect(layerData.srcRuntime.clipTime).to.eq(0); }); @@ -1034,13 +1034,13 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.6); - expect(animatorLayerData[0]?.srcRuntime.state.name).to.eq("state1"); + expect(animatorLayerData[0]?.srcRuntime.instance.name).to.eq("state1"); transition2.mute = false; // @ts-ignore animator.engine.time._frameCount++; animator.update(0.3); - expect(animatorLayerData[0]?.srcRuntime.state.name).to.eq("state2"); + expect(animatorLayerData[0]?.srcRuntime.instance.name).to.eq("state2"); }); it("Clone", () => { @@ -1113,7 +1113,7 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destRuntime.state.name).to.eq("Run"); + expect(layerData.destRuntime.instance.name).to.eq("Run"); // Trigger interrupt during crossFade animator.setParameterValue("interrupt", true); @@ -1122,7 +1122,7 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destRuntime.state.name).to.eq("Survey"); + expect(layerData.destRuntime.instance.name).to.eq("Survey"); }); it("noExitTime transition scan should ignore exitTime transitions", () => { @@ -1161,7 +1161,7 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(preExitDeltaTime); - expect(layerData.srcRuntime.state.name).to.eq("Walk"); + expect(layerData.srcRuntime.instance.name).to.eq("Walk"); expect(layerData.destRuntime).to.be.null; // Update past exitTime, should transition to Run. @@ -1185,7 +1185,7 @@ describe("Animator test", function () { anyToIdle.addCondition("interrupt", AnimatorConditionMode.If, true); // Play Walk with Once mode, let it finish to reach Finished state - (walkState as any)._state.wrapMode = WrapMode.Once; + walkState.wrapMode = WrapMode.Once; animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -1203,7 +1203,7 @@ describe("Animator test", function () { animator.update(0.1); expect(layerData.layerState).to.eq(LayerState.FixedCrossFading); - expect(layerData.destRuntime.state.name).to.eq("Run"); + expect(layerData.destRuntime.instance.name).to.eq("Run"); // Trigger interrupt during FixedCrossFading animator.setParameterValue("interrupt", true); @@ -1212,7 +1212,7 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destRuntime.state.name).to.eq("Survey"); + expect(layerData.destRuntime.instance.name).to.eq("Survey"); }); it("anyState interrupt should skip transition to same destination state", () => { @@ -1239,7 +1239,7 @@ describe("Animator test", function () { // Should be in CrossFading state, dest = Run expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destRuntime.state.name).to.eq("Run"); + expect(layerData.destRuntime.instance.name).to.eq("Run"); // Update again - anyState -> Run should be skipped because dest is already Run // @ts-ignore @@ -1248,7 +1248,7 @@ describe("Animator test", function () { // Should still be CrossFading to Run (not interrupted/reset) expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destRuntime.state.name).to.eq("Run"); + expect(layerData.destRuntime.instance.name).to.eq("Run"); }); it("zero-duration crossFade should not be interrupted by anyState transition", () => { @@ -1274,7 +1274,7 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); // Zero-duration crossFade completes instantly, should be Playing Run (not interrupted to Survey) - expect(layerData.srcRuntime.state.name).to.eq("Run"); + expect(layerData.srcRuntime.instance.name).to.eq("Run"); }); it("toggle hasExitTime should maintain correct noExitTimeCount", () => { @@ -1360,7 +1360,7 @@ describe("Animator test", function () { // @ts-ignore const srcRuntime = animator._animatorLayersData[0].srcRuntime; - expect(srcRuntime.state.name).to.eq("Survey"); // ensure crossfade actually completed back to Survey + expect(srcRuntime.instance.name).to.eq("Survey"); // ensure crossfade actually completed back to Survey expect(animator.findAnimatorState("Survey").speed).to.eq(0.5); expect(srcRuntime.speed).to.eq(0.5); }); @@ -1672,7 +1672,7 @@ describe("Animator test", function () { // 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.srcRuntime).to.eq(srcBefore); - expect(layerData.srcRuntime.state.name).to.eq("Walk"); + expect(layerData.srcRuntime.instance.name).to.eq("Walk"); expect(layerData.srcRuntime.playedTime).to.be.greaterThan(playedBefore); expect(layerData.destRuntime).to.eq(null); }); From 0e30020c6b9ac76832038a98920bde1ede8710f0 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 18:37:12 +0800 Subject: [PATCH 67/92] refactor(animation): restore AnimatorStatePlayData naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier rename to AnimatorStateRuntime was unmotivated — "PlayData" already accurately describes the internal class (playedTime, clipTime, playState, ...). Reverts class name, field names (srcPlayData/destPlayData, instance._playData), method getOrCreatePlayData, and local variable names back to the original PR #2999 / dev/2.0 naming. --- packages/core/src/animation/Animator.ts | 222 +++++++++--------- .../src/animation/AnimatorStateInstance.ts | 4 +- .../animation/internal/AnimatorLayerData.ts | 16 +- ...ateRuntime.ts => AnimatorStatePlayData.ts} | 8 +- tests/src/core/Animator.test.ts | 162 ++++++------- 5 files changed, 204 insertions(+), 208 deletions(-) rename packages/core/src/animation/internal/{AnimatorStateRuntime.ts => AnimatorStatePlayData.ts} (91%) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 3b52378f36..cde69c167c 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -24,7 +24,7 @@ import { AnimationEventHandler } from "./internal/AnimationEventHandler"; import { AnimatorLayerData } from "./internal/AnimatorLayerData"; import { AnimatorStateData } from "./internal/AnimatorStateData"; import { AnimatorStateInstance } from "./AnimatorStateInstance"; -import { AnimatorStateRuntime } from "./internal/AnimatorStateRuntime"; +import { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; import { AnimationCurveOwner } from "./internal/animationCurveOwner/AnimationCurveOwner"; /** @@ -208,7 +208,7 @@ export class Animator extends Component { * @returns The state instance, or null if nothing is playing */ getCurrentAnimatorState(layerIndex: number): AnimatorStateInstance | null { - return this._animatorLayersData[layerIndex]?.srcRuntime?.instance ?? null; + return this._animatorLayersData[layerIndex]?.srcPlayData?.instance ?? null; } /** @@ -222,7 +222,7 @@ export class Animator extends Component { this._resetIfControllerUpdated(); const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); if (!state || foundLayer < 0) return null; - return this._getAnimatorLayerData(foundLayer).getOrCreateRuntime(state).instance; + return this._getAnimatorLayerData(foundLayer).getOrCreatePlayData(state).instance; } /** @@ -556,8 +556,8 @@ export class Animator extends Component { } private _prepareStandbyCrossFading(animatorLayerData: AnimatorLayerData): void { - // Standby have two sub state, one is never play (srcRuntime is null), one is finished (srcRuntime is non-null) - animatorLayerData.srcRuntime && this._prepareSrcCrossData(animatorLayerData, true); + // Standby have two sub state, one is never play (srcPlayData is null), one is finished (srcPlayData is non-null) + animatorLayerData.srcPlayData && this._prepareSrcCrossData(animatorLayerData, true); // Add dest cross curve data this._prepareDestCrossData(animatorLayerData, true); } @@ -578,7 +578,7 @@ export class Animator extends Component { } private _prepareSrcCrossData(animatorLayerData: AnimatorLayerData, saveFixed: boolean): void { - const { curveLayerOwner } = animatorLayerData.srcRuntime.stateData; + const { curveLayerOwner } = animatorLayerData.srcPlayData.stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; if (!layerOwner) continue; @@ -589,7 +589,7 @@ export class Animator extends Component { } private _prepareDestCrossData(animatorLayerData: AnimatorLayerData, saveFixed: boolean): void { - const { curveLayerOwner } = animatorLayerData.destRuntime.stateData; + const { curveLayerOwner } = animatorLayerData.destPlayData.stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; if (!layerOwner) continue; @@ -648,20 +648,20 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ): void { - const { srcRuntime } = layerData; - const state = srcRuntime.instance._state; + const { srcPlayData } = layerData; + const state = srcPlayData.instance._state; - const playSpeed = srcRuntime.instance.speed * this.speed; + const playSpeed = srcPlayData.instance.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; - srcRuntime.updateOrientation(playDeltaTime); + srcPlayData.updateOrientation(playDeltaTime); - const { clipTime: lastClipTime, playState: lastPlayState } = srcRuntime; + const { clipTime: lastClipTime, playState: lastPlayState } = srcPlayData; // Precalculate to get the transition - srcRuntime.update(playDeltaTime); + srcPlayData.update(playDeltaTime); - const { clipTime: clipTime, isForward: isForward } = srcRuntime; + const { clipTime: clipTime, isForward: isForward } = srcPlayData; const { _transitionCollection: transitions } = state; const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine; @@ -670,7 +670,7 @@ export class Animator extends Component { this._applyStateTransitions( layerData, isForward, - srcRuntime, + srcPlayData, anyStateTransitions, lastClipTime, clipTime, @@ -681,7 +681,7 @@ export class Animator extends Component { this._applyStateTransitions( layerData, isForward, - srcRuntime, + srcPlayData, transitions, lastClipTime, clipTime, @@ -715,18 +715,18 @@ export class Animator extends Component { playCostTime = 0; } // Revert actualDeltaTime and update playCostTime - srcRuntime.update(playCostTime - playDeltaTime); + srcPlayData.update(playCostTime - playDeltaTime); } else { playCostTime = playDeltaTime; - if (srcRuntime.playState === AnimatorStatePlayState.Finished) { + if (srcPlayData.playState === AnimatorStatePlayState.Finished) { layerData.layerState = LayerState.Finished; } } - this._evaluatePlayingState(srcRuntime, weight, additive, aniUpdate); + this._evaluatePlayingState(srcPlayData, weight, additive, aniUpdate); this._fireAnimationEventsAndCallScripts( layerData.layerIndex, - srcRuntime, + srcPlayData, state, lastClipTime, lastPlayState, @@ -742,16 +742,16 @@ export class Animator extends Component { } private _evaluatePlayingState( - runtime: AnimatorStateRuntime, + playData: AnimatorStatePlayData, weight: number, additive: boolean, aniUpdate: boolean ): void { - const curveBindings = runtime.instance.clip._curveBindings; - const finished = runtime.playState === AnimatorStatePlayState.Finished; + const curveBindings = playData.instance.clip._curveBindings; + const finished = playData.playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { - const curveLayerOwner = runtime.stateData.curveLayerOwner; + const curveLayerOwner = playData.stateData.curveLayerOwner; for (let i = curveBindings.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; const owner = layerOwner?.curveOwner; @@ -764,7 +764,7 @@ export class Animator extends Component { if (curve.keys.length) { this._checkRevertOwner(owner, additive); - const value = owner.evaluateValue(curve, runtime.clipTime, additive); + const value = owner.evaluateValue(curve, playData.clipTime, additive); aniUpdate && owner.applyValue(value, weight, additive); finished && layerOwner.saveFinalValue(); } @@ -779,35 +779,35 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ) { - const { srcRuntime, destRuntime, layerIndex } = layerData; + const { srcPlayData, destPlayData, layerIndex } = layerData; const { speed } = this; - const srcState = srcRuntime.instance._state; - const destState = destRuntime.instance._state; + const srcState = srcPlayData.instance._state; + const destState = destPlayData.instance._state; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, destState, deltaTime, aniUpdate)) { return; } - const srcPlaySpeed = srcRuntime.instance.speed * speed; - const dstPlaySpeed = destRuntime.instance.speed * speed; + const srcPlaySpeed = srcPlayData.instance.speed * speed; + const dstPlaySpeed = destPlayData.instance.speed * speed; const dstPlayDeltaTime = dstPlaySpeed * deltaTime; - srcRuntime.updateOrientation(srcPlaySpeed * deltaTime); - destRuntime.updateOrientation(dstPlayDeltaTime); + srcPlayData.updateOrientation(srcPlaySpeed * deltaTime); + destPlayData.updateOrientation(dstPlayDeltaTime); - const { clipTime: lastSrcClipTime, playState: lastSrcPlayState } = srcRuntime; - const { clipTime: lastDestClipTime, playState: lastDstPlayState } = destRuntime; + const { clipTime: lastSrcClipTime, playState: lastSrcPlayState } = srcPlayData; + const { clipTime: lastDestClipTime, playState: lastDstPlayState } = destPlayData; let dstPlayCostTime: number; - if (destRuntime.isForward) { + if (destPlayData.isForward) { // The time that has been played - const playedTime = destRuntime.playedTime; + const playedTime = destPlayData.playedTime; dstPlayCostTime = playedTime + dstPlayDeltaTime > transitionDuration ? transitionDuration - playedTime : dstPlayDeltaTime; } else { // The time that has been played - const playedTime = destRuntime.playedTime; + const playedTime = destPlayData.playedTime; dstPlayCostTime = // -dstPlayDeltaTime: The time that will be played, negative are meant to make it be a periods // > transition: The time that will be played is enough to finish the transition @@ -821,25 +821,25 @@ export class Animator extends Component { const actualCostTime = dstPlaySpeed === 0 ? deltaTime : dstPlayCostTime / dstPlaySpeed; const srcPlayCostTime = actualCostTime * srcPlaySpeed; - srcRuntime.update(srcPlayCostTime); - destRuntime.update(dstPlayCostTime); + srcPlayData.update(srcPlayCostTime); + destPlayData.update(dstPlayCostTime); - let crossWeight = Math.abs(destRuntime.playedTime) / transitionDuration; + let crossWeight = Math.abs(destPlayData.playedTime) / transitionDuration; (crossWeight >= 1.0 - MathUtil.zeroTolerance || transitionDuration === 0) && (crossWeight = 1.0); const crossFadeFinished = crossWeight === 1.0; if (crossFadeFinished) { - srcRuntime.playState = AnimatorStatePlayState.Finished; + srcPlayData.playState = AnimatorStatePlayState.Finished; this._preparePlayOwner(layerData, destState); - this._evaluatePlayingState(destRuntime, weight, additive, aniUpdate); + this._evaluatePlayingState(destPlayData, weight, additive, aniUpdate); } else { - this._evaluateCrossFadeState(layerData, srcRuntime, destRuntime, weight, crossWeight, additive, aniUpdate); + this._evaluateCrossFadeState(layerData, srcPlayData, destPlayData, weight, crossWeight, additive, aniUpdate); } this._fireAnimationEventsAndCallScripts( layerIndex, - srcRuntime, + srcPlayData, srcState, lastSrcClipTime, lastSrcPlayState, @@ -848,7 +848,7 @@ export class Animator extends Component { this._fireAnimationEventsAndCallScripts( layerIndex, - destRuntime, + destPlayData, destState, lastDestClipTime, lastDstPlayState, @@ -864,19 +864,19 @@ export class Animator extends Component { private _evaluateCrossFadeState( layerData: AnimatorLayerData, - srcRuntime: AnimatorStateRuntime, - destRuntime: AnimatorStateRuntime, + srcPlayData: AnimatorStatePlayData, + destPlayData: AnimatorStatePlayData, weight: number, crossWeight: number, additive: boolean, aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const { _curveBindings: srcCurves } = srcRuntime.instance.clip; - const destState = destRuntime.instance._state; + const { _curveBindings: srcCurves } = srcPlayData.instance.clip; + const destState = destPlayData.instance._state; const { _curveBindings: destCurves } = destState.clip; - const finished = destRuntime.playState === AnimatorStatePlayState.Finished; + const finished = destPlayData.playState === AnimatorStatePlayState.Finished; if (aniUpdate || finished) { for (let i = crossLayerOwnerCollection.length - 1; i >= 0; i--) { @@ -893,8 +893,8 @@ export class Animator extends Component { const value = owner.evaluateCrossFadeValue( srcCurveIndex >= 0 ? srcCurves[srcCurveIndex].curve : null, destCurveIndex >= 0 ? destCurves[destCurveIndex].curve : null, - srcRuntime.clipTime, - destRuntime.clipTime, + srcPlayData.clipTime, + destPlayData.clipTime, crossWeight, additive ); @@ -911,30 +911,30 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ) { - const { destRuntime } = layerData; - const state = destRuntime.instance._state; + const { destPlayData } = layerData; + const state = destPlayData.instance._state; const transitionDuration = layerData.crossFadeTransition._getFixedDuration(); if (this._tryCrossFadeInterrupt(layerData, transitionDuration, state, deltaTime, aniUpdate)) { return; } - const playSpeed = destRuntime.instance.speed * this.speed; + const playSpeed = destPlayData.instance.speed * this.speed; const playDeltaTime = playSpeed * deltaTime; - destRuntime.updateOrientation(playDeltaTime); + destPlayData.updateOrientation(playDeltaTime); - const { clipTime: lastDestClipTime, playState: lastPlayState } = destRuntime; + const { clipTime: lastDestClipTime, playState: lastPlayState } = destPlayData; let dstPlayCostTime: number; - if (destRuntime.isForward) { + if (destPlayData.isForward) { // The time that has been played - const playedTime = destRuntime.playedTime; + const playedTime = destPlayData.playedTime; dstPlayCostTime = playedTime + playDeltaTime > transitionDuration ? transitionDuration - playedTime : playDeltaTime; } else { // The time that has been played - const playedTime = destRuntime.playedTime; + const playedTime = destPlayData.playedTime; dstPlayCostTime = // -playDeltaTime: The time that will be played, negative are meant to make it be a periods // > transition: The time that will be played is enough to finish the transition @@ -947,23 +947,23 @@ export class Animator extends Component { const actualCostTime = playSpeed === 0 ? deltaTime : dstPlayCostTime / playSpeed; - destRuntime.update(dstPlayCostTime); + destPlayData.update(dstPlayCostTime); - let crossWeight = Math.abs(destRuntime.playedTime) / transitionDuration; + let crossWeight = Math.abs(destPlayData.playedTime) / transitionDuration; (crossWeight >= 1.0 - MathUtil.zeroTolerance || transitionDuration === 0) && (crossWeight = 1.0); const crossFadeFinished = crossWeight === 1.0; if (crossFadeFinished) { this._preparePlayOwner(layerData, state); - this._evaluatePlayingState(destRuntime, weight, additive, aniUpdate); + this._evaluatePlayingState(destPlayData, weight, additive, aniUpdate); } else { - this._evaluateCrossFadeFromPoseState(layerData, destRuntime, weight, crossWeight, additive, aniUpdate); + this._evaluateCrossFadeFromPoseState(layerData, destPlayData, weight, crossWeight, additive, aniUpdate); } this._fireAnimationEventsAndCallScripts( layerData.layerIndex, - destRuntime, + destPlayData, state, lastDestClipTime, lastPlayState, @@ -979,17 +979,17 @@ export class Animator extends Component { private _evaluateCrossFadeFromPoseState( layerData: AnimatorLayerData, - destRuntime: AnimatorStateRuntime, + destPlayData: AnimatorStatePlayData, weight: number, crossWeight: number, additive: boolean, aniUpdate: boolean ) { const { crossLayerOwnerCollection } = layerData; - const state = destRuntime.instance._state; + const state = destPlayData.instance._state; const { _curveBindings: curveBindings } = state.clip; - const { clipTime: destClipTime, playState: playState } = destRuntime; + const { clipTime: destClipTime, playState: playState } = destPlayData; const finished = playState === AnimatorStatePlayState.Finished; // When the animator is culled (aniUpdate=false), if the play state has finished, the final value needs to be calculated and saved to be applied directly @@ -1023,14 +1023,14 @@ export class Animator extends Component { deltaTime: number, aniUpdate: boolean ): void { - const runtime = layerData.srcRuntime; - const state = runtime.instance._state; - const actualSpeed = runtime.instance.speed * this.speed; + const playData = layerData.srcPlayData; + const state = playData.instance._state; + const actualSpeed = playData.instance.speed * this.speed; const actualDeltaTime = actualSpeed * deltaTime; - runtime.updateOrientation(actualDeltaTime); + playData.updateOrientation(actualDeltaTime); - const { clipTime: clipTime, isForward: isForward } = runtime; + const { clipTime: clipTime, isForward: isForward } = playData; const { _transitionCollection: transitions } = state; const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine; @@ -1040,7 +1040,7 @@ export class Animator extends Component { this._applyStateTransitions( layerData, isForward, - runtime, + playData, transitions, clipTime, clipTime, @@ -1051,12 +1051,12 @@ export class Animator extends Component { if (transition) { this._updateState(layerData, deltaTime, aniUpdate); } else { - this._evaluateFinishedState(runtime, weight, additive, aniUpdate); + this._evaluateFinishedState(playData, weight, additive, aniUpdate); } } private _evaluateFinishedState( - runtime: AnimatorStateRuntime, + playData: AnimatorStatePlayData, weight: number, additive: boolean, aniUpdate: boolean @@ -1065,8 +1065,8 @@ export class Animator extends Component { return; } - const { curveLayerOwner } = runtime.stateData; - const { _curveBindings: curveBindings } = runtime.instance.clip; + const { curveLayerOwner } = playData.stateData; + const { _curveBindings: curveBindings } = playData.instance.clip; for (let i = curveBindings.length - 1; i >= 0; i--) { const layerOwner = curveLayerOwner[i]; @@ -1081,8 +1081,8 @@ export class Animator extends Component { } private _updateCrossFadeData(layerData: AnimatorLayerData): void { - const { destRuntime } = layerData; - if (destRuntime.playState === AnimatorStatePlayState.Finished) { + const { destPlayData } = layerData; + if (destPlayData.playState === AnimatorStatePlayState.Finished) { layerData.layerState = LayerState.Finished; } else { layerData.layerState = LayerState.Playing; @@ -1093,9 +1093,9 @@ export class Animator extends Component { private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorState): void { if (layerData.layerState === LayerState.Playing) { - const srcRuntime = layerData.srcRuntime; - if (srcRuntime.instance._state !== playState) { - const { curveLayerOwner } = srcRuntime.stateData; + const srcPlayData = layerData.srcPlayData; + if (srcPlayData.instance._state !== playState) { + const { curveLayerOwner } = srcPlayData.stateData; for (let i = curveLayerOwner.length - 1; i >= 0; i--) { curveLayerOwner[i]?.curveOwner.revertDefaultValue(); } @@ -1111,14 +1111,14 @@ export class Animator extends Component { private _applyStateTransitions( layerData: AnimatorLayerData, isForward: boolean, - runtime: AnimatorStateRuntime, + playData: AnimatorStatePlayData, transitionCollection: AnimatorStateTransitionCollection, lastClipTime: number, clipTime: number, deltaTime: number, aniUpdate: boolean ): AnimatorStateTransition { - const state = runtime.instance._state; + const state = playData.instance._state; const clipDuration = state.clip.length; let targetTransition: AnimatorStateTransition = null; const startTime = state.clipStartTime * clipDuration; @@ -1347,12 +1347,12 @@ export class Animator extends Component { this._preparePlayOwner(animatorLayerData, state); animatorLayerData.layerState = LayerState.Playing; - const runtime = animatorLayerData.getOrCreateRuntime(state); - runtime.resetForPlay(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); - animatorLayerData.srcRuntime = runtime; + const playData = animatorLayerData.getOrCreatePlayData(state); + playData.resetForPlay(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); + animatorLayerData.srcPlayData = playData; // Clear any stale cross-fade slot from a previously-interrupted crossFade so // subsequent crossFade() calls aren't no-op'd by the self-target alias guard. - animatorLayerData.destRuntime = null; + animatorLayerData.destPlayData = null; animatorLayerData.crossFadeTransition = null; animatorLayerData.resetCurrentCheckIndex(); @@ -1457,17 +1457,17 @@ export class Animator extends Component { // 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.srcRuntime?.instance._state === crossState || - animatorLayerData.destRuntime?.instance._state === crossState + animatorLayerData.srcPlayData?.instance._state === crossState || + animatorLayerData.destPlayData?.instance._state === crossState ) { return false; } const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex); - const destRuntime = animatorLayerData.getOrCreateRuntime(crossState); - destRuntime.resetForPlay(animatorStateData, transition.offset * crossState._getClipActualEndTime()); - animatorLayerData.destRuntime = destRuntime; + const destPlayData = animatorLayerData.getOrCreatePlayData(crossState); + destPlayData.resetForPlay(animatorStateData, transition.offset * crossState._getClipActualEndTime()); + animatorLayerData.destPlayData = destPlayData; animatorLayerData.resetCurrentCheckIndex(); switch (animatorLayerData.layerState) { @@ -1497,37 +1497,37 @@ export class Animator extends Component { } private _fireAnimationEvents( - runtime: AnimatorStateRuntime, + playData: AnimatorStatePlayData, eventHandlers: AnimationEventHandler[], lastClipTime: number, deltaTime: number ): void { - const { isForward, clipTime } = runtime; - const state = runtime.instance._state; + const { isForward, clipTime } = playData; + const state = playData.instance._state; const startTime = state._getClipActualStartTime(); const endTime = state._getClipActualEndTime(); if (isForward) { if (lastClipTime + deltaTime >= endTime) { - this._fireSubAnimationEvents(runtime, eventHandlers, lastClipTime, endTime); - runtime.currentEventIndex = 0; - this._fireSubAnimationEvents(runtime, eventHandlers, startTime, clipTime); + this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, endTime); + playData.currentEventIndex = 0; + this._fireSubAnimationEvents(playData, eventHandlers, startTime, clipTime); } else { - this._fireSubAnimationEvents(runtime, eventHandlers, lastClipTime, clipTime); + this._fireSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); } } else { if (lastClipTime + deltaTime <= startTime) { - this._fireBackwardSubAnimationEvents(runtime, eventHandlers, lastClipTime, startTime); - runtime.currentEventIndex = eventHandlers.length - 1; - this._fireBackwardSubAnimationEvents(runtime, eventHandlers, endTime, clipTime); + this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, startTime); + playData.currentEventIndex = eventHandlers.length - 1; + this._fireBackwardSubAnimationEvents(playData, eventHandlers, endTime, clipTime); } else { - this._fireBackwardSubAnimationEvents(runtime, eventHandlers, lastClipTime, clipTime); + this._fireBackwardSubAnimationEvents(playData, eventHandlers, lastClipTime, clipTime); } } } private _fireSubAnimationEvents( - playState: AnimatorStateRuntime, + playState: AnimatorStatePlayData, eventHandlers: AnimationEventHandler[], lastClipTime: number, curClipTime: number @@ -1552,7 +1552,7 @@ export class Animator extends Component { } private _fireBackwardSubAnimationEvents( - playState: AnimatorStateRuntime, + playState: AnimatorStatePlayData, eventHandlers: AnimationEventHandler[], lastClipTime: number, curClipTime: number @@ -1609,19 +1609,19 @@ export class Animator extends Component { private _fireAnimationEventsAndCallScripts( layerIndex: number, - runtime: AnimatorStateRuntime, + playData: AnimatorStatePlayData, state: AnimatorState, lastClipTime: number, lastPlayState: AnimatorStatePlayState, deltaTime: number ) { - const { eventHandlers } = runtime.stateData; - eventHandlers.length && this._fireAnimationEvents(runtime, eventHandlers, lastClipTime, deltaTime); + const { eventHandlers } = playData.stateData; + eventHandlers.length && this._fireAnimationEvents(playData, eventHandlers, lastClipTime, deltaTime); if (lastPlayState === AnimatorStatePlayState.UnStarted) { state._callOnEnter(this, layerIndex); } - if (lastPlayState !== AnimatorStatePlayState.Finished && runtime.playState === AnimatorStatePlayState.Finished) { + if (lastPlayState !== AnimatorStatePlayState.Finished && playData.playState === AnimatorStatePlayState.Finished) { state._callOnExit(this, layerIndex); } else { state._callOnUpdate(this, layerIndex); diff --git a/packages/core/src/animation/AnimatorStateInstance.ts b/packages/core/src/animation/AnimatorStateInstance.ts index 5dc185e4ed..b5af975f48 100644 --- a/packages/core/src/animation/AnimatorStateInstance.ts +++ b/packages/core/src/animation/AnimatorStateInstance.ts @@ -1,6 +1,6 @@ import { AnimationClip } from "./AnimationClip"; import { AnimatorState } from "./AnimatorState"; -import { AnimatorStateRuntime } from "./internal/AnimatorStateRuntime"; +import { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; import { WrapMode } from "./enums/WrapMode"; /** @@ -11,7 +11,7 @@ export class AnimatorStateInstance { /** @internal */ _state: AnimatorState; /** @internal */ - _runtime: AnimatorStateRuntime; + _playData: AnimatorStatePlayData; private _speed: number | undefined; private _wrapMode: WrapMode | undefined; diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index 548ca59c11..2837e0df15 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -5,7 +5,7 @@ import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { LayerState } from "../enums/LayerState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimatorStateData } from "./AnimatorStateData"; -import { AnimatorStateRuntime } from "./AnimatorStateRuntime"; +import { AnimatorStatePlayData } from "./AnimatorStatePlayData"; /** * @internal @@ -16,8 +16,8 @@ export class AnimatorLayerData { curveOwnerPool: Record> = Object.create(null); animatorStateDataMap: Record = Object.create(null); instanceMap: Record = Object.create(null); - srcRuntime: AnimatorStateRuntime | null = null; - destRuntime: AnimatorStateRuntime | null = null; + srcPlayData: AnimatorStatePlayData | null = null; + destPlayData: AnimatorStatePlayData | null = null; layerState: LayerState = LayerState.Standby; crossCurveMark: number = 0; manuallyTransition: AnimatorStateTransition = new AnimatorStateTransition(); @@ -25,21 +25,21 @@ export class AnimatorLayerData { crossLayerOwnerCollection: AnimationCurveLayerOwner[] = []; /** Lazy-create the (instance, runtime) pair; rebuild if the asset was swapped. */ - getOrCreateRuntime(state: AnimatorState): AnimatorStateRuntime { + getOrCreatePlayData(state: AnimatorState): AnimatorStatePlayData { const map = this.instanceMap; const name = state.name; let instance = map[name]; if (instance?._state !== state) { instance = new AnimatorStateInstance(state); - new AnimatorStateRuntime(instance); + new AnimatorStatePlayData(instance); map[name] = instance; } - return instance._runtime; + return instance._playData; } promoteDest(): void { - this.srcRuntime = this.destRuntime; - this.destRuntime = null; + this.srcPlayData = this.destPlayData; + this.destPlayData = null; } resetCurrentCheckIndex(): void { diff --git a/packages/core/src/animation/internal/AnimatorStateRuntime.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts similarity index 91% rename from packages/core/src/animation/internal/AnimatorStateRuntime.ts rename to packages/core/src/animation/internal/AnimatorStatePlayData.ts index f489054996..3a84270c33 100644 --- a/packages/core/src/animation/internal/AnimatorStateRuntime.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -1,4 +1,3 @@ -import { AnimatorState } from "../AnimatorState"; import { AnimatorStateInstance } from "../AnimatorStateInstance"; import { AnimatorStatePlayState } from "../enums/AnimatorStatePlayState"; import { WrapMode } from "../enums/WrapMode"; @@ -6,11 +5,8 @@ import { AnimatorStateData } from "./AnimatorStateData"; /** * @internal - * - * Per-(Animator, AnimatorState) playback runtime. Paired 1:1 with an - * `AnimatorStateInstance` and mutated by the Animator update loop. */ -export class AnimatorStateRuntime { +export class AnimatorStatePlayData { readonly instance: AnimatorStateInstance; stateData: AnimatorStateData; @@ -25,7 +21,7 @@ export class AnimatorStateRuntime { constructor(instance: AnimatorStateInstance) { this.instance = instance; - instance._runtime = this; + instance._playData = this; } /** Reset playback fields on (re-)enter. Per-instance overrides are preserved. */ diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index fceb0d66f4..fe52e4babd 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -102,29 +102,29 @@ describe("Animator test", function () { animator.play("Run"); let animatorLayerData = animator["_animatorLayersData"]; - const srcRuntime = animatorLayerData[0]?.srcRuntime; + const srcPlayData = animatorLayerData[0]?.srcPlayData; const speed = 1; let expectedSpeed = speed * 0.5; animator.speed = expectedSpeed; - let playedTime = srcRuntime.playedTime; + let playedTime = srcPlayData.playedTime; // @ts-ignore animator.engine.time._frameCount++; animator.update(5); expect(animator.speed).to.eq(expectedSpeed); - expect(srcRuntime.playedTime).to.eq(playedTime + 5 * expectedSpeed); + expect(srcPlayData.playedTime).to.eq(playedTime + 5 * expectedSpeed); expectedSpeed = speed * 2; animator.speed = expectedSpeed; - playedTime = srcRuntime.playedTime; + playedTime = srcPlayData.playedTime; animator.update(10); expect(animator.speed).to.eq(expectedSpeed); - expect(srcRuntime.playedTime).to.eq(playedTime + 10 * expectedSpeed); + expect(srcPlayData.playedTime).to.eq(playedTime + 10 * expectedSpeed); expectedSpeed = speed * 0; animator.speed = expectedSpeed; - playedTime = srcRuntime.playedTime; + playedTime = srcPlayData.playedTime; animator.update(15); expect(animator.speed).to.eq(expectedSpeed); - expect(srcRuntime.playedTime).to.eq(playedTime + 15 * expectedSpeed); + expect(srcPlayData.playedTime).to.eq(playedTime + 15 * expectedSpeed); }); it("play animation", () => { @@ -159,9 +159,9 @@ describe("Animator test", function () { animator.play("Run"); let animatorLayerData = animator["_animatorLayersData"]; - const srcRuntime = animatorLayerData[0]?.srcRuntime; + const srcPlayData = animatorLayerData[0]?.srcPlayData; animator.update(5); - const curveOwner = srcRuntime.stateData.curveLayerOwner[0].curveOwner; + const curveOwner = srcPlayData.stateData.curveLayerOwner[0].curveOwner; const initValue = curveOwner.defaultValue; const currentValue = curveOwner.referenceTargetValue; expect(Quaternion.equals(initValue, currentValue)).to.eq(true); @@ -250,11 +250,11 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._getAnimatorLayerData(0); - const srcRuntime = layerData.srcRuntime; - expect(srcRuntime.instance.name).to.eq("Run"); - expect(srcRuntime.playedTime).to.eq(0.3); + const srcPlayData = layerData.srcPlayData; + expect(srcPlayData.instance.name).to.eq("Run"); + expect(srcPlayData.playedTime).to.eq(0.3); // @ts-ignore - expect(srcRuntime.clipTime).to.eq(0.3 + 0.1 * (runState as any)._state._getDuration()); + expect(srcPlayData.clipTime).to.eq(0.3 + 0.1 * (runState as any)._state._getDuration()); }); it("animation cross fade by transition", () => { @@ -627,10 +627,10 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.01); - const destRuntime = animator["_animatorLayersData"][0].destRuntime; - const destState = (destRuntime.instance as any)._state; + const destPlayData = animator["_animatorLayersData"][0].destPlayData; + const destState = (destPlayData.instance as any)._state; const transitionDuration = toRunTransition.duration * destState._getDuration(); - const crossWeight = animator["_animatorLayersData"][0].destRuntime.playedTime / transitionDuration; + const crossWeight = animator["_animatorLayersData"][0].destPlayData.playedTime / transitionDuration; expect(crossWeight).to.lessThan(0.01); }); @@ -650,8 +650,8 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.1); - const destRuntime = animator["_animatorLayersData"][0].destRuntime; - expect(destRuntime.instance?.name).to.eq("Run"); + const destPlayData = animator["_animatorLayersData"][0].destPlayData; + expect(destPlayData.instance?.name).to.eq("Run"); }); it("transition to exit but no entry", () => { @@ -846,9 +846,9 @@ describe("Animator test", function () { animator.engine.time._frameCount++; animator.update(0.5); - expect(layerData.srcRuntime.instance.name).to.eq("Run"); - expect(layerData.srcRuntime.playedTime).to.eq(0.5); - expect(layerData.srcRuntime.clipTime).to.eq((walkState as any)._state.clip.length * 0.5 + 0.5); + expect(layerData.srcPlayData.instance.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); }); it("hasExitTime", () => { @@ -876,8 +876,8 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update((walkState as any)._state.clip.length * 0.5); - expect(layerData.destRuntime.instance.name).to.eq("Run"); - expect(layerData.destRuntime.playedTime).to.eq(0); + expect(layerData.destPlayData.instance.name).to.eq("Run"); + expect(layerData.destPlayData.playedTime).to.eq(0); const anyToIdleTransition = stateMachine.addAnyStateTransition((idleState as any)._state); anyToIdleTransition.hasExitTime = false; anyToIdleTransition.duration = 0.2; @@ -886,13 +886,13 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcRuntime.instance.name).to.eq("Run"); - expect(layerData.srcRuntime.playedTime).to.eq(0.1); + expect(layerData.srcPlayData.instance.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.srcRuntime.instance.name).to.eq("Survey"); - expect(layerData.srcRuntime.clipTime).to.eq((idleState as any)._state.clip.length * 0.2); + expect(layerData.srcPlayData.instance.name).to.eq("Survey"); + expect(layerData.srcPlayData.clipTime).to.eq((idleState as any)._state.clip.length * 0.2); }); it("setTriggerParameter", () => { @@ -926,28 +926,28 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcRuntime.instance.name).to.eq("Walk"); - expect(layerData.srcRuntime.playedTime).to.eq(0.1); - expect(layerData.destRuntime.instance.name).to.eq("Run"); - expect(layerData.destRuntime.playedTime).to.eq(0.1); + expect(layerData.srcPlayData.instance.name).to.eq("Walk"); + expect(layerData.srcPlayData.playedTime).to.eq(0.1); + expect(layerData.destPlayData.instance.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.srcRuntime.instance.name).to.eq("Run"); - expect(layerData.srcRuntime.playedTime).to.eq((runState as any)._state.clip.length * 0.1); + expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.playedTime).to.eq((runState as any)._state.clip.length * 0.1); // @ts-ignore animator.engine.time._frameCount++; animator.update((runState as any)._state.clip.length * 0.6); - expect(layerData.destRuntime.instance.name).to.eq("Walk"); - expect(layerData.destRuntime.playedTime).to.eq(0); + expect(layerData.destPlayData.instance.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.srcRuntime.instance.name).to.eq("Walk"); - expect(layerData.srcRuntime.playedTime).to.eq((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); }); it("fixedDuration", () => { @@ -971,9 +971,9 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.1); - expect(layerData.srcRuntime.instance.name).to.eq("Run"); - expect(layerData.srcRuntime.playedTime).to.eq(0.1); - expect(layerData.srcRuntime.clipTime).to.eq(0); + expect(layerData.srcPlayData.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.playedTime).to.eq(0.1); + expect(layerData.srcPlayData.clipTime).to.eq(0); }); it("transitionIndex", () => { @@ -1034,13 +1034,13 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(0.6); - expect(animatorLayerData[0]?.srcRuntime.instance.name).to.eq("state1"); + expect(animatorLayerData[0]?.srcPlayData.instance.name).to.eq("state1"); transition2.mute = false; // @ts-ignore animator.engine.time._frameCount++; animator.update(0.3); - expect(animatorLayerData[0]?.srcRuntime.instance.name).to.eq("state2"); + expect(animatorLayerData[0]?.srcPlayData.instance.name).to.eq("state2"); }); it("Clone", () => { @@ -1113,7 +1113,7 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destRuntime.instance.name).to.eq("Run"); + expect(layerData.destPlayData.instance.name).to.eq("Run"); // Trigger interrupt during crossFade animator.setParameterValue("interrupt", true); @@ -1122,7 +1122,7 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destRuntime.instance.name).to.eq("Survey"); + expect(layerData.destPlayData.instance.name).to.eq("Survey"); }); it("noExitTime transition scan should ignore exitTime transitions", () => { @@ -1161,8 +1161,8 @@ describe("Animator test", function () { // @ts-ignore animator.engine.time._frameCount++; animator.update(preExitDeltaTime); - expect(layerData.srcRuntime.instance.name).to.eq("Walk"); - expect(layerData.destRuntime).to.be.null; + expect(layerData.srcPlayData.instance.name).to.eq("Walk"); + expect(layerData.destPlayData).to.be.null; // Update past exitTime, should transition to Run. // @ts-ignore @@ -1203,7 +1203,7 @@ describe("Animator test", function () { animator.update(0.1); expect(layerData.layerState).to.eq(LayerState.FixedCrossFading); - expect(layerData.destRuntime.instance.name).to.eq("Run"); + expect(layerData.destPlayData.instance.name).to.eq("Run"); // Trigger interrupt during FixedCrossFading animator.setParameterValue("interrupt", true); @@ -1212,7 +1212,7 @@ describe("Animator test", function () { animator.update(0.1); // Should have interrupted to Idle - expect(layerData.destRuntime.instance.name).to.eq("Survey"); + expect(layerData.destPlayData.instance.name).to.eq("Survey"); }); it("anyState interrupt should skip transition to same destination state", () => { @@ -1239,7 +1239,7 @@ describe("Animator test", function () { // Should be in CrossFading state, dest = Run expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destRuntime.instance.name).to.eq("Run"); + expect(layerData.destPlayData.instance.name).to.eq("Run"); // Update again - anyState -> Run should be skipped because dest is already Run // @ts-ignore @@ -1248,7 +1248,7 @@ describe("Animator test", function () { // Should still be CrossFading to Run (not interrupted/reset) expect(layerData.layerState).to.eq(LayerState.CrossFading); - expect(layerData.destRuntime.instance.name).to.eq("Run"); + expect(layerData.destPlayData.instance.name).to.eq("Run"); }); it("zero-duration crossFade should not be interrupted by anyState transition", () => { @@ -1274,7 +1274,7 @@ describe("Animator test", function () { const layerData = animator._getAnimatorLayerData(0); // Zero-duration crossFade completes instantly, should be Playing Run (not interrupted to Survey) - expect(layerData.srcRuntime.instance.name).to.eq("Run"); + expect(layerData.srcPlayData.instance.name).to.eq("Run"); }); it("toggle hasExitTime should maintain correct noExitTimeCount", () => { @@ -1359,10 +1359,10 @@ describe("Animator test", function () { animator.update(0.1); // @ts-ignore - const srcRuntime = animator._animatorLayersData[0].srcRuntime; - expect(srcRuntime.instance.name).to.eq("Survey"); // ensure crossfade actually completed back to Survey + 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(srcRuntime.speed).to.eq(0.5); + expect(srcPlayData.speed).to.eq(0.5); }); it("per-instance speed is per-Animator (clone isolation)", () => { @@ -1379,7 +1379,7 @@ describe("Animator test", function () { expect(sharedSurvey.speed).to.eq(1); }); - it("crossFade phase uses runtime.speed for time progression", () => { + 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"); @@ -1389,17 +1389,17 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - const srcPlayedBefore = layerData.srcRuntime.playedTime; + const srcPlayedBefore = layerData.srcPlayData.playedTime; - // Start crossFade — during crossFade, src should still advance per runtime.speed=4 + // 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.srcRuntime.playedTime; + const srcPlayedAfter = layerData.srcPlayData.playedTime; const advanced = srcPlayedAfter - srcPlayedBefore; - // With runtime.speed=4 and dt=0.05, expect ~0.2 (4 * 0.05). With state.speed=1 it'd be ~0.05. + // 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); }); @@ -1609,15 +1609,15 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - const srcBefore = layerData.srcRuntime; + 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.srcRuntime).to.eq(srcBefore); - expect(layerData.srcRuntime.playedTime).to.eq(playedBefore); - expect(layerData.destRuntime).to.eq(null); + 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", () => { @@ -1633,14 +1633,14 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - const destBefore = layerData.destRuntime; + 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.destRuntime).to.eq(destBefore); - expect(layerData.destRuntime.playedTime).to.eq(destPlayedBefore); + 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)", () => { @@ -1660,7 +1660,7 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - const srcBefore = layerData.srcRuntime; + const srcBefore = layerData.srcPlayData; const playedBefore = srcBefore.playedTime; // Trigger the self-transition @@ -1671,13 +1671,13 @@ describe("Animator test", function () { // 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.srcRuntime).to.eq(srcBefore); - expect(layerData.srcRuntime.instance.name).to.eq("Walk"); - expect(layerData.srcRuntime.playedTime).to.be.greaterThan(playedBefore); - expect(layerData.destRuntime).to.eq(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 destRuntime", () => { + it("play during crossFade clears stale destPlayData", () => { animator.play("Walk"); // @ts-ignore animator.engine.time._frameCount++; @@ -1693,13 +1693,13 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - expect(layerData.destRuntime).to.eq(null); + 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.destRuntime?.state.name).to.eq("Run"); + expect(layerData.destPlayData?.state.name).to.eq("Run"); }); it("crossFade to nonexistent state is a safe no-op", () => { @@ -1756,10 +1756,10 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - expect(Number.isNaN(layerData.srcRuntime.playedTime)).to.eq(false); - expect(Number.isNaN(layerData.destRuntime?.playedTime ?? 0)).to.eq(false); + 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.destRuntime?.playedTime).to.be.greaterThan(0); + expect(layerData.destPlayData?.playedTime).to.be.greaterThan(0); }); it("no-exit transition out of speed=0 source preserves remaining deltaTime and avoids NaN", () => { @@ -1788,11 +1788,11 @@ describe("Animator test", function () { // @ts-ignore const layerData = animator._animatorLayersData[0]; - expect(Number.isNaN(layerData.srcRuntime.playedTime)).to.eq(false); - expect(Number.isNaN(layerData.destRuntime?.playedTime ?? 0)).to.eq(false); - expect(layerData.destRuntime?.state.name).to.eq("Walk"); + expect(Number.isNaN(layerData.srcPlayData.playedTime)).to.eq(false); + expect(Number.isNaN(layerData.destPlayData?.playedTime ?? 0)).to.eq(false); + expect(layerData.destPlayData?.state.name).to.eq("Walk"); // dest should have advanced from the remaining deltaTime that was // preserved by the playSpeed===0 guard - expect(layerData.destRuntime?.playedTime).to.be.greaterThan(0); + expect(layerData.destPlayData?.playedTime).to.be.greaterThan(0); }); }); From 50d9ff9dba00a14021f54483f8df90a88311cc71 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 19:55:04 +0800 Subject: [PATCH 68/92] refactor(animation): clean up Instance/PlayData ownership and naming - Instance owns PlayData: created in Instance.constructor, no more reverse side-effect (PlayData no longer mutates the passed-in instance). - _state and _playData on Instance are now readonly. - AnimatorLayerData.getOrCreatePlayData renamed to getOrCreateInstance so the return type matches the method name. Call sites take ._playData themselves when they need the runtime data. --- packages/core/src/animation/Animator.ts | 6 +++--- packages/core/src/animation/AnimatorStateInstance.ts | 5 +++-- .../core/src/animation/internal/AnimatorLayerData.ts | 9 ++++----- .../core/src/animation/internal/AnimatorStatePlayData.ts | 6 +----- 4 files changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index cde69c167c..ab7534c95c 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -222,7 +222,7 @@ export class Animator extends Component { this._resetIfControllerUpdated(); const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); if (!state || foundLayer < 0) return null; - return this._getAnimatorLayerData(foundLayer).getOrCreatePlayData(state).instance; + return this._getAnimatorLayerData(foundLayer).getOrCreateInstance(state); } /** @@ -1347,7 +1347,7 @@ export class Animator extends Component { this._preparePlayOwner(animatorLayerData, state); animatorLayerData.layerState = LayerState.Playing; - const playData = animatorLayerData.getOrCreatePlayData(state); + const playData = animatorLayerData.getOrCreateInstance(state)._playData; playData.resetForPlay(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); animatorLayerData.srcPlayData = playData; // Clear any stale cross-fade slot from a previously-interrupted crossFade so @@ -1465,7 +1465,7 @@ export class Animator extends Component { const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex); - const destPlayData = animatorLayerData.getOrCreatePlayData(crossState); + const destPlayData = animatorLayerData.getOrCreateInstance(crossState)._playData; destPlayData.resetForPlay(animatorStateData, transition.offset * crossState._getClipActualEndTime()); animatorLayerData.destPlayData = destPlayData; animatorLayerData.resetCurrentCheckIndex(); diff --git a/packages/core/src/animation/AnimatorStateInstance.ts b/packages/core/src/animation/AnimatorStateInstance.ts index b5af975f48..7cc1c54f1f 100644 --- a/packages/core/src/animation/AnimatorStateInstance.ts +++ b/packages/core/src/animation/AnimatorStateInstance.ts @@ -9,9 +9,9 @@ import { WrapMode } from "./enums/WrapMode"; */ export class AnimatorStateInstance { /** @internal */ - _state: AnimatorState; + readonly _state: AnimatorState; /** @internal */ - _playData: AnimatorStatePlayData; + readonly _playData: AnimatorStatePlayData; private _speed: number | undefined; private _wrapMode: WrapMode | undefined; @@ -53,5 +53,6 @@ export class AnimatorStateInstance { /** @internal */ constructor(state: AnimatorState) { this._state = state; + this._playData = new AnimatorStatePlayData(this); } } diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index 2837e0df15..64d7bcd18e 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -5,7 +5,7 @@ import { AnimatorStateTransition } from "../AnimatorStateTransition"; import { LayerState } from "../enums/LayerState"; import { AnimationCurveLayerOwner } from "./AnimationCurveLayerOwner"; import { AnimatorStateData } from "./AnimatorStateData"; -import { AnimatorStatePlayData } from "./AnimatorStatePlayData"; +import type { AnimatorStatePlayData } from "./AnimatorStatePlayData"; /** * @internal @@ -24,17 +24,16 @@ export class AnimatorLayerData { crossFadeTransition: AnimatorStateTransition; crossLayerOwnerCollection: AnimationCurveLayerOwner[] = []; - /** Lazy-create the (instance, runtime) pair; rebuild if the asset was swapped. */ - getOrCreatePlayData(state: AnimatorState): AnimatorStatePlayData { + /** Lazy-create the per-Animator instance for a state; rebuild if the asset was swapped. */ + getOrCreateInstance(state: AnimatorState): AnimatorStateInstance { const map = this.instanceMap; const name = state.name; let instance = map[name]; if (instance?._state !== state) { instance = new AnimatorStateInstance(state); - new AnimatorStatePlayData(instance); map[name] = instance; } - return instance._playData; + return instance; } promoteDest(): void { diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index 3a84270c33..e9c0b2e2ac 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -7,7 +7,6 @@ import { AnimatorStateData } from "./AnimatorStateData"; * @internal */ export class AnimatorStatePlayData { - readonly instance: AnimatorStateInstance; stateData: AnimatorStateData; playedTime: number = 0; @@ -19,10 +18,7 @@ export class AnimatorStatePlayData { private _changedOrientation: boolean = false; - constructor(instance: AnimatorStateInstance) { - this.instance = instance; - instance._playData = this; - } + constructor(public readonly instance: AnimatorStateInstance) {} /** Reset playback fields on (re-)enter. Per-instance overrides are preserved. */ resetForPlay(stateData: AnimatorStateData, offsetFrameTime: number): void { From a0c21a33b55a434d37252be5bfb222a58dceac6a Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 20:19:25 +0800 Subject: [PATCH 69/92] refactor(animation): drop redundant layerIndex<0 check after !state guard When _getAnimatorStateInfo returns state!=null, layerIndex is always >= 0, so the second branch never fires on its own. Aligns findAnimatorState and _crossFade with the sibling play() function's single !state check. --- packages/core/src/animation/Animator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index ab7534c95c..7d95fd56c2 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -221,7 +221,7 @@ export class Animator extends Component { findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStateInstance | null { this._resetIfControllerUpdated(); const { state, layerIndex: foundLayer } = this._getAnimatorStateInfo(stateName, layerIndex); - if (!state || foundLayer < 0) return null; + if (!state) return null; return this._getAnimatorLayerData(foundLayer).getOrCreateInstance(state); } @@ -386,7 +386,7 @@ export class Animator extends Component { this._resetIfControllerUpdated(); const { state, layerIndex: playLayerIndex } = this._getAnimatorStateInfo(stateName, layerIndex); - if (!state || playLayerIndex < 0) { + if (!state) { return; } const { manuallyTransition } = this._getAnimatorLayerData(playLayerIndex); From 86b8398ceabc3dad57e952f5be9179c6f8d1cbc2 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 20:33:47 +0800 Subject: [PATCH 70/92] fix(animation): dispatch controller update flag on stateMachine state mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stateMachine.addState/removeState now flow up to the controller's update flag via a parallel _setController injection chain (mirroring the existing _setEngine pattern). This fixes the silent staleness when users do removeState + addState same-name at runtime — the controller update flag now triggers a full _reset on the next Animator entry, which transparently clears all caches (stateDataMap, instanceMap, ...). Drops two now-redundant identity checks: - Animator._getAnimatorStateData: stateData.state !== animatorState - AnimatorLayerData.getOrCreateInstance: instance._state !== state Treats the root cause instead of patching each cache's lookup path, and aligns stateMachine mutations with the existing addLayer/removeLayer dispatch pattern on AnimatorController. --- packages/core/src/animation/Animator.ts | 6 ------ packages/core/src/animation/AnimatorController.ts | 10 +++++++++- .../core/src/animation/AnimatorControllerLayer.ts | 6 ++++++ packages/core/src/animation/AnimatorStateMachine.ts | 11 ++++++++++- .../core/src/animation/internal/AnimatorLayerData.ts | 4 ++-- 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 7d95fd56c2..330719d3fd 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -433,12 +433,6 @@ export class Animator extends Component { ): AnimatorStateData { const { animatorStateDataMap } = animatorLayerData; let animatorStateData = animatorStateDataMap[stateName]; - if (animatorStateData && animatorStateData.state !== animatorState) { - // Same name but different state instance (e.g. removeState + addState same name): - // detach the old listener and rebuild stateData against the new state. - animatorStateData.dispose(); - animatorStateData = null; - } if (!animatorStateData) { animatorStateData = new AnimatorStateData(animatorState); animatorStateDataMap[stateName] = animatorStateData; diff --git a/packages/core/src/animation/AnimatorController.ts b/packages/core/src/animation/AnimatorController.ts index 60bc439210..d3360a98aa 100644 --- a/packages/core/src/animation/AnimatorController.ts +++ b/packages/core/src/animation/AnimatorController.ts @@ -112,6 +112,7 @@ export class AnimatorController extends ReferResource { addLayer(layer: AnimatorControllerLayer): void { this._layers.push(layer); this._layersMap[layer.name] = layer; + layer._setController(this); layer._setEngine(this._engine); this._updateFlagManager.dispatch(); } @@ -145,13 +146,20 @@ export class AnimatorController extends ReferResource { return this._updateFlagManager.createFlag(BoolUpdateFlag); } + /** @internal */ + _dispatchUpdate(): void { + this._updateFlagManager.dispatch(); + } + /** * @internal */ _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._setController(this); + layer._setEngine(engine); } } diff --git a/packages/core/src/animation/AnimatorControllerLayer.ts b/packages/core/src/animation/AnimatorControllerLayer.ts index 135ac0204f..1eaa346aa0 100644 --- a/packages/core/src/animation/AnimatorControllerLayer.ts +++ b/packages/core/src/animation/AnimatorControllerLayer.ts @@ -1,4 +1,5 @@ import { Engine } from "../Engine"; +import type { AnimatorController } from "./AnimatorController"; import { AnimatorStateMachine } from "./AnimatorStateMachine"; import { AnimatorLayerBlendingMode } from "./enums/AnimatorLayerBlendingMode"; import { AnimatorLayerMask } from "./AnimatorLayerMask"; @@ -29,4 +30,9 @@ export class AnimatorControllerLayer { _setEngine(engine: Engine): void { this.stateMachine._setEngine(engine); } + + /** @internal */ + _setController(controller: AnimatorController): void { + this.stateMachine._setController(controller); + } } diff --git a/packages/core/src/animation/AnimatorStateMachine.ts b/packages/core/src/animation/AnimatorStateMachine.ts index 7a2914280a..be2ac31798 100644 --- a/packages/core/src/animation/AnimatorStateMachine.ts +++ b/packages/core/src/animation/AnimatorStateMachine.ts @@ -1,4 +1,5 @@ import { Engine } from "../Engine"; +import type { AnimatorController } from "./AnimatorController"; import { AnimatorState } from "./AnimatorState"; import { AnimatorStateTransition } from "./AnimatorStateTransition"; import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; @@ -14,6 +15,7 @@ export class AnimatorStateMachine { readonly states: AnimatorState[] = []; private _engine: Engine; + private _controller: AnimatorController; /** * The state will be played automatically. @@ -53,6 +55,7 @@ export class AnimatorStateMachine { state._setEngine(this._engine); this.states.push(state); this._statesMap[name] = state; + this._controller?._dispatchUpdate(); } else { console.warn(`The state named ${name} has existed.`); } @@ -68,8 +71,9 @@ export class AnimatorStateMachine { const index = this.states.indexOf(state); if (index > -1) { this.states.splice(index, 1); + delete this._statesMap[name]; + this._controller?._dispatchUpdate(); } - delete this._statesMap[name]; } /** @@ -167,4 +171,9 @@ export class AnimatorStateMachine { states[i]._setEngine(engine); } } + + /** @internal */ + _setController(controller: AnimatorController): void { + this._controller = controller; + } } diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index 64d7bcd18e..20ac83fff6 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -24,12 +24,12 @@ export class AnimatorLayerData { crossFadeTransition: AnimatorStateTransition; crossLayerOwnerCollection: AnimationCurveLayerOwner[] = []; - /** Lazy-create the per-Animator instance for a state; rebuild if the asset was swapped. */ + /** Lazy-create the per-Animator instance for a state. */ getOrCreateInstance(state: AnimatorState): AnimatorStateInstance { const map = this.instanceMap; const name = state.name; let instance = map[name]; - if (instance?._state !== state) { + if (!instance) { instance = new AnimatorStateInstance(state); map[name] = instance; } From 8292d1a6fbaf118165f0f0e642865ad72f039806 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 21:13:28 +0800 Subject: [PATCH 71/92] refactor(animation): lazy version + WeakMap for state-keyed caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace listener-based clip change notification with lazy version pull, and switch AnimatorState-keyed maps from Record to WeakMap. - UpdateFlagManager: add monotonic `_version` counter, bumped on dispatch - AnimatorStateData: drop clipChangedListener field and dispose method; add `eventsBuiltVersion` snapshot for lazy invalidation - Animator: replace `_saveAnimatorEventHandlers` (listener-based) with `_ensureEventHandlersUpToDate` (pull-based version check); drop dispose loop from `_reset` and `_reset` call from `_onDestroy` - AnimatorLayerData: switch animatorStateDataMap and instanceMap to WeakMap — entries auto-clear when state is GC'd - AnimatorStateMachine / AnimatorControllerLayer / AnimatorController: drop the stateMachine -> controller dispatch chain that was only needed to invalidate stale stateData on `removeState + addState`; WeakMap keying by identity makes that path unnecessary - tests: drop two listener-detach tests; add lazy invalidation test; update WeakMap access patterns Net -85 lines, eliminates listener leak path entirely. --- packages/core/src/UpdateFlagManager.ts | 5 ++ packages/core/src/animation/Animator.ts | 70 +++++++--------- .../core/src/animation/AnimatorController.ts | 10 +-- .../src/animation/AnimatorControllerLayer.ts | 6 -- .../src/animation/AnimatorStateMachine.ts | 9 --- .../animation/internal/AnimatorLayerData.ts | 9 +-- .../animation/internal/AnimatorStateData.ts | 13 +-- tests/src/core/Animator.test.ts | 79 +++++-------------- 8 files changed, 58 insertions(+), 143 deletions(-) diff --git a/packages/core/src/UpdateFlagManager.ts b/packages/core/src/UpdateFlagManager.ts index 82dda34349..0513710e25 100644 --- a/packages/core/src/UpdateFlagManager.ts +++ b/packages/core/src/UpdateFlagManager.ts @@ -7,6 +7,9 @@ import { Utils } from "./Utils"; export class UpdateFlagManager { /** @internal */ _updateFlags: UpdateFlag[] = []; + /** Monotonic counter; bumped on every `dispatch`. Lets consumers do lazy pull-style invalidation without registering a listener. */ + /** @internal */ + _version: number = 0; private _listeners: ((type?: number, param?: Object) => void)[] = []; @@ -62,6 +65,8 @@ export class UpdateFlagManager { * @param param - Event param */ dispatch(type?: number, param?: Object): void { + this._version++; + const updateFlags = this._updateFlags; for (let i = updateFlags.length - 1; i >= 0; i--) { updateFlags[i].dispatch(type, param); diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 330719d3fd..e46923e87e 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -324,18 +324,6 @@ export class Animator extends Component { } } - // Detach clipChangedListeners before dropping stateData; otherwise each - // controller mutation would leave a dead listener attached to the - // surviving AnimatorState's UpdateFlagManager. - const layersData = this._animatorLayersData; - for (let i = 0, n = layersData.length; i < n; i++) { - const stateDataMap = layersData[i]?.animatorStateDataMap; - if (!stateDataMap) continue; - for (const stateName in stateDataMap) { - stateDataMap[stateName].dispose(); - } - } - this._animatorLayersData.length = 0; this._curveOwnerPool = Object.create(null); this._parametersValueMap = Object.create(null); @@ -364,10 +352,6 @@ export class Animator extends Component { } protected override _onDestroy(): void { - // Reuse _reset() to detach AnimatorStateData clipChangedListeners — without - // this the listener closures stay attached to surviving AnimatorState - // UpdateFlagManagers and keep referencing the destroyed entity / stateData. - this._reset(); super._onDestroy(); const controller = this._animatorController; if (controller) { @@ -432,13 +416,13 @@ export class Animator extends Component { layerIndex: number ): AnimatorStateData { const { animatorStateDataMap } = animatorLayerData; - let animatorStateData = animatorStateDataMap[stateName]; + let animatorStateData = animatorStateDataMap.get(animatorState); if (!animatorStateData) { animatorStateData = new AnimatorStateData(animatorState); - animatorStateDataMap[stateName] = animatorStateData; + animatorStateDataMap.set(animatorState, animatorStateData); this._saveAnimatorStateData(animatorState, animatorStateData, animatorLayerData, layerIndex); - this._saveAnimatorEventHandlers(animatorState, animatorStateData); } + this._ensureEventHandlersUpToDate(animatorState, animatorStateData); return animatorStateData; } @@ -495,35 +479,35 @@ export class Animator extends Component { } } - private _saveAnimatorEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void { + private _ensureEventHandlersUpToDate(state: AnimatorState, animatorStateData: AnimatorStateData): void { + const clipFlag = state.clip._updateFlagManager; + if (animatorStateData.eventsBuiltVersion === clipFlag._version) { + return; + } + const eventHandlerPool = this._animationEventHandlerPool; const scripts = []; + this._entity.getComponents(Script, scripts); + const scriptCount = scripts.length; + const { events } = state.clip; const { eventHandlers } = animatorStateData; + eventHandlers.length = 0; + for (let i = 0, n = events.length; i < n; i++) { + const event = events[i]; + const eventHandler = eventHandlerPool.get(); + const funcName = event.functionName; + const { handlers } = eventHandler; - const clipChangedListener = () => { - this._entity.getComponents(Script, scripts); - const scriptCount = scripts.length; - const { events } = state.clip; - eventHandlers.length = 0; - for (let i = 0, n = events.length; i < n; i++) { - const event = events[i]; - const eventHandler = eventHandlerPool.get(); - const funcName = event.functionName; - const { handlers } = eventHandler; - - eventHandler.event = event; - handlers.length = 0; - for (let j = scriptCount - 1; j >= 0; j--) { - const script = scripts[j]; - const handler = script[funcName]?.bind(script); - handler && handlers.push(handler); - } - eventHandlers.push(eventHandler); + eventHandler.event = event; + handlers.length = 0; + for (let j = scriptCount - 1; j >= 0; j--) { + const script = scripts[j]; + const handler = script[funcName]?.bind(script); + handler && handlers.push(handler); } - }; - clipChangedListener(); - state._updateFlagManager.addListener(clipChangedListener); - animatorStateData.clipChangedListener = clipChangedListener; + eventHandlers.push(eventHandler); + } + animatorStateData.eventsBuiltVersion = clipFlag._version; } private _clearCrossData(animatorLayerData: AnimatorLayerData): void { diff --git a/packages/core/src/animation/AnimatorController.ts b/packages/core/src/animation/AnimatorController.ts index d3360a98aa..60bc439210 100644 --- a/packages/core/src/animation/AnimatorController.ts +++ b/packages/core/src/animation/AnimatorController.ts @@ -112,7 +112,6 @@ export class AnimatorController extends ReferResource { addLayer(layer: AnimatorControllerLayer): void { this._layers.push(layer); this._layersMap[layer.name] = layer; - layer._setController(this); layer._setEngine(this._engine); this._updateFlagManager.dispatch(); } @@ -146,20 +145,13 @@ export class AnimatorController extends ReferResource { return this._updateFlagManager.createFlag(BoolUpdateFlag); } - /** @internal */ - _dispatchUpdate(): void { - this._updateFlagManager.dispatch(); - } - /** * @internal */ _setEngine(engine: Engine): void { const { _layers: layers } = this; for (let i = 0, n = layers.length; i < n; i++) { - const layer = layers[i]; - layer._setController(this); - layer._setEngine(engine); + layers[i]._setEngine(engine); } } diff --git a/packages/core/src/animation/AnimatorControllerLayer.ts b/packages/core/src/animation/AnimatorControllerLayer.ts index 1eaa346aa0..135ac0204f 100644 --- a/packages/core/src/animation/AnimatorControllerLayer.ts +++ b/packages/core/src/animation/AnimatorControllerLayer.ts @@ -1,5 +1,4 @@ import { Engine } from "../Engine"; -import type { AnimatorController } from "./AnimatorController"; import { AnimatorStateMachine } from "./AnimatorStateMachine"; import { AnimatorLayerBlendingMode } from "./enums/AnimatorLayerBlendingMode"; import { AnimatorLayerMask } from "./AnimatorLayerMask"; @@ -30,9 +29,4 @@ export class AnimatorControllerLayer { _setEngine(engine: Engine): void { this.stateMachine._setEngine(engine); } - - /** @internal */ - _setController(controller: AnimatorController): void { - this.stateMachine._setController(controller); - } } diff --git a/packages/core/src/animation/AnimatorStateMachine.ts b/packages/core/src/animation/AnimatorStateMachine.ts index be2ac31798..e8125c7285 100644 --- a/packages/core/src/animation/AnimatorStateMachine.ts +++ b/packages/core/src/animation/AnimatorStateMachine.ts @@ -1,5 +1,4 @@ import { Engine } from "../Engine"; -import type { AnimatorController } from "./AnimatorController"; import { AnimatorState } from "./AnimatorState"; import { AnimatorStateTransition } from "./AnimatorStateTransition"; import { AnimatorStateTransitionCollection } from "./AnimatorStateTransitionCollection"; @@ -15,7 +14,6 @@ export class AnimatorStateMachine { readonly states: AnimatorState[] = []; private _engine: Engine; - private _controller: AnimatorController; /** * The state will be played automatically. @@ -55,7 +53,6 @@ export class AnimatorStateMachine { state._setEngine(this._engine); this.states.push(state); this._statesMap[name] = state; - this._controller?._dispatchUpdate(); } else { console.warn(`The state named ${name} has existed.`); } @@ -72,7 +69,6 @@ export class AnimatorStateMachine { if (index > -1) { this.states.splice(index, 1); delete this._statesMap[name]; - this._controller?._dispatchUpdate(); } } @@ -171,9 +167,4 @@ export class AnimatorStateMachine { states[i]._setEngine(engine); } } - - /** @internal */ - _setController(controller: AnimatorController): void { - this._controller = controller; - } } diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index 20ac83fff6..187b42f059 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -14,8 +14,8 @@ export class AnimatorLayerData { layerIndex: number; layer: AnimatorControllerLayer; curveOwnerPool: Record> = Object.create(null); - animatorStateDataMap: Record = Object.create(null); - instanceMap: Record = Object.create(null); + animatorStateDataMap: WeakMap = new WeakMap(); + instanceMap: WeakMap = new WeakMap(); srcPlayData: AnimatorStatePlayData | null = null; destPlayData: AnimatorStatePlayData | null = null; layerState: LayerState = LayerState.Standby; @@ -27,11 +27,10 @@ export class AnimatorLayerData { /** Lazy-create the per-Animator instance for a state. */ getOrCreateInstance(state: AnimatorState): AnimatorStateInstance { const map = this.instanceMap; - const name = state.name; - let instance = map[name]; + let instance = map.get(state); if (!instance) { instance = new AnimatorStateInstance(state); - map[name] = instance; + map.set(state, instance); } return instance; } diff --git a/packages/core/src/animation/internal/AnimatorStateData.ts b/packages/core/src/animation/internal/AnimatorStateData.ts index 45e2099eb0..1918760f2e 100644 --- a/packages/core/src/animation/internal/AnimatorStateData.ts +++ b/packages/core/src/animation/internal/AnimatorStateData.ts @@ -6,19 +6,10 @@ import { AnimationEventHandler } from "./AnimationEventHandler"; * @internal */ export class AnimatorStateData { - /** Listener registered on `state._updateFlagManager`; kept so dispose() can detach it. */ - clipChangedListener: (() => void) | null = null; curveLayerOwner: AnimationCurveLayerOwner[] = []; eventHandlers: AnimationEventHandler[] = []; + /** Snapshot of `state.clip._updateFlagManager._version` when eventHandlers were last built. */ + eventsBuiltVersion: number = -1; constructor(readonly state: AnimatorState) {} - - /** Detach the clipChangedListener from state's UpdateFlagManager. No-op if not attached. */ - dispose(): void { - const { clipChangedListener } = this; - if (clipChangedListener) { - this.state._updateFlagManager.removeListener(clipChangedListener); - this.clipChangedListener = null; - } - } } diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index fe52e4babd..fa1148177d 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1488,7 +1488,7 @@ describe("Animator test", function () { // @ts-ignore — internal layer data, verify cached state BEFORE second play to confirm stale. const layerDataBeforeSecondPlay = localAnimator._animatorLayersData[0]; - const stateDataBefore = layerDataBeforeSecondPlay.animatorStateDataMap["X"]; + 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); @@ -1496,7 +1496,7 @@ describe("Animator test", function () { // @ts-ignore — internal layer data const layerData = localAnimator._animatorLayersData[0]; - const stateData = layerData.animatorStateDataMap["X"]; + 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. @@ -1535,70 +1535,29 @@ describe("Animator test", function () { controller.removeLayer(controller.layers.indexOf(dummyLayer)); }); - it("destroy detaches stateData clipChangedListeners from surviving AnimatorState", () => { - // Build a controller whose AnimatorState we can keep alive after the animator is destroyed. - const controller = new AnimatorController(engine); - const layer = new AnimatorControllerLayer("layer"); - controller.addLayer(layer); - const sharedState = layer.stateMachine.addState("Y"); - const clip = new AnimationClip("yClip"); - const curve = new AnimationFloatCurve(); - const k1 = new Keyframe(); - k1.time = 0; - k1.value = 0; - const k2 = new Keyframe(); - k2.time = 1; - k2.value = 90; - curve.addKey(k1); - curve.addKey(k2); - clip.addCurveBinding("", Transform, "rotation.x", curve); - sharedState.clip = clip; - - // @ts-ignore — inspect listener attachment on the shared state directly. - const listenersBefore = sharedState._updateFlagManager._listeners.length; - - const localEntity = new Entity(engine); - const localAnimator = localEntity.addComponent(Animator); - localAnimator.animatorController = controller; - localAnimator.play("Y"); - // @ts-ignore - expect(sharedState._updateFlagManager._listeners.length).to.eq(listenersBefore + 1); - - // Destroying only the Animator (controller + state still alive) must - // detach the clipChangedListener it installed; otherwise the closure - // keeps a destroyed entity reachable through state.clip.dispatch(). - localAnimator.destroy(); - localEntity.destroy(); - - // @ts-ignore - expect(sharedState._updateFlagManager._listeners.length).to.eq(listenersBefore); - }); - - it("_reset detaches stateData clipChangedListeners so they do not accumulate on the AnimatorState", () => { + 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; - // @ts-ignore — read internal listener list size - const listenersBefore = surveyState._updateFlagManager._listeners.length; + 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); - // First play: registers one clipChangedListener for Survey on this layer. + 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 listenersAfterFirstPlay = surveyState._updateFlagManager._listeners.length; - expect(listenersAfterFirstPlay).to.eq(listenersBefore + 1); - - // Three controller mutations → three _reset() calls → without cleanup - // each reset would leave its prior listener attached and the next play - // would register a fresh one on top of it. - for (let i = 0; i < 3; i++) { - const dummy = new AnimatorControllerLayer(`__dummy_${i}__`); - animator.animatorController.addLayer(dummy); - animator.play("Survey"); - // @ts-ignore - const count = surveyState._updateFlagManager._listeners.length; - expect(count, `listener count after iteration ${i + 1}`).to.eq(listenersBefore + 1); - animator.animatorController.removeLayer(animator.animatorController.layers.indexOf(dummy)); - } + 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)", () => { From 4b4fe7f3bf85ccf9c23242eb588568b9378ff949 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 21:33:00 +0800 Subject: [PATCH 72/92] refactor(animation): drop unused event handler pool, rename to _ensureEventHandlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ClearableObjectPool for AnimationEventHandler couldn't actually reuse objects under our usage: pool is shared across stateData yet each stateData rebuilds independently — pool.clear() would alias live handlers across stateData, so we never called it; usedCount grew monotonically until _reset. Net effect: every rebuild allocated new objects and stranded the old ones in the pool. Inline `new` is simpler and equivalent in allocation count. Also rename `_ensureEventHandlersUpToDate` to `_ensureEventHandlers` — `ensure` already implies idempotent "make it correct". - drop _animationEventHandlerPool field and its clear() in _reset - drop ClearableObjectPool import - inline `new AnimationEventHandler()` in the rebuild loop - drop redundant `handlers.length = 0` (fresh instance starts empty) --- packages/core/src/animation/Animator.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index e46923e87e..17844369e5 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -6,7 +6,6 @@ import { Renderer } from "../Renderer"; import { Script } from "../Script"; import { Logger } from "../base/Logger"; import { assignmentClone, ignoreClone } from "../clone/CloneManager"; -import { ClearableObjectPool } from "../utils/ClearableObjectPool"; import { AnimatorController } from "./AnimatorController"; import { AnimatorControllerLayer } from "./AnimatorControllerLayer"; import { AnimatorControllerParameter, AnimatorControllerParameterValue } from "./AnimatorControllerParameter"; @@ -56,8 +55,6 @@ export class Animator extends Component { @ignoreClone private _curveOwnerPool: Record>> = Object.create(null); @ignoreClone - private _animationEventHandlerPool = new ClearableObjectPool(AnimationEventHandler); - @ignoreClone private _parametersValueMap = >Object.create(null); @ignoreClone @@ -327,7 +324,6 @@ export class Animator extends Component { this._animatorLayersData.length = 0; this._curveOwnerPool = Object.create(null); this._parametersValueMap = Object.create(null); - this._animationEventHandlerPool.clear(); if (this._controllerUpdateFlag) { this._controllerUpdateFlag.flag = false; @@ -422,7 +418,7 @@ export class Animator extends Component { animatorStateDataMap.set(animatorState, animatorStateData); this._saveAnimatorStateData(animatorState, animatorStateData, animatorLayerData, layerIndex); } - this._ensureEventHandlersUpToDate(animatorState, animatorStateData); + this._ensureEventHandlers(animatorState, animatorStateData); return animatorStateData; } @@ -479,13 +475,12 @@ export class Animator extends Component { } } - private _ensureEventHandlersUpToDate(state: AnimatorState, animatorStateData: AnimatorStateData): void { + private _ensureEventHandlers(state: AnimatorState, animatorStateData: AnimatorStateData): void { const clipFlag = state.clip._updateFlagManager; if (animatorStateData.eventsBuiltVersion === clipFlag._version) { return; } - const eventHandlerPool = this._animationEventHandlerPool; const scripts = []; this._entity.getComponents(Script, scripts); const scriptCount = scripts.length; @@ -494,12 +489,11 @@ export class Animator extends Component { eventHandlers.length = 0; for (let i = 0, n = events.length; i < n; i++) { const event = events[i]; - const eventHandler = eventHandlerPool.get(); + const eventHandler = new AnimationEventHandler(); const funcName = event.functionName; const { handlers } = eventHandler; eventHandler.event = event; - handlers.length = 0; for (let j = scriptCount - 1; j >= 0; j--) { const script = scripts[j]; const handler = script[funcName]?.bind(script); From efc428fd516d174c1b1b35e4835667b4af7218d8 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 21:40:47 +0800 Subject: [PATCH 73/92] style(animation): drop redundant self-rename in destructures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leftover from an earlier underscore-prefix → no-prefix field rename: the `_clipTime: clipTime` rename collapsed into `clipTime: clipTime`, which is just noise. Strip the redundant aliases. --- packages/core/src/animation/Animator.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 17844369e5..44e76e49cf 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -633,7 +633,7 @@ export class Animator extends Component { // Precalculate to get the transition srcPlayData.update(playDeltaTime); - const { clipTime: clipTime, isForward: isForward } = srcPlayData; + const { clipTime, isForward } = srcPlayData; const { _transitionCollection: transitions } = state; const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine; @@ -961,7 +961,7 @@ export class Animator extends Component { const state = destPlayData.instance._state; const { _curveBindings: curveBindings } = state.clip; - const { clipTime: destClipTime, playState: playState } = destPlayData; + const { clipTime: destClipTime, playState } = destPlayData; const finished = playState === AnimatorStatePlayState.Finished; // When the animator is culled (aniUpdate=false), if the play state has finished, the final value needs to be calculated and saved to be applied directly @@ -1002,7 +1002,7 @@ export class Animator extends Component { playData.updateOrientation(actualDeltaTime); - const { clipTime: clipTime, isForward: isForward } = playData; + const { clipTime, isForward } = playData; const { _transitionCollection: transitions } = state; const { _anyStateTransitionCollection: anyStateTransitions } = layerData.layer.stateMachine; From 4e7d18dc29c03944a027e757ce2f18173b8ba504 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 21:51:21 +0800 Subject: [PATCH 74/92] refactor(animation): collapse cross-fade slot reset into AnimatorLayerData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two cross-fade slot fields (destPlayData, crossFadeTransition) were managed by ad-hoc field assignments scattered across Animator. Pull both reset patterns onto AnimatorLayerData where the fields live: - completeCrossFade(): dest promoted to src, slot cleared (replaces promoteDest() + manual `crossFadeTransition = null`) - clearCrossFadeSlot(): slot discarded without promoting dest (replaces _preparePlay's two-line inline reset after a play() interrupts a fade) Also tighten the comment in _preparePlay — the guard that was being defeated is the active-dest check, not "self-target alias". --- packages/core/src/animation/Animator.ts | 10 ++++------ .../core/src/animation/internal/AnimatorLayerData.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index 44e76e49cf..a6c90618fd 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -1059,8 +1059,7 @@ export class Animator extends Component { } else { layerData.layerState = LayerState.Playing; } - layerData.promoteDest(); - layerData.crossFadeTransition = null; + layerData.completeCrossFade(); } private _preparePlayOwner(layerData: AnimatorLayerData, playState: AnimatorState): void { @@ -1322,10 +1321,9 @@ export class Animator extends Component { const playData = animatorLayerData.getOrCreateInstance(state)._playData; playData.resetForPlay(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); animatorLayerData.srcPlayData = playData; - // Clear any stale cross-fade slot from a previously-interrupted crossFade so - // subsequent crossFade() calls aren't no-op'd by the self-target alias guard. - animatorLayerData.destPlayData = null; - animatorLayerData.crossFadeTransition = null; + // 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.resetCurrentCheckIndex(); return true; diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index 187b42f059..a120fc4b9f 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -35,9 +35,17 @@ export class AnimatorLayerData { return instance; } - promoteDest(): void { + /** Cross-fade finished: dest becomes the new src, slot fields cleared. */ + completeCrossFade(): void { this.srcPlayData = this.destPlayData; this.destPlayData = null; + this.crossFadeTransition = null; + } + + /** Discard the cross-fade slot fields without promoting dest (e.g. play() interrupts a fade). */ + clearCrossFadeSlot(): void { + this.destPlayData = null; + this.crossFadeTransition = null; } resetCurrentCheckIndex(): void { From 04cb8049d54c628bf57f90b1d1416c212e3e2204 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 21:59:45 +0800 Subject: [PATCH 75/92] docs(animation): expand AnimatorStateInstance JSDoc Spell out the shared-asset + per-instance-override model on the class doc, and add per-getter doc for name/clip/clipStartTime/clipEndTime that were previously undocumented. Speed/wrapMode getters now state the read-through + isolated-write semantics explicitly. --- .../src/animation/AnimatorStateInstance.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/core/src/animation/AnimatorStateInstance.ts b/packages/core/src/animation/AnimatorStateInstance.ts index 7cc1c54f1f..d42f212a58 100644 --- a/packages/core/src/animation/AnimatorStateInstance.ts +++ b/packages/core/src/animation/AnimatorStateInstance.ts @@ -4,8 +4,12 @@ import { AnimatorStatePlayData } from "./internal/AnimatorStatePlayData"; import { WrapMode } from "./enums/WrapMode"; /** - * Per-Animator view of an `AnimatorState`. Overrides on this view only affect - * the owning Animator; other Animators using the same controller are unaffected. + * Per-Animator view of an `AnimatorState`. + * + * The state asset is shared across every Animator that references the same controller; + * this view is created lazily per (Animator, state) pair and lets a single Animator + * override playback fields (e.g. speed, wrapMode) without affecting other Animators + * sharing the same controller. Unset fields fall through to the underlying state asset. */ export class AnimatorStateInstance { /** @internal */ @@ -16,23 +20,32 @@ export class AnimatorStateInstance { private _speed: number | undefined; private _wrapMode: WrapMode | undefined; + /** Name of the underlying state. */ get name(): string { return this._state.name; } + /** Animation clip of the underlying state. */ get clip(): AnimationClip { return this._state.clip; } + /** Normalized clip start time of the underlying state. */ get clipStartTime(): number { return this._state.clipStartTime; } + /** Normalized clip end time of the underlying state. */ get clipEndTime(): number { return this._state.clipEndTime; } - /** Playback speed for this Animator. */ + /** + * Playback speed for this Animator. + * + * Reading returns the per-instance override if set, otherwise the underlying state's speed. + * Writing sets the override on this instance only; other Animators sharing the controller are unaffected. + */ get speed(): number { return this._speed ?? this._state.speed; } @@ -41,7 +54,12 @@ export class AnimatorStateInstance { this._speed = value; } - /** Wrap mode for this Animator. */ + /** + * Wrap mode for this Animator. + * + * Reading returns the per-instance override if set, otherwise the underlying state's wrapMode. + * Writing sets the override on this instance only; other Animators sharing the controller are unaffected. + */ get wrapMode(): WrapMode { return this._wrapMode ?? this._state.wrapMode; } From ca72f7f35232b2bc223047b64b4d2c9674be6232 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 22:00:18 +0800 Subject: [PATCH 76/92] style(animation): expand single-line getter JSDoc to multi-line --- .../core/src/animation/AnimatorStateInstance.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/core/src/animation/AnimatorStateInstance.ts b/packages/core/src/animation/AnimatorStateInstance.ts index d42f212a58..c966184c6a 100644 --- a/packages/core/src/animation/AnimatorStateInstance.ts +++ b/packages/core/src/animation/AnimatorStateInstance.ts @@ -20,22 +20,30 @@ export class AnimatorStateInstance { private _speed: number | undefined; private _wrapMode: WrapMode | undefined; - /** Name of the underlying state. */ + /** + * Name of the underlying state. + */ get name(): string { return this._state.name; } - /** Animation clip of the underlying state. */ + /** + * Animation clip of the underlying state. + */ get clip(): AnimationClip { return this._state.clip; } - /** Normalized clip start time of the underlying state. */ + /** + * Normalized clip start time of the underlying state. + */ get clipStartTime(): number { return this._state.clipStartTime; } - /** Normalized clip end time of the underlying state. */ + /** + * Normalized clip end time of the underlying state. + */ get clipEndTime(): number { return this._state.clipEndTime; } From faf95c62a6d91cb13acff30da6d108382390645e Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 22:08:47 +0800 Subject: [PATCH 77/92] fix(animation): clear defaultState on removeState to drop dangling ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `removeState` deleted the state from both `states` and `_statesMap` but left `defaultState` pointing at the removed state. The next implicit default-state play would then dispatch to a state the user already removed. `defaultState` is the user's explicit choice ("which state to play automatically"), not a fallback — auto-reselecting `states[0]` (Unity's editor-side behavior) would fabricate intent the user didn't express. Cleared to `null` instead; consumers in Animator already null-check the field, so the contract is now honored consistently. Also annotate the field type as `AnimatorState | null` (initialized to null) to make the nullability explicit to consumers. --- packages/core/src/animation/AnimatorStateMachine.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/animation/AnimatorStateMachine.ts b/packages/core/src/animation/AnimatorStateMachine.ts index e8125c7285..3200aab5df 100644 --- a/packages/core/src/animation/AnimatorStateMachine.ts +++ b/packages/core/src/animation/AnimatorStateMachine.ts @@ -17,9 +17,9 @@ export class AnimatorStateMachine { /** * The state will be played automatically. - * @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered. + * @remarks When the Animator's AnimatorController changed or the Animator's onEnable be triggered. Cleared to `null` if the state is removed via `removeState`. */ - defaultState: AnimatorState; + defaultState: AnimatorState | null = null; /** @internal */ _entryTransitionCollection = new AnimatorStateTransitionCollection(); @@ -69,6 +69,9 @@ export class AnimatorStateMachine { if (index > -1) { this.states.splice(index, 1); delete this._statesMap[name]; + if (this.defaultState === state) { + this.defaultState = null; + } } } From fb529bafb2ebae4a2f72a6f61ca697542b98cc3f Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 22:11:04 +0800 Subject: [PATCH 78/92] style(updateflagmanager): drop redundant prose comment on _version The field is internal (class is @internal, field is @internal, _ prefix); the prose comment above it sat as a second adjacent JSDoc block, which doesn't get attached to the field by TS tooling and duplicated info that is now obvious from the single call-site in _ensureEventHandlers. --- packages/core/src/UpdateFlagManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/UpdateFlagManager.ts b/packages/core/src/UpdateFlagManager.ts index 0513710e25..4f8cc9aff1 100644 --- a/packages/core/src/UpdateFlagManager.ts +++ b/packages/core/src/UpdateFlagManager.ts @@ -7,7 +7,6 @@ import { Utils } from "./Utils"; export class UpdateFlagManager { /** @internal */ _updateFlags: UpdateFlag[] = []; - /** Monotonic counter; bumped on every `dispatch`. Lets consumers do lazy pull-style invalidation without registering a listener. */ /** @internal */ _version: number = 0; From e6b861f01807b905fa3fd1c18709fe235ad9694c Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 22:12:04 +0800 Subject: [PATCH 79/92] style(animatorstatedata): drop redundant prose comment on eventsBuiltVersion Internal class + self-describing field name; sibling fields are all plain-declared. The prose just restated what the call site in _ensureEventHandlers already makes obvious. --- packages/core/src/animation/internal/AnimatorStateData.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/animation/internal/AnimatorStateData.ts b/packages/core/src/animation/internal/AnimatorStateData.ts index 1918760f2e..0c8ba7c2d9 100644 --- a/packages/core/src/animation/internal/AnimatorStateData.ts +++ b/packages/core/src/animation/internal/AnimatorStateData.ts @@ -8,7 +8,6 @@ import { AnimationEventHandler } from "./AnimationEventHandler"; export class AnimatorStateData { curveLayerOwner: AnimationCurveLayerOwner[] = []; eventHandlers: AnimationEventHandler[] = []; - /** Snapshot of `state.clip._updateFlagManager._version` when eventHandlers were last built. */ eventsBuiltVersion: number = -1; constructor(readonly state: AnimatorState) {} From 04009e06efd6feb597b8b36d312519966f006ac8 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 22:18:51 +0800 Subject: [PATCH 80/92] refactor(animation): revert AnimatorStatePlayData.resetForPlay to reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier rename and accompanying note ("Per-instance overrides are preserved") suggested this method preserves overrides as a feature. But overrides live on AnimatorStateInstance, not PlayData — this method physically can't touch them. The "ForPlay" qualifier added no disambiguation either: both call sites are obvious play-entry paths. Restoring the conventional `reset` name and dropping the misleading note. --- packages/core/src/animation/Animator.ts | 4 ++-- packages/core/src/animation/internal/AnimatorStatePlayData.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index a6c90618fd..a7fa83770d 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -1319,7 +1319,7 @@ export class Animator extends Component { animatorLayerData.layerState = LayerState.Playing; const playData = animatorLayerData.getOrCreateInstance(state)._playData; - playData.resetForPlay(animatorStateData, state._getClipActualEndTime() * normalizedTimeOffset); + 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. @@ -1436,7 +1436,7 @@ export class Animator extends Component { const animatorStateData = this._getAnimatorStateData(crossState.name, crossState, animatorLayerData, layerIndex); const destPlayData = animatorLayerData.getOrCreateInstance(crossState)._playData; - destPlayData.resetForPlay(animatorStateData, transition.offset * crossState._getClipActualEndTime()); + destPlayData.reset(animatorStateData, transition.offset * crossState._getClipActualEndTime()); animatorLayerData.destPlayData = destPlayData; animatorLayerData.resetCurrentCheckIndex(); diff --git a/packages/core/src/animation/internal/AnimatorStatePlayData.ts b/packages/core/src/animation/internal/AnimatorStatePlayData.ts index e9c0b2e2ac..a4f2c80142 100644 --- a/packages/core/src/animation/internal/AnimatorStatePlayData.ts +++ b/packages/core/src/animation/internal/AnimatorStatePlayData.ts @@ -20,8 +20,7 @@ export class AnimatorStatePlayData { constructor(public readonly instance: AnimatorStateInstance) {} - /** Reset playback fields on (re-)enter. Per-instance overrides are preserved. */ - resetForPlay(stateData: AnimatorStateData, offsetFrameTime: number): void { + reset(stateData: AnimatorStateData, offsetFrameTime: number): void { const state = this.instance._state; this.stateData = stateData; this.offsetFrameTime = offsetFrameTime; From 8e1c13152b2e940c30423d94088ff5f5b21535fa Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 22:21:43 +0800 Subject: [PATCH 81/92] style(animatorlayerdata): drop redundant method JSDoc Internal class, methods are 3-4 lines, names self-describe (getOrCreateInstance/completeCrossFade/clearCrossFadeSlot). The comments only restated what name + body already convey. Contextual "why is this called here" notes belong at the call sites, not on the methods themselves. --- packages/core/src/animation/internal/AnimatorLayerData.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/src/animation/internal/AnimatorLayerData.ts b/packages/core/src/animation/internal/AnimatorLayerData.ts index a120fc4b9f..a3b2677c0b 100644 --- a/packages/core/src/animation/internal/AnimatorLayerData.ts +++ b/packages/core/src/animation/internal/AnimatorLayerData.ts @@ -24,7 +24,6 @@ export class AnimatorLayerData { crossFadeTransition: AnimatorStateTransition; crossLayerOwnerCollection: AnimationCurveLayerOwner[] = []; - /** Lazy-create the per-Animator instance for a state. */ getOrCreateInstance(state: AnimatorState): AnimatorStateInstance { const map = this.instanceMap; let instance = map.get(state); @@ -35,14 +34,12 @@ export class AnimatorLayerData { return instance; } - /** Cross-fade finished: dest becomes the new src, slot fields cleared. */ completeCrossFade(): void { this.srcPlayData = this.destPlayData; this.destPlayData = null; this.crossFadeTransition = null; } - /** Discard the cross-fade slot fields without promoting dest (e.g. play() interrupts a fade). */ clearCrossFadeSlot(): void { this.destPlayData = null; this.crossFadeTransition = null; From f167d01f2697e6f1f803efeb53b471e3b06b00e6 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 22:56:11 +0800 Subject: [PATCH 82/92] fix(animation, loader): test assertion + getCurrentAnimatorState reset + skin index guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests: "find animator state" was comparing `(instance as any)._state` (AnimatorState) against `getCurrentAnimatorState` (AnimatorStateInstance), which can never be equal. Also fix the layerIndex source — it was read from `_tempAnimatorStateInfo` *before* play() populated it, so it was -1 instead of 0. - Animator.getCurrentAnimatorState now calls _resetIfControllerUpdated() so it doesn't return a stale instance after a controller mutation, consistent with play/crossFade/findAnimatorState/update. - Add @remarks on both getCurrentAnimatorState and findAnimatorState spelling out that the returned instance is invalidated by controller structure changes (layers added/removed) and must be re-fetched. - GLTFSkinParser: when `skin.skeleton` is an out-of-range index, throw a precise error instead of silently assigning `undefined` to `skin.rootBone` (which surfaces as a confusing error later). --- packages/core/src/animation/Animator.ts | 3 +++ packages/loader/src/gltf/parser/GLTFSkinParser.ts | 6 +++++- tests/src/core/Animator.test.ts | 8 ++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index a7fa83770d..b0edeb2f7e 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -203,8 +203,10 @@ export class Animator extends Component { * Get the state instance currently playing on the target layer. * @param layerIndex - The layer index * @returns The state instance, or null if nothing is playing + * @remarks The returned instance is tied to the current controller's layer data. After a controller structure change (layers added or removed), the instance is invalidated; re-call this method to get a fresh one. */ getCurrentAnimatorState(layerIndex: number): AnimatorStateInstance | null { + this._resetIfControllerUpdated(); return this._animatorLayersData[layerIndex]?.srcPlayData?.instance ?? null; } @@ -214,6 +216,7 @@ export class Animator extends Component { * @param stateName - The state name * @param layerIndex - The layer index (default -1, searches all layers) * @returns The state instance, or null if no state matches + * @remarks The returned instance is tied to the current controller's layer data. After a controller structure change (layers added or removed), the instance is invalidated; re-call this method to get a fresh one. */ findAnimatorState(stateName: string, layerIndex: number = -1): AnimatorStateInstance | null { this._resetIfControllerUpdated(); diff --git a/packages/loader/src/gltf/parser/GLTFSkinParser.ts b/packages/loader/src/gltf/parser/GLTFSkinParser.ts index ed67758c83..e8959b4b70 100644 --- a/packages/loader/src/gltf/parser/GLTFSkinParser.ts +++ b/packages/loader/src/gltf/parser/GLTFSkinParser.ts @@ -37,7 +37,11 @@ export class GLTFSkinParser extends GLTFParser { // Get skeleton — when `skin.skeleton` is absent, resolve via joints' LCA // LCA falls back to the GLTF_ROOT wrapper only when joints span multiple top-level scene nodes if (skeleton !== undefined) { - skin.rootBone = entities[skeleton]; + const rootBone = entities[skeleton]; + if (!rootBone) { + throw `Skin skeleton index ${skeleton} is out of range.`; + } + skin.rootBone = rootBone; } else { const rootBone = this._findSkeletonRootBone(joints, entities); if (!rootBone) { diff --git a/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index fa1148177d..67db02755d 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -197,17 +197,17 @@ describe("Animator test", function () { it("find animator state", () => { const stateName = "Survey"; const expectedStateName = "Run"; - const layerIndex = animator["_tempAnimatorStateInfo"].layerIndex; animator.play(stateName); + const layerIndex = animator["_tempAnimatorStateInfo"].layerIndex; const currentAnimatorState = animator.getCurrentAnimatorState(layerIndex); let animatorState = animator.findAnimatorState(stateName, layerIndex); - expect((animatorState as any)._state).to.eq(currentAnimatorState); + expect(animatorState).to.eq(currentAnimatorState); animator.play(expectedStateName); animatorState = animator.findAnimatorState(expectedStateName, layerIndex); - expect((animatorState as any)._state).not.to.eq(currentAnimatorState); - expect((animatorState as any)._state.name).to.eq(expectedStateName); + expect(animatorState).not.to.eq(currentAnimatorState); + expect(animatorState?.name).to.eq(expectedStateName); }); it("animation getCurrentAnimatorState", () => { From 7b52bfc81ce357fd7627c6747bd92c8b9b3623ce Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Fri, 15 May 2026 23:01:50 +0800 Subject: [PATCH 83/92] fix(animation): ensure event handlers in update path; fix stale test field refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues surfaced when running the full Animator suite: 1. animation event test fails — the lazy `_ensureEventHandlers` was only invoked at play()/crossFade() entry, so a clip.addEvent() after play() didn't rebuild handlers on the next update. Move the ensure call into `_fireAnimationEventsAndCallScripts` so update-path re-checks the clip version too. 2. Three tests still reference fields that moved during the refactor: - `srcPlayData.speed` — speed is now on the instance, change to `srcPlayData.instance.speed` - `layerData.destPlayData?.state.name` (two sites) — PlayData no longer has `state`, change to `.instance.name` All 52 Animator tests pass after this commit. --- packages/core/src/animation/Animator.ts | 4 ++++ tests/src/core/Animator.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts index b0edeb2f7e..7a6e83a52c 100644 --- a/packages/core/src/animation/Animator.ts +++ b/packages/core/src/animation/Animator.ts @@ -1588,6 +1588,10 @@ 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/tests/src/core/Animator.test.ts b/tests/src/core/Animator.test.ts index 67db02755d..4b2b028465 100644 --- a/tests/src/core/Animator.test.ts +++ b/tests/src/core/Animator.test.ts @@ -1362,7 +1362,7 @@ describe("Animator test", function () { 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.speed).to.eq(0.5); + expect(srcPlayData.instance.speed).to.eq(0.5); }); it("per-instance speed is per-Animator (clone isolation)", () => { @@ -1658,7 +1658,7 @@ describe("Animator test", function () { // 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?.state.name).to.eq("Run"); + expect(layerData.destPlayData?.instance.name).to.eq("Run"); }); it("crossFade to nonexistent state is a safe no-op", () => { @@ -1749,7 +1749,7 @@ describe("Animator test", function () { 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?.state.name).to.eq("Walk"); + 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); From 7394e3240c3e268121187f7d57d67404e1486478 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Sat, 16 May 2026 11:57:33 +0800 Subject: [PATCH 84/92] fix(e2e): write clipStartTime/clipEndTime on the AnimatorState asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getCurrentAnimatorState` now returns the per-Animator view (`AnimatorStateInstance`), which only exposes getters for these clip-framing fields. Writing on the view was a silent no-op, so the blendShape e2e never actually clamped the clip range — page timed out waiting for the expected frame. The intent is to freeze playback at frame 1, which is shared-state behavior (every Animator using this controller would freeze the same way). Write directly on the AnimatorState asset. --- e2e/case/gltf-blendshape.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/e2e/case/gltf-blendshape.ts b/e2e/case/gltf-blendshape.ts index 85f3c72b72..5d4c1bde89 100644 --- a/e2e/case/gltf-blendshape.ts +++ b/e2e/case/gltf-blendshape.ts @@ -45,13 +45,14 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => { rootEntity.addChild(defaultSceneRoot); const animator = defaultSceneRoot.getComponent(Animator)!; - animator.play("Right"); - const state = animator.getCurrentAnimatorState(0); + // clipStartTime/clipEndTime are on the shared AnimatorState asset, not the per-Animator instance view. + const state = animator.animatorController.layers[0].stateMachine.findStateByName("Right"); state.clipStartTime = 1; state.clipEndTime = 1; + animator.play("Right"); updateForE2E(engine); initScreenshot(engine, camera); }); -}); \ No newline at end of file +}); From 524e8f81fff2dd516b90b3836a49b79486632691 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Sat, 16 May 2026 20:50:56 +0800 Subject: [PATCH 85/92] fix(animation): cover script-add and clip-swap invalidation paths for eventHandlers cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two corner-case rebuild misses in `_ensureEventHandlers`. 1. Script-add after play() Lazy cache only compared clip._updateFlagManager._version. addComponent(Script) on the Animator's entity does not bump any clip flag, so a script attached after play() never received existing clip events. Fix: Entity owns a `_scriptsVersion` counter bumped on _addScript/_removeScript; AnimatorStateData snapshots `eventsBuiltScriptsVersion`; rebuild on mismatch. 2. Clip swap with coincidental matching version `state.clip = newClip` already dispatches `state._updateFlagManager` (via _onClipChanged) and clip-internal addEvent/clearEvents bubbles through the same path. Switch the cache key from `clip._updateFlagManager.version` to `state._updateFlagManager.version` — state is the funnel for both clip-swap and clip-events-mutation, so a single version covers every input that affects eventHandlers binding. Also: - Promote `UpdateFlagManager._version` to a documented public `version` field (the class is @internal, so this stays engine-internal). - afterEach cleans clip events to keep test isolation. - Two red tests: scripts-added-after-play, and clip-swap-rebuilds-handlers. --- packages/core/src/Entity.ts | 4 ++ packages/core/src/UpdateFlagManager.ts | 8 +-- packages/core/src/animation/Animator.ts | 13 +++- .../animation/internal/AnimatorStateData.ts | 3 +- tests/src/core/Animator.test.ts | 61 +++++++++++++++++++ 5 files changed, 81 insertions(+), 8 deletions(-) diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index 010931a74f..063fbe1c1c 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -105,6 +105,8 @@ export class Entity extends EngineObject { /** @internal */ _scripts: DisorderedArray