diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 66fef4f4fd..0fb8564666 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. (#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) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkAnimator.cs index bf275ab3c3..be8dc8a61d 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,43 @@ 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, 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) + { + transitionInfo = null; + if (!m_DestinationStateToTransitionInfo.TryGetValue(layer, out var layerTransitions) || !layerTransitions.TryGetValue(destinationState, out var transitions)) + { + return false; + } + + foreach (var entry in transitions) + { + if (entry.OriginatingState == originatingState) + { + transitionInfo = entry; + return true; + } + } + return false; + } + [Serializable] internal class AnimatorParameterEntry { @@ -323,7 +352,83 @@ 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) + { + // 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]; + 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) + { + 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,23 +460,17 @@ private void ParseStateMachineStates(int layerIndex, ref AnimatorController anim { case AnimatorControllerParameterType.Trigger: { - if (transition.destinationStateMachine != null) + if (transition.destinationState != null) + { + AddTransitionStateInfo(layerIndex, animatorState, transition.destinationState, transition, parameter, z); + } + else if (transition.isExit) { - var destinationStateMachine = transition.destinationStateMachine; - ParseStateMachineStates(layerIndex, ref animatorController, ref destinationStateMachine); + AddExitTransitionStateInfo(layerIndex, animatorState, stateMachine, transition, parameter, z, parentStateMachines, parentStateMachines != null ? parentStateMachines.Count - 1 : -1); } - else if (transition.destinationState != null) + else if (transition.destinationStateMachine != null) { - var transitionInfo = new TransitionStateInfo() - { - Layer = layerIndex, - OriginatingState = animatorState.nameHash, - DestinationState = transition.destinationState.nameHash, - TransitionDuration = transition.duration, - TriggerNameHash = parameter.nameHash, - TransitionIndex = z - }; - TransitionStateInfoList.Add(transitionInfo); + AddStateMachineDestinations(layerIndex, animatorState, transition.destinationStateMachine, transition, parameter, z, new HashSet()); } else { @@ -387,6 +486,18 @@ 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; + // A missing sub-state machine asset reference leaves this null + if (nestedStateMachine != null) + { + ParseStateMachineStates(layerIndex, ref animatorController, ref nestedStateMachine, childStateMachineParents); + } + } } /// @@ -996,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.TryGetValue(layer, out var layerTransitions)) + if (TryGetTransitionInfo(layer, nextState.shortNameHash, synchronizationStateInfo.shortNameHash, out var transitionInfo)) { - if (layerTransitions.TryGetValue(nextState.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; } } @@ -1460,13 +1568,20 @@ 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) + 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/Editor/NetworkAnimatorEditorTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs new file mode 100644 index 0000000000..659965d007 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/NetworkAnimatorEditorTests.cs @@ -0,0 +1,266 @@ +#if COM_UNITY_MODULES_ANIMATION +using NUnit.Framework; +using Unity.Netcode.Components; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; + +namespace Unity.Netcode.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); + } + + [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)); + Assert.That(networkAnimator.TryGetTransitionInfo(0, stateB.nameHash, stateB.nameHash, out _), Is.False); + } + + 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(); + } + } + } +} +#endif 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 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": "", 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..b5582b64f2 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Components/NetworkAnimatorLateJoinTransitionTests.cs @@ -0,0 +1,132 @@ +#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; +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; + + // 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.hasFixedDuration = false; + transition.duration = 0.5f; + 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}!"); + 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) + { + 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": "",