From 38ddf12fa329400032a47e51693c2a9b45e0f230 Mon Sep 17 00:00:00 2001 From: "B. Zaky" Date: Sun, 4 Oct 2026 00:51:48 +0200 Subject: [PATCH 1/7] Fix NetworkAnimator conditional exit transitions --- .../Runtime/Components/NetworkAnimator.cs | 96 +++++++++-- .../Editor/NetworkAnimatorEditorTests.cs | 153 ++++++++++++++++++ .../Editor/NetworkAnimatorEditorTests.cs.meta | 3 + 3 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs index bf275ab3c3..9542e790a5 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs @@ -323,7 +323,73 @@ internal class AnimatorParametersListContainer private readonly Dictionary m_ParameterToNameLookup = new Dictionary(); - private void ParseStateMachineStates(int layerIndex, ref AnimatorController animatorController, ref AnimatorStateMachine stateMachine) + private void AddTransitionStateInfo(int layerIndex, AnimatorState originatingState, AnimatorState destinationState, AnimatorStateTransition transition, AnimatorControllerParameter parameter, int transitionIndex) + { + var transitionInfo = new TransitionStateInfo() + { + Layer = layerIndex, + OriginatingState = originatingState.nameHash, + DestinationState = destinationState.nameHash, + TransitionDuration = transition.duration, + TriggerNameHash = parameter.nameHash, + TransitionIndex = transitionIndex + }; + TransitionStateInfoList.Add(transitionInfo); + } + + private void AddStateMachineDestinations(int layerIndex, AnimatorState originatingState, AnimatorStateMachine destinationStateMachine, AnimatorStateTransition transition, + AnimatorControllerParameter parameter, int transitionIndex, HashSet visitedStateMachines) + { + if (!visitedStateMachines.Add(destinationStateMachine)) + { + return; + } + + if (destinationStateMachine.defaultState != null) + { + AddTransitionStateInfo(layerIndex, originatingState, destinationStateMachine.defaultState, transition, parameter, transitionIndex); + } + + foreach (var entryTransition in destinationStateMachine.entryTransitions) + { + if (entryTransition.destinationState != null) + { + AddTransitionStateInfo(layerIndex, originatingState, entryTransition.destinationState, transition, parameter, transitionIndex); + } + else if (entryTransition.destinationStateMachine != null) + { + AddStateMachineDestinations(layerIndex, originatingState, entryTransition.destinationStateMachine, transition, parameter, transitionIndex, visitedStateMachines); + } + } + } + + private void AddExitTransitionStateInfo(int layerIndex, AnimatorState originatingState, AnimatorStateMachine exitingStateMachine, AnimatorStateTransition transition, + AnimatorControllerParameter parameter, int transitionIndex, List parentStateMachines, int parentIndex) + { + if (parentIndex < 0) + { + return; + } + + var parentStateMachine = parentStateMachines[parentIndex]; + foreach (var stateMachineTransition in parentStateMachine.GetStateMachineTransitions(exitingStateMachine)) + { + if (stateMachineTransition.destinationState != null) + { + AddTransitionStateInfo(layerIndex, originatingState, stateMachineTransition.destinationState, transition, parameter, transitionIndex); + } + else if (stateMachineTransition.destinationStateMachine != null) + { + AddStateMachineDestinations(layerIndex, originatingState, stateMachineTransition.destinationStateMachine, transition, parameter, transitionIndex, new HashSet()); + } + else if (stateMachineTransition.isExit) + { + AddExitTransitionStateInfo(layerIndex, originatingState, parentStateMachine, transition, parameter, transitionIndex, parentStateMachines, parentIndex - 1); + } + } + } + + private void ParseStateMachineStates(int layerIndex, ref AnimatorController animatorController, ref AnimatorStateMachine stateMachine, List parentStateMachines = null) { for (int y = 0; y < stateMachine.states.Length; y++) { @@ -355,25 +421,15 @@ private void ParseStateMachineStates(int layerIndex, ref AnimatorController anim { case AnimatorControllerParameterType.Trigger: { - if (transition.destinationStateMachine != null) + if (transition.destinationState != null) { - var destinationStateMachine = transition.destinationStateMachine; - ParseStateMachineStates(layerIndex, ref animatorController, ref destinationStateMachine); + AddTransitionStateInfo(layerIndex, animatorState, transition.destinationState, transition, parameter, z); } - else if (transition.destinationState != null) + else if (transition.isExit) { - var transitionInfo = new TransitionStateInfo() - { - Layer = layerIndex, - OriginatingState = animatorState.nameHash, - DestinationState = transition.destinationState.nameHash, - TransitionDuration = transition.duration, - TriggerNameHash = parameter.nameHash, - TransitionIndex = z - }; - TransitionStateInfoList.Add(transitionInfo); + AddExitTransitionStateInfo(layerIndex, animatorState, stateMachine, transition, parameter, z, parentStateMachines, parentStateMachines != null ? parentStateMachines.Count - 1 : -1); } - else + else if (transition.destinationStateMachine == null) { Debug.LogError($"[{name}][Conditional Transition for {animatorState.name}] Conditional triggered transition has neither a DestinationState nor a DestinationStateMachine! This transition is not likely to synchronize properly. " + $"Please file a GitHub issue about this error with details about your Animator's setup."); @@ -387,6 +443,14 @@ private void ParseStateMachineStates(int layerIndex, ref AnimatorController anim } } } + + var childStateMachineParents = parentStateMachines != null ? new List(parentStateMachines) : new List(); + childStateMachineParents.Add(stateMachine); + foreach (var childStateMachine in stateMachine.stateMachines) + { + var nestedStateMachine = childStateMachine.stateMachine; + ParseStateMachineStates(layerIndex, ref animatorController, ref nestedStateMachine, childStateMachineParents); + } } /// diff --git a/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs new file mode 100644 index 0000000000..9cdcd449ec --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs @@ -0,0 +1,153 @@ +using NUnit.Framework; +using Unity.Netcode.Components; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; + +namespace Unity.Netcode.GameObjects.EditorTests +{ + internal class NetworkAnimatorEditorTests + { + private const string k_TestControllerPath = "Assets/NetworkAnimatorConditionalExitTest.controller"; + + private GameObject m_GameObject; + + [TearDown] + public void TearDown() + { + if (m_GameObject != null) + { + Object.DestroyImmediate(m_GameObject); + } + + AssetDatabase.DeleteAsset(k_TestControllerPath); + } + + [Test] + public void ConditionalExitTransitionResolvesParentDestination() + { + var controller = AnimatorController.CreateAnimatorControllerAtPath(k_TestControllerPath); + controller.AddParameter("ExitSubMachine", AnimatorControllerParameterType.Trigger); + + var rootStateMachine = controller.layers[0].stateMachine; + var destinationState = rootStateMachine.AddState("State B"); + var subStateMachine = rootStateMachine.AddStateMachine("Sub SM"); + var originatingState = subStateMachine.AddState("State A"); + subStateMachine.defaultState = originatingState; + + rootStateMachine.AddEntryTransition(subStateMachine); + + var exitTransition = originatingState.AddExitTransition(); + exitTransition.hasExitTime = false; + exitTransition.duration = 0.25f; + exitTransition.AddCondition(AnimatorConditionMode.If, 0.0f, "ExitSubMachine"); + + var parentTransition = rootStateMachine.AddStateMachineTransition(subStateMachine, destinationState); + parentTransition.AddCondition(AnimatorConditionMode.If, 0.0f, "ExitSubMachine"); + AssetDatabase.SaveAssets(); + + var networkAnimator = CreateNetworkAnimator(controller, nameof(ConditionalExitTransitionResolvesParentDestination)); + networkAnimator.InvokeOnValidate(); + + Assert.That(networkAnimator.TransitionStateInfoList, Has.Count.EqualTo(1)); + var transitionInfo = networkAnimator.TransitionStateInfoList[0]; + Assert.That(transitionInfo.Layer, Is.Zero); + Assert.That(transitionInfo.OriginatingState, Is.EqualTo(originatingState.nameHash)); + Assert.That(transitionInfo.DestinationState, Is.EqualTo(destinationState.nameHash)); + Assert.That(transitionInfo.TransitionDuration, Is.EqualTo(exitTransition.duration)); + Assert.That(transitionInfo.TriggerNameHash, Is.EqualTo(Animator.StringToHash("ExitSubMachine"))); + } + + [Test] + public void NestedConditionalExitTransitionResolvesAncestorDestination() + { + var controller = AnimatorController.CreateAnimatorControllerAtPath(k_TestControllerPath); + controller.AddParameter("ExitNested", AnimatorControllerParameterType.Trigger); + + var rootStateMachine = controller.layers[0].stateMachine; + var destinationState = rootStateMachine.AddState("State B"); + var outerStateMachine = rootStateMachine.AddStateMachine("Outer SM"); + var innerStateMachine = outerStateMachine.AddStateMachine("Inner SM"); + var originatingState = innerStateMachine.AddState("State A"); + innerStateMachine.defaultState = originatingState; + + rootStateMachine.AddEntryTransition(outerStateMachine); + outerStateMachine.AddEntryTransition(innerStateMachine); + + var exitTransition = originatingState.AddExitTransition(); + exitTransition.hasExitTime = false; + exitTransition.duration = 0.5f; + exitTransition.AddCondition(AnimatorConditionMode.If, 0.0f, "ExitNested"); + + var exitInnerTransition = outerStateMachine.AddStateMachineExitTransition(innerStateMachine); + exitInnerTransition.AddCondition(AnimatorConditionMode.If, 0.0f, "ExitNested"); + var exitOuterTransition = rootStateMachine.AddStateMachineTransition(outerStateMachine, destinationState); + exitOuterTransition.AddCondition(AnimatorConditionMode.If, 0.0f, "ExitNested"); + AssetDatabase.SaveAssets(); + + var networkAnimator = CreateNetworkAnimator(controller, nameof(NestedConditionalExitTransitionResolvesAncestorDestination)); + networkAnimator.InvokeOnValidate(); + + Assert.That(networkAnimator.TransitionStateInfoList, Has.Count.EqualTo(1)); + var transitionInfo = networkAnimator.TransitionStateInfoList[0]; + Assert.That(transitionInfo.OriginatingState, Is.EqualTo(originatingState.nameHash)); + Assert.That(transitionInfo.DestinationState, Is.EqualTo(destinationState.nameHash)); + Assert.That(transitionInfo.TransitionDuration, Is.EqualTo(exitTransition.duration)); + } + + [Test] + public void ConditionalExitTransitionResolvesDestinationStateMachineStates() + { + var controller = AnimatorController.CreateAnimatorControllerAtPath(k_TestControllerPath); + controller.AddParameter("ExitSubMachine", AnimatorControllerParameterType.Trigger); + + var rootStateMachine = controller.layers[0].stateMachine; + var sourceStateMachine = rootStateMachine.AddStateMachine("Source SM"); + var originatingState = sourceStateMachine.AddState("State A"); + sourceStateMachine.defaultState = originatingState; + rootStateMachine.AddEntryTransition(sourceStateMachine); + + var destinationStateMachine = rootStateMachine.AddStateMachine("Destination SM"); + var defaultDestinationState = destinationStateMachine.AddState("Default State"); + var entryDestinationState = destinationStateMachine.AddState("Entry State"); + destinationStateMachine.defaultState = defaultDestinationState; + destinationStateMachine.AddEntryTransition(entryDestinationState); + + var exitTransition = originatingState.AddExitTransition(); + exitTransition.hasExitTime = false; + exitTransition.duration = 0.25f; + exitTransition.AddCondition(AnimatorConditionMode.If, 0.0f, "ExitSubMachine"); + + var parentTransition = rootStateMachine.AddStateMachineTransition(sourceStateMachine, destinationStateMachine); + parentTransition.AddCondition(AnimatorConditionMode.If, 0.0f, "ExitSubMachine"); + AssetDatabase.SaveAssets(); + + var networkAnimator = CreateNetworkAnimator(controller, nameof(ConditionalExitTransitionResolvesDestinationStateMachineStates)); + networkAnimator.InvokeOnValidate(); + + Assert.That(networkAnimator.TransitionStateInfoList, Has.Count.EqualTo(2)); + Assert.That(networkAnimator.TransitionStateInfoList.Exists(entry => entry.DestinationState == defaultDestinationState.nameHash), Is.True); + Assert.That(networkAnimator.TransitionStateInfoList.Exists(entry => entry.DestinationState == entryDestinationState.nameHash), Is.True); + } + + private TestNetworkAnimator CreateNetworkAnimator(AnimatorController controller, string name) + { + m_GameObject = new GameObject(name); + m_GameObject.AddComponent(); + var animator = m_GameObject.AddComponent(); + animator.runtimeAnimatorController = controller; + var networkAnimator = m_GameObject.AddComponent(); + networkAnimator.Animator = animator; + networkAnimator.AnimatorParameterEntries = new NetworkAnimator.AnimatorParametersListContainer(); + return networkAnimator; + } + + private class TestNetworkAnimator : NetworkAnimator + { + public void InvokeOnValidate() + { + base.OnValidate(); + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs.meta new file mode 100644 index 0000000000..8ff8606f4c --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7df48e8f74684d8b8d0c650fdfb9fa1a +timeCreated: 1790424000 From 0f9f9bf920bf7cba9d143b14181e6bb7235916a7 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 3 Nov 2026 10:30:22 -0600 Subject: [PATCH 2/7] fix: resolve NetworkAnimator root and parent Entry exits, match lookup by originating state - Exit from the root state machine, or from a sub-state machine with no outgoing transition, resolves through the Entry node. - Trigger transitions into a sub-state machine resolve to its default and entry states. - The late-join lookup keeps every transition into a destination and picks the one from the current originating state. - Editor tests are guarded by COM_UNITY_MODULES_ANIMATION, with the define added to the test asmdef. - CHANGELOG entry. --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 + .../Runtime/Components/NetworkAnimator.cs | 64 ++++++++-- .../Editor/NetworkAnimatorEditorTests.cs | 112 ++++++++++++++++++ .../Editor/Unity.Netcode.Editor.Tests.asmdef | 5 + 4 files changed, 172 insertions(+), 10 deletions(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 66fef4f4fd..6a3918c1c8 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -22,6 +22,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed +- Issue where `NetworkAnimator` logged an error for a trigger-conditioned transition into an Exit node, and a client that joined during that transition, or during one into a sub-state machine, did not synchronize with it. Transitions that pass through Exit, Entry or sub-state machine nodes now resolve to the state they reach, and a late-joining client cross-fades from the matching originating state when several states transition into the same destination. - Issue where a client promoted to session owner could throw an exception when synchronizing a newly joining client. (#4217) - Issue where a growable `FastBufferWriter` created with a size of 0 never returned from its first write or `TryBeginWrite` call that needed the buffer to grow. (#4223) - Issue where a `FastBufferReader` created with an offset and the default length of -1 read past the end of its source. The `ArraySegment` overload that takes an offset now applies it from the start of the segment. (#4221) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs index 9542e790a5..5d515d7c11 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs @@ -272,8 +272,8 @@ public Animator Animator internal List TransitionStateInfoList; // Used to get the associated transition information required to synchronize late joining clients with transitions - // [Layer][DestinationState][TransitionStateInfo] - private readonly Dictionary> m_DestinationStateToTransitionInfo = new Dictionary>(); + // [Layer][DestinationState][TransitionStateInfo per originating state] + private readonly Dictionary>> m_DestinationStateToTransitionInfo = new Dictionary>>(); // Named differently to avoid serialization conflicts with NetworkBehaviour internal NetworkManager LocalNetworkManager; @@ -285,14 +285,44 @@ public Animator Animator /// private void BuildDestinationToTransitionInfoTable() { + m_DestinationStateToTransitionInfo.Clear(); foreach (var entry in TransitionStateInfoList) { - m_DestinationStateToTransitionInfo.TryAdd(entry.Layer, new Dictionary()); + m_DestinationStateToTransitionInfo.TryAdd(entry.Layer, new Dictionary>()); var destinationStateTransitionInfo = m_DestinationStateToTransitionInfo[entry.Layer]; - destinationStateTransitionInfo.TryAdd(entry.DestinationState, entry); + if (!destinationStateTransitionInfo.TryGetValue(entry.DestinationState, out var transitions)) + { + transitions = new List(); + destinationStateTransitionInfo.Add(entry.DestinationState, transitions); + } + transitions.Add(entry); } } + /// + /// Gets the transition info for a destination state, preferring the entry that starts from the originating state + /// since several states can transition into the same destination. + /// + internal bool TryGetTransitionInfo(int layer, int destinationState, int originatingState, out TransitionStateInfo transitionInfo) + { + transitionInfo = null; + if (!m_DestinationStateToTransitionInfo.TryGetValue(layer, out var layerTransitions) || !layerTransitions.TryGetValue(destinationState, out var transitions)) + { + return false; + } + + transitionInfo = transitions[0]; + foreach (var entry in transitions) + { + if (entry.OriginatingState == originatingState) + { + transitionInfo = entry; + break; + } + } + return true; + } + [Serializable] internal class AnimatorParameterEntry { @@ -366,13 +396,23 @@ private void AddStateMachineDestinations(int layerIndex, AnimatorState originati private void AddExitTransitionStateInfo(int layerIndex, AnimatorState originatingState, AnimatorStateMachine exitingStateMachine, AnimatorStateTransition transition, AnimatorControllerParameter parameter, int transitionIndex, List parentStateMachines, int parentIndex) { + // Exiting the layer's root state machine re-enters it through its Entry node if (parentIndex < 0) { + AddStateMachineDestinations(layerIndex, originatingState, exitingStateMachine, transition, parameter, transitionIndex, new HashSet()); return; } var parentStateMachine = parentStateMachines[parentIndex]; - foreach (var stateMachineTransition in parentStateMachine.GetStateMachineTransitions(exitingStateMachine)) + var stateMachineTransitions = parentStateMachine.GetStateMachineTransitions(exitingStateMachine); + // With no transition out of the sub-state machine, its Exit goes through the parent's Entry node + if (stateMachineTransitions.Length == 0) + { + AddStateMachineDestinations(layerIndex, originatingState, parentStateMachine, transition, parameter, transitionIndex, new HashSet()); + return; + } + + foreach (var stateMachineTransition in stateMachineTransitions) { if (stateMachineTransition.destinationState != null) { @@ -429,7 +469,11 @@ private void ParseStateMachineStates(int layerIndex, ref AnimatorController anim { AddExitTransitionStateInfo(layerIndex, animatorState, stateMachine, transition, parameter, z, parentStateMachines, parentStateMachines != null ? parentStateMachines.Count - 1 : -1); } - else if (transition.destinationStateMachine == null) + else if (transition.destinationStateMachine != null) + { + AddStateMachineDestinations(layerIndex, animatorState, transition.destinationStateMachine, transition, parameter, z, new HashSet()); + } + else { Debug.LogError($"[{name}][Conditional Transition for {animatorState.name}] Conditional triggered transition has neither a DestinationState nor a DestinationStateMachine! This transition is not likely to synchronize properly. " + $"Please file a GitHub issue about this error with details about your Animator's setup."); @@ -1060,9 +1104,9 @@ private void WriteSynchronizationData(ref BufferSerializer serializer) whe stateHash = nextState.fullPathHash; // Check if this transition can be synchronized using cross-fading - if (m_DestinationStateToTransitionInfo.TryGetValue(layer, out var layerTransitions)) + if (m_DestinationStateToTransitionInfo.ContainsKey(layer)) { - if (layerTransitions.TryGetValue(nextState.shortNameHash, out var transitionInfo)) + if (TryGetTransitionInfo(layer, nextState.shortNameHash, synchronizationStateInfo.shortNameHash, out var transitionInfo)) { stateHash = transitionInfo.OriginatingState; // Set the destination state to cross-fade to from the originating state @@ -1524,9 +1568,9 @@ private void UpdateAnimationState(AnimationState animationState) { // At this point all entries in the lookup table should be valid. // Look up the transition info for this layer and destination state. - if (m_DestinationStateToTransitionInfo.TryGetValue(animationState.Layer, out var layerTransitions)) + if (m_DestinationStateToTransitionInfo.ContainsKey(animationState.Layer)) { - if (layerTransitions.TryGetValue(animationState.DestinationStateHash, out var transitionInfo)) + if (TryGetTransitionInfo(animationState.Layer, animationState.DestinationStateHash, animationState.StateHash, out var transitionInfo)) { // Make sure we are on the originating/starting state we are going to cross-fade into if (currentState.shortNameHash == animationState.StateHash) diff --git a/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs index 9cdcd449ec..608bf8c709 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs @@ -1,3 +1,4 @@ +#if COM_UNITY_MODULES_ANIMATION using NUnit.Framework; using Unity.Netcode.Components; using UnityEditor; @@ -130,6 +131,116 @@ public void ConditionalExitTransitionResolvesDestinationStateMachineStates() Assert.That(networkAnimator.TransitionStateInfoList.Exists(entry => entry.DestinationState == entryDestinationState.nameHash), Is.True); } + [Test] + public void RootConditionalExitTransitionResolvesRootEntry() + { + var controller = AnimatorController.CreateAnimatorControllerAtPath(k_TestControllerPath); + controller.AddParameter("ExitRoot", AnimatorControllerParameterType.Trigger); + + var rootStateMachine = controller.layers[0].stateMachine; + var defaultState = rootStateMachine.AddState("Default State"); + var originatingState = rootStateMachine.AddState("State A"); + rootStateMachine.defaultState = defaultState; + + var exitTransition = originatingState.AddExitTransition(); + exitTransition.hasExitTime = false; + exitTransition.AddCondition(AnimatorConditionMode.If, 0.0f, "ExitRoot"); + AssetDatabase.SaveAssets(); + + var networkAnimator = CreateNetworkAnimator(controller, nameof(RootConditionalExitTransitionResolvesRootEntry)); + networkAnimator.InvokeOnValidate(); + + Assert.That(networkAnimator.TransitionStateInfoList, Has.Count.EqualTo(1)); + Assert.That(networkAnimator.TransitionStateInfoList[0].OriginatingState, Is.EqualTo(originatingState.nameHash)); + Assert.That(networkAnimator.TransitionStateInfoList[0].DestinationState, Is.EqualTo(defaultState.nameHash)); + } + + [Test] + public void ConditionalExitWithoutParentTransitionResolvesParentEntry() + { + var controller = AnimatorController.CreateAnimatorControllerAtPath(k_TestControllerPath); + controller.AddParameter("ExitSubMachine", AnimatorControllerParameterType.Trigger); + + var rootStateMachine = controller.layers[0].stateMachine; + var rootDefaultState = rootStateMachine.AddState("Root Default State"); + rootStateMachine.defaultState = rootDefaultState; + var outerStateMachine = rootStateMachine.AddStateMachine("Outer SM"); + var outerDefaultState = outerStateMachine.AddState("Outer Default State"); + outerStateMachine.defaultState = outerDefaultState; + var innerStateMachine = outerStateMachine.AddStateMachine("Inner SM"); + var originatingState = innerStateMachine.AddState("State A"); + + var exitTransition = originatingState.AddExitTransition(); + exitTransition.hasExitTime = false; + exitTransition.AddCondition(AnimatorConditionMode.If, 0.0f, "ExitSubMachine"); + AssetDatabase.SaveAssets(); + + var networkAnimator = CreateNetworkAnimator(controller, nameof(ConditionalExitWithoutParentTransitionResolvesParentEntry)); + networkAnimator.InvokeOnValidate(); + + Assert.That(networkAnimator.TransitionStateInfoList, Has.Count.EqualTo(1)); + Assert.That(networkAnimator.TransitionStateInfoList[0].OriginatingState, Is.EqualTo(originatingState.nameHash)); + Assert.That(networkAnimator.TransitionStateInfoList[0].DestinationState, Is.EqualTo(outerDefaultState.nameHash)); + } + + [Test] + public void ConditionalTransitionToStateMachineResolvesItsEntryStates() + { + var controller = AnimatorController.CreateAnimatorControllerAtPath(k_TestControllerPath); + controller.AddParameter("EnterSubMachine", AnimatorControllerParameterType.Trigger); + + var rootStateMachine = controller.layers[0].stateMachine; + var originatingState = rootStateMachine.AddState("State A"); + var destinationStateMachine = rootStateMachine.AddStateMachine("Destination SM"); + var defaultDestinationState = destinationStateMachine.AddState("Default State"); + var entryDestinationState = destinationStateMachine.AddState("Entry State"); + destinationStateMachine.defaultState = defaultDestinationState; + destinationStateMachine.AddEntryTransition(entryDestinationState); + + var transition = originatingState.AddTransition(destinationStateMachine); + transition.hasExitTime = false; + transition.AddCondition(AnimatorConditionMode.If, 0.0f, "EnterSubMachine"); + AssetDatabase.SaveAssets(); + + var networkAnimator = CreateNetworkAnimator(controller, nameof(ConditionalTransitionToStateMachineResolvesItsEntryStates)); + networkAnimator.InvokeOnValidate(); + + Assert.That(networkAnimator.TransitionStateInfoList, Has.Count.EqualTo(2)); + Assert.That(networkAnimator.TransitionStateInfoList.Exists(entry => entry.DestinationState == defaultDestinationState.nameHash), Is.True); + Assert.That(networkAnimator.TransitionStateInfoList.Exists(entry => entry.DestinationState == entryDestinationState.nameHash), Is.True); + } + + [Test] + public void TransitionInfoLookupMatchesOriginatingState() + { + var controller = AnimatorController.CreateAnimatorControllerAtPath(k_TestControllerPath); + controller.AddParameter("ToB", AnimatorControllerParameterType.Trigger); + + var rootStateMachine = controller.layers[0].stateMachine; + var stateA = rootStateMachine.AddState("State A"); + var stateC = rootStateMachine.AddState("State C"); + var stateB = rootStateMachine.AddState("State B"); + + var transitionFromA = stateA.AddTransition(stateB); + transitionFromA.duration = 0.1f; + transitionFromA.AddCondition(AnimatorConditionMode.If, 0.0f, "ToB"); + var transitionFromC = stateC.AddTransition(stateB); + transitionFromC.duration = 0.7f; + transitionFromC.AddCondition(AnimatorConditionMode.If, 0.0f, "ToB"); + AssetDatabase.SaveAssets(); + + var networkAnimator = CreateNetworkAnimator(controller, nameof(TransitionInfoLookupMatchesOriginatingState)); + networkAnimator.InvokeOnValidate(); + networkAnimator.OnAfterDeserialize(); + + Assert.That(networkAnimator.TryGetTransitionInfo(0, stateB.nameHash, stateA.nameHash, out var infoFromA), Is.True); + Assert.That(infoFromA.OriginatingState, Is.EqualTo(stateA.nameHash)); + Assert.That(infoFromA.TransitionDuration, Is.EqualTo(transitionFromA.duration)); + Assert.That(networkAnimator.TryGetTransitionInfo(0, stateB.nameHash, stateC.nameHash, out var infoFromC), Is.True); + Assert.That(infoFromC.OriginatingState, Is.EqualTo(stateC.nameHash)); + Assert.That(infoFromC.TransitionDuration, Is.EqualTo(transitionFromC.duration)); + } + private TestNetworkAnimator CreateNetworkAnimator(AnimatorController controller, string name) { m_GameObject = new GameObject(name); @@ -151,3 +262,4 @@ public void InvokeOnValidate() } } } +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef b/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef index 06002bf7d2..c9709270f8 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef +++ b/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef @@ -29,6 +29,11 @@ "UNITY_INCLUDE_TESTS" ], "versionDefines": [ + { + "name": "com.unity.modules.animation", + "expression": "", + "define": "COM_UNITY_MODULES_ANIMATION" + }, { "name": "com.unity.multiplayer.tools", "expression": "", From a02226270e53fe1fbf583986d64e1f89330abf40 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 3 Nov 2026 10:38:54 -0600 Subject: [PATCH 3/7] test: use the develop-2.0.0 editor test namespace for NetworkAnimatorEditorTests --- .../Tests/Editor/NetworkAnimatorEditorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs index 608bf8c709..5dacdbd877 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs @@ -5,7 +5,7 @@ using UnityEditor.Animations; using UnityEngine; -namespace Unity.Netcode.GameObjects.EditorTests +namespace Unity.Netcode.EditorTests { internal class NetworkAnimatorEditorTests { From 10db78ac283103f6c002efa7edaa9bdeb9942b0a Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 3 Nov 2026 10:46:56 -0600 Subject: [PATCH 4/7] fix: late-joining client cross-fades when not on the originating state A late-joining client starts in its default state, so requiring the originating state left it stuck there. Adds a runtime test that joins mid-transition from a non-default state. --- .../Runtime/Components/NetworkAnimator.cs | 5 +- .../NetworkAnimatorLateJoinTransitionTests.cs | 116 ++++++++++++++++++ ...orkAnimatorLateJoinTransitionTests.cs.meta | 2 + .../Unity.Netcode.Runtime.Tests.asmdef | 5 + 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs index 5d515d7c11..7e2b4db3b1 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs @@ -1572,8 +1572,9 @@ private void UpdateAnimationState(AnimationState animationState) { if (TryGetTransitionInfo(animationState.Layer, animationState.DestinationStateHash, animationState.StateHash, out var transitionInfo)) { - // Make sure we are on the originating/starting state we are going to cross-fade into - if (currentState.shortNameHash == animationState.StateHash) + // A late-joining client starts in its default state, which may not be the originating state, + // so it cross-fades from whatever state it is in unless it is already mid-transition + if (currentState.shortNameHash == animationState.StateHash || !m_Animator.IsInTransition(animationState.Layer)) { // Cross-fade from the current to the destination state for the transitions duration while starting at the server's current normalized time of the transition m_Animator.CrossFade(transitionInfo.DestinationState, transitionInfo.TransitionDuration, transitionInfo.Layer, 0.0f, animationState.NormalizedTime); diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs new file mode 100644 index 0000000000..18fc97e098 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs @@ -0,0 +1,116 @@ +#if COM_UNITY_MODULES_ANIMATION && UNITY_EDITOR +using System.Collections; +using System.Text; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Verifies a client that joins while the authority is mid-transition from a state other than + /// the controller's default state still cross-fades into the transition's destination state. + /// + internal class NetworkAnimatorLateJoinTransitionTests : NetcodeIntegrationTest + { + private const string k_ControllerPath = "Assets/NetworkAnimatorLateJoinTransitionTest.controller"; + private const string k_TriggerName = "ToB"; + + protected override int NumberOfClients => 1; + + private AnimatorController m_Controller; + private AnimatorState m_DefaultState; + private AnimatorState m_SourceState; + private AnimatorState m_DestinationState; + private GameObject m_Prefab; + + protected override void OnServerAndClientsCreated() + { + m_Controller = AnimatorController.CreateAnimatorControllerAtPath(k_ControllerPath); + m_Controller.AddParameter(k_TriggerName, AnimatorControllerParameterType.Trigger); + + var rootStateMachine = m_Controller.layers[0].stateMachine; + m_DefaultState = rootStateMachine.AddState("State A"); + m_SourceState = rootStateMachine.AddState("State C"); + m_DestinationState = rootStateMachine.AddState("State B"); + rootStateMachine.defaultState = m_DefaultState; + + // Both states transition into the same destination, and the transitions are long enough to still be running when the client joins + foreach (var state in new[] { m_DefaultState, m_SourceState }) + { + var transition = state.AddTransition(m_DestinationState); + transition.hasExitTime = false; + transition.duration = 10.0f; + transition.AddCondition(AnimatorConditionMode.If, 0.0f, k_TriggerName); + } + AssetDatabase.SaveAssets(); + + m_Prefab = CreateNetworkObjectPrefab("Animated"); + var animator = m_Prefab.AddComponent(); + animator.runtimeAnimatorController = m_Controller; + var networkAnimator = m_Prefab.AddComponent(); + networkAnimator.Animator = animator; + networkAnimator.AnimatorParameterEntries = new NetworkAnimator.AnimatorParametersListContainer(); + networkAnimator.InvokeOnValidate(); + networkAnimator.OnAfterDeserialize(); + + base.OnServerAndClientsCreated(); + } + + protected override IEnumerator OnTearDown() + { + AssetDatabase.DeleteAsset(k_ControllerPath); + yield return base.OnTearDown(); + } + + [UnityTest] + public IEnumerator LateJoinFromNonDefaultStateCrossFadesToDestination() + { + var authority = GetAuthorityNetworkManager(); + var authorityInstance = SpawnObject(m_Prefab, authority).GetComponent(); + var authorityAnimator = authorityInstance.GetComponent(); + + authorityAnimator.Play(m_SourceState.nameHash, 0); + yield return WaitForConditionOrTimeOut(_ => authorityAnimator.GetCurrentAnimatorStateInfo(0).shortNameHash == m_SourceState.nameHash); + AssertOnTimeout($"Authority did not enter {m_SourceState.name}!"); + + authorityAnimator.SetTrigger(k_TriggerName); + yield return WaitForConditionOrTimeOut(_ => authorityAnimator.IsInTransition(0) && + authorityAnimator.GetNextAnimatorStateInfo(0).shortNameHash == m_DestinationState.nameHash); + AssertOnTimeout($"Authority did not start transitioning from {m_SourceState.name} to {m_DestinationState.name}!"); + + yield return CreateAndStartNewClient(); + var lateJoinClient = m_ClientNetworkManagers[m_ClientNetworkManagers.Length - 1]; + + yield return WaitForConditionOrTimeOut(_ => lateJoinClient.SpawnManager.SpawnedObjects.ContainsKey(authorityInstance.NetworkObjectId)); + AssertOnTimeout($"Late-joining client did not spawn {authorityInstance.name}!"); + var clientAnimator = lateJoinClient.SpawnManager.SpawnedObjects[authorityInstance.NetworkObjectId].GetComponent(); + + yield return WaitForConditionOrTimeOut(errorLog => ReachingDestination(clientAnimator, errorLog)); + AssertOnTimeout($"Late-joining client did not cross-fade to {m_DestinationState.name}!"); + } + + private bool ReachingDestination(Animator animator, StringBuilder errorLog) + { + var current = animator.GetCurrentAnimatorStateInfo(0).shortNameHash; + if (current == m_DestinationState.nameHash || (animator.IsInTransition(0) && animator.GetNextAnimatorStateInfo(0).shortNameHash == m_DestinationState.nameHash)) + { + return true; + } + errorLog.Append($"Client current state hash {current}, in transition: {animator.IsInTransition(0)}"); + return false; + } + + private class TestNetworkAnimator : NetworkAnimator + { + public void InvokeOnValidate() + { + base.OnValidate(); + } + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs.meta new file mode 100644 index 0000000000..aa06c6ae37 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4478aad070e3b7741b2cae4b6ed92946 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unity.Netcode.Runtime.Tests.asmdef b/com.unity.netcode.gameobjects/Tests/Runtime/Unity.Netcode.Runtime.Tests.asmdef index cde139b07f..6841e80aba 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unity.Netcode.Runtime.Tests.asmdef +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unity.Netcode.Runtime.Tests.asmdef @@ -38,6 +38,11 @@ "expression": "1.0.0-pre.7", "define": "MULTIPLAYER_TOOLS_1_0_0_PRE_7" }, + { + "name": "com.unity.modules.animation", + "expression": "", + "define": "COM_UNITY_MODULES_ANIMATION" + }, { "name": "com.unity.modules.physics", "expression": "", From a15c9fbac361e89532ef1f9adf31221e5941549e Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 3 Nov 2026 10:54:03 -0600 Subject: [PATCH 5/7] chore: add PR number to CHANGELOG entry --- com.unity.netcode.gameobjects/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 6a3918c1c8..0fb8564666 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -22,7 +22,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed -- Issue where `NetworkAnimator` logged an error for a trigger-conditioned transition into an Exit node, and a client that joined during that transition, or during one into a sub-state machine, did not synchronize with it. Transitions that pass through Exit, Entry or sub-state machine nodes now resolve to the state they reach, and a late-joining client cross-fades from the matching originating state when several states transition into the same destination. +- Issue where `NetworkAnimator` logged an error for a trigger-conditioned transition into an Exit node, and a client that joined during that transition, or during one into a sub-state machine, did not synchronize with it. Transitions that pass through Exit, Entry or sub-state machine nodes now resolve to the state they reach, and a late-joining client cross-fades from the matching originating state when several states transition into the same destination. (#4240) - Issue where a client promoted to session owner could throw an exception when synchronizing a newly joining client. (#4217) - Issue where a growable `FastBufferWriter` created with a size of 0 never returned from its first write or `TryBeginWrite` call that needed the buffer to grow. (#4223) - Issue where a `FastBufferReader` created with an offset and the default length of -1 read past the end of its source. The `ArraySegment` overload that takes an offset now applies it from the start of the segment. (#4221) From d07cd7f06858a860fb8bfd7cb0fe88d3e99ceabd Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 3 Nov 2026 11:23:50 -0600 Subject: [PATCH 6/7] fix: match NetworkAnimator transition info by originating state only Transitions not in the table no longer fall back to the first entry for the destination, and a missing sub-state machine reference no longer throws while the table is built. --- .../Runtime/Components/NetworkAnimator.cs | 26 +++++++++---------- .../Editor/NetworkAnimatorEditorTests.cs | 1 + 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs index 7e2b4db3b1..edc67d52b7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs @@ -300,8 +300,8 @@ private void BuildDestinationToTransitionInfoTable() } /// - /// Gets the transition info for a destination state, preferring the entry that starts from the originating state - /// since several states can transition into the same destination. + /// Gets the transition info for a destination state, matched by originating state since several states can + /// transition into the same destination. Transitions not in the table, such as non-trigger ones, return false. /// internal bool TryGetTransitionInfo(int layer, int destinationState, int originatingState, out TransitionStateInfo transitionInfo) { @@ -311,16 +311,15 @@ internal bool TryGetTransitionInfo(int layer, int destinationState, int originat return false; } - transitionInfo = transitions[0]; foreach (var entry in transitions) { if (entry.OriginatingState == originatingState) { transitionInfo = entry; - break; + return true; } } - return true; + return false; } [Serializable] @@ -493,7 +492,11 @@ private void ParseStateMachineStates(int layerIndex, ref AnimatorController anim foreach (var childStateMachine in stateMachine.stateMachines) { var nestedStateMachine = childStateMachine.stateMachine; - ParseStateMachineStates(layerIndex, ref animatorController, ref nestedStateMachine, childStateMachineParents); + // A missing sub-state machine asset reference leaves this null + if (nestedStateMachine != null) + { + ParseStateMachineStates(layerIndex, ref animatorController, ref nestedStateMachine, childStateMachineParents); + } } } @@ -1104,14 +1107,11 @@ private void WriteSynchronizationData(ref BufferSerializer serializer) whe stateHash = nextState.fullPathHash; // Check if this transition can be synchronized using cross-fading - if (m_DestinationStateToTransitionInfo.ContainsKey(layer)) + if (TryGetTransitionInfo(layer, nextState.shortNameHash, synchronizationStateInfo.shortNameHash, out var transitionInfo)) { - if (TryGetTransitionInfo(layer, nextState.shortNameHash, synchronizationStateInfo.shortNameHash, out var transitionInfo)) - { - stateHash = transitionInfo.OriginatingState; - // Set the destination state to cross-fade to from the originating state - animationState.DestinationStateHash = transitionInfo.DestinationState; - } + stateHash = transitionInfo.OriginatingState; + // Set the destination state to cross-fade to from the originating state + animationState.DestinationStateHash = transitionInfo.DestinationState; } } diff --git a/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs index 5dacdbd877..659965d007 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs @@ -239,6 +239,7 @@ public void TransitionInfoLookupMatchesOriginatingState() Assert.That(networkAnimator.TryGetTransitionInfo(0, stateB.nameHash, stateC.nameHash, out var infoFromC), Is.True); Assert.That(infoFromC.OriginatingState, Is.EqualTo(stateC.nameHash)); Assert.That(infoFromC.TransitionDuration, Is.EqualTo(transitionFromC.duration)); + Assert.That(networkAnimator.TryGetTransitionInfo(0, stateB.nameHash, stateB.nameHash, out _), Is.False); } private TestNetworkAnimator CreateNetworkAnimator(AnimatorController controller, string name) From 4fe966c57a3d2ffc82d4ef5ee3485ad70ec334f4 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 3 Nov 2026 13:14:49 -0600 Subject: [PATCH 7/7] fix: late-joining client cross-fades from the originating state CrossFade normalizes the transition duration to the current state's length, so cross-fading from the default state blended at the wrong speed when the clip lengths differ. --- .../Runtime/Components/NetworkAnimator.cs | 12 +++++++++--- .../NetworkAnimatorLateJoinTransitionTests.cs | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs index edc67d52b7..be8dc8a61d 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs @@ -1572,10 +1572,16 @@ private void UpdateAnimationState(AnimationState animationState) { if (TryGetTransitionInfo(animationState.Layer, animationState.DestinationStateHash, animationState.StateHash, out var transitionInfo)) { - // A late-joining client starts in its default state, which may not be the originating state, - // so it cross-fades from whatever state it is in unless it is already mid-transition - if (currentState.shortNameHash == animationState.StateHash || !m_Animator.IsInTransition(animationState.Layer)) + var onOriginatingState = currentState.shortNameHash == animationState.StateHash; + if (onOriginatingState || !m_Animator.IsInTransition(animationState.Layer)) { + // A late-joining client starts in its default state. CrossFade normalizes the duration to the + // current state's length, so enter the originating state first and apply it before cross-fading. + if (!onOriginatingState) + { + m_Animator.Play(animationState.StateHash, animationState.Layer, 0.0f); + m_Animator.Update(0.0f); + } // Cross-fade from the current to the destination state for the transitions duration while starting at the server's current normalized time of the transition m_Animator.CrossFade(transitionInfo.DestinationState, transitionInfo.TransitionDuration, transitionInfo.Layer, 0.0f, animationState.NormalizedTime); } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs index 18fc97e098..b5582b64f2 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs @@ -1,6 +1,7 @@ #if COM_UNITY_MODULES_ANIMATION && UNITY_EDITOR using System.Collections; using System.Text; +using NUnit.Framework; using Unity.Netcode.Components; using Unity.Netcode.TestHelpers.Runtime; using UnityEditor; @@ -38,12 +39,18 @@ protected override void OnServerAndClientsCreated() m_DestinationState = rootStateMachine.AddState("State B"); rootStateMachine.defaultState = m_DefaultState; + // The source clip is much longer than the default one, so a cross-fade normalized to the wrong state blends far faster + m_DefaultState.motion = CreateClip("Short", 1.0f); + m_SourceState.motion = CreateClip("Long", 100.0f); + m_DestinationState.motion = CreateClip("Destination", 1.0f); + // Both states transition into the same destination, and the transitions are long enough to still be running when the client joins foreach (var state in new[] { m_DefaultState, m_SourceState }) { var transition = state.AddTransition(m_DestinationState); transition.hasExitTime = false; - transition.duration = 10.0f; + transition.hasFixedDuration = false; + transition.duration = 0.5f; transition.AddCondition(AnimatorConditionMode.If, 0.0f, k_TriggerName); } AssetDatabase.SaveAssets(); @@ -91,6 +98,15 @@ public IEnumerator LateJoinFromNonDefaultStateCrossFadesToDestination() yield return WaitForConditionOrTimeOut(errorLog => ReachingDestination(clientAnimator, errorLog)); AssertOnTimeout($"Late-joining client did not cross-fade to {m_DestinationState.name}!"); + Assert.That(clientAnimator.GetCurrentAnimatorStateInfo(0).shortNameHash, Is.EqualTo(m_SourceState.nameHash), $"Late-joining client did not cross-fade from {m_SourceState.name}!"); + } + + private AnimationClip CreateClip(string name, float length) + { + var clip = new AnimationClip { name = name }; + clip.SetCurve(string.Empty, typeof(Transform), "localScale.x", AnimationCurve.Linear(0.0f, 1.0f, length, 1.0f)); + AssetDatabase.AddObjectToAsset(clip, m_Controller); + return clip; } private bool ReachingDestination(Animator animator, StringBuilder errorLog)