diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs index 99f923b6ca..8e3d8d59dc 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs @@ -702,6 +702,14 @@ internal void SetIsDestroying() IsDestroying = true; } + /// + /// Invoked by . + /// + internal void ResetIsDestroying() + { + IsDestroying = false; + } + /// /// Updates properties with network session related /// dependencies such as a NetworkObject's spawned diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs index 96e765f86d..14076ce025 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs @@ -1953,14 +1953,29 @@ internal void SetIsDestroying() { // Tell the childBehaviour that this NetworkObject is being destroyed. // Keeping the property a private set to assure this is - // the only way it can be set as it should never be reset - // back to false once invoked. + // the only way it can be set. Only a hybrid instance that + // its ghost keeps alive resets it (ResetIsDestroying). childBehaviour?.SetIsDestroying(); } } IsDestroying = true; } + /// + /// Clears the despawning flag of a hybrid instance that its ghost keeps alive, and of its child NetworkBehaviours. + /// + internal void ResetIsDestroying() + { + if (ChildNetworkBehaviours != null) + { + foreach (var childBehaviour in ChildNetworkBehaviours.Values) + { + childBehaviour?.ResetIsDestroying(); + } + } + IsDestroying = false; + } + private void OnDestroy() { SceneManager.activeSceneChanged -= CurrentlyActiveSceneChanged; diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs index adafb89925..270c8a45b0 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/GhostSpawnManager.cs @@ -115,7 +115,11 @@ internal bool TryGetGhostNetworkObjectForSpawn(NetworkObject.SerializedObject se // TODO-UNIFIED: We need a better way to preserve any hybrid instances pending NGO spawn. // NOTE: We might be able to use the NetworkSceneHandle to get the associated local scene handle to which we can use to get the targeted scene. - UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(networkObject.gameObject, UnityEngine.SceneManagement.SceneManager.GetActiveScene()); + // A child kept alive by its ghost (NetworkHide) is still parented and stays in its parent's scene. + if (networkObject.transform.parent == null) + { + UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(networkObject.gameObject, UnityEngine.SceneManagement.SceneManager.GetActiveScene()); + } return true; } diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index d8b46043e9..ebc8ad612e 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -1958,7 +1958,12 @@ internal void OnDespawnObject([NotNull] NetworkObject networkObject, bool destro // Unified netcode handles destroying the instance of the object on the non-authority side when the object has a ghost representation. if (destroyGameObject && networkObject.HasGhost && !NetworkManager.IsServer) { - // exit early + // A NetworkHide leaves the ghost alive, so the instance waits for a later NetworkShow (removed again if N4E destroys it). + if (networkObject && NetworkManager.IsConnectedClient && !NetworkManager.ShutdownInProgress) + { + networkObject.ResetIsDestroying(); + GhostSpawnManager.RegisterGhostPendingSpawn(networkObject, networkObject.NetworkObjectId); + } return; } #endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridNetworkShowHideTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridNetworkShowHideTests.cs new file mode 100644 index 0000000000..dd4b1ec25d --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridNetworkShowHideTests.cs @@ -0,0 +1,152 @@ +#if UNIFIED_NETCODE +using System; +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates that hiding and then showing a spawned hybrid instance respawns it on the client. + /// + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] + internal class HybridNetworkShowHideTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 1; + + private GameObject m_HybridPrefab; + private GameObject m_AttachablePrefab; + private GameObject m_AttachableNodePrefab; + private ulong m_NetworkObjectId; + + public HybridNetworkShowHideTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override bool UseUnifiedTests() + { + return true; + } + + protected override void OnServerAndClientsCreated() + { + m_HybridPrefab = CreateNetworkObjectPrefab("HybridShowHide"); + m_AttachablePrefab = CreateNetworkObjectPrefab("HybridAttachable"); + var attachableChild = new GameObject("AttachableChild"); + attachableChild.transform.parent = m_AttachablePrefab.transform; + attachableChild.AddComponent(); + m_AttachableNodePrefab = CreateNetworkObjectPrefab("HybridAttachableNode"); + var nodeChild = new GameObject("NodeChild"); + nodeChild.transform.parent = m_AttachableNodePrefab.transform; + nodeChild.AddComponent(); + base.OnServerAndClientsCreated(); + } + + private bool IsSpawnedOnClient() + { + return GetNonAuthorityNetworkManager().SpawnManager.SpawnedObjects.ContainsKey(m_NetworkObjectId); + } + + private IEnumerator SpawnOnAll(GameObject prefab, Action onSpawned) + { + var authorityInstance = SpawnObject(prefab, GetAuthorityNetworkManager()).GetComponent(); + yield return WaitForSpawnedOnAllOrTimeOut(authorityInstance); + AssertOnTimeout($"[Setup] {authorityInstance.name} was not spawned on all instances!"); + onSpawned(authorityInstance); + } + + private IEnumerator HideThenShow(NetworkObject authorityInstance, string context) + { + var nonAuthority = GetNonAuthorityNetworkManager(); + m_NetworkObjectId = authorityInstance.NetworkObjectId; + authorityInstance.NetworkHide(nonAuthority.LocalClientId); + yield return WaitForConditionOrTimeOut(() => !IsSpawnedOnClient()); + AssertOnTimeout($"[{context}] {authorityInstance.name} was not despawned on Client-{nonAuthority.LocalClientId}!"); + + authorityInstance.NetworkShow(nonAuthority.LocalClientId); + yield return WaitForConditionOrTimeOut(IsSpawnedOnClient); + AssertOnTimeout($"[{context}] {authorityInstance.name} was not respawned on Client-{nonAuthority.LocalClientId}!"); + } + + [UnityTest] + public IEnumerator HideThenShowRespawnsOnClient() + { + var nonAuthority = GetNonAuthorityNetworkManager(); + NetworkObject authorityInstance = null; + yield return SpawnOnAll(m_HybridPrefab, spawned => authorityInstance = spawned); + var clientInstance = nonAuthority.SpawnManager.SpawnedObjects[authorityInstance.NetworkObjectId]; + + // A second cycle shows the respawned instance can be hidden and shown again. + for (int i = 0; i < 2; i++) + { + yield return HideThenShow(authorityInstance, $"Cycle {i}"); + Assert.AreSame(clientInstance, nonAuthority.SpawnManager.SpawnedObjects[m_NetworkObjectId], $"[Cycle {i}] Client-{nonAuthority.LocalClientId} did not respawn the instance its ghost kept alive!"); + } + } + + [UnityTest] + public IEnumerator HideThenShowParentedChild() + { + var nonAuthority = GetNonAuthorityNetworkManager(); + NetworkObject parent = null; + NetworkObject child = null; + yield return SpawnOnAll(m_HybridPrefab, spawned => parent = spawned); + yield return SpawnOnAll(m_HybridPrefab, spawned => child = spawned); + var clientParent = nonAuthority.SpawnManager.SpawnedObjects[parent.NetworkObjectId].transform; + var clientChild = nonAuthority.SpawnManager.SpawnedObjects[child.NetworkObjectId].transform; + Assert.IsTrue(child.TrySetParent(parent, false), $"{nameof(NetworkObject.TrySetParent)} failed!"); + yield return WaitForConditionOrTimeOut(() => clientChild.parent == clientParent); + AssertOnTimeout($"[Setup] {child.name} was not parented on Client-{nonAuthority.LocalClientId}!"); + + yield return HideThenShow(child, "Parented"); + Assert.AreSame(clientParent, clientChild.parent, $"Client-{nonAuthority.LocalClientId} did not keep {child.name} parented!"); + } + + [UnityTest] + public IEnumerator HideThenShowThenAttachAndDetach() + { + var nonAuthority = GetNonAuthorityNetworkManager(); + NetworkObject source = null; + NetworkObject node = null; + yield return SpawnOnAll(m_AttachablePrefab, spawned => source = spawned); + yield return SpawnOnAll(m_AttachableNodePrefab, spawned => node = spawned); + yield return HideThenShow(source, "Attachable"); + + var clientSource = nonAuthority.SpawnManager.SpawnedObjects[source.NetworkObjectId].transform; + var clientAttachable = clientSource.GetComponentInChildren().transform; + var clientNode = nonAuthority.SpawnManager.SpawnedObjects[node.NetworkObjectId].GetComponentInChildren().transform; + var attachable = source.GetComponentInChildren(); + + attachable.Attach(node.GetComponentInChildren()); + yield return WaitForConditionOrTimeOut(() => clientAttachable.parent == clientNode); + AssertOnTimeout($"Client-{nonAuthority.LocalClientId} did not attach {clientAttachable.name}!"); + + attachable.Detach(); + yield return WaitForConditionOrTimeOut(() => clientAttachable.parent == clientSource); + AssertOnTimeout($"Client-{nonAuthority.LocalClientId} did not detach {clientAttachable.name}!"); + } + + [UnityTest] + public IEnumerator DespawnWhileHiddenReleasesClientInstance() + { + var nonAuthority = GetNonAuthorityNetworkManager(); + NetworkObject authorityInstance = null; + yield return SpawnOnAll(m_HybridPrefab, spawned => authorityInstance = spawned); + m_NetworkObjectId = authorityInstance.NetworkObjectId; + var clientInstance = nonAuthority.SpawnManager.SpawnedObjects[m_NetworkObjectId]; + + authorityInstance.NetworkHide(nonAuthority.LocalClientId); + yield return WaitForConditionOrTimeOut(() => !IsSpawnedOnClient()); + AssertOnTimeout($"[Setup] {authorityInstance.name} was not despawned on Client-{nonAuthority.LocalClientId}!"); + Assert.IsTrue(nonAuthority.SpawnManager.GhostSpawnManager.IsGhostPendingSpawn(m_NetworkObjectId), $"Client-{nonAuthority.LocalClientId} is not holding the hidden instance for a later show!"); + + authorityInstance.Despawn(); + yield return WaitForConditionOrTimeOut(() => clientInstance == null); + AssertOnTimeout($"N4E did not destroy the hidden instance on Client-{nonAuthority.LocalClientId}!"); + Assert.IsFalse(nonAuthority.SpawnManager.GhostSpawnManager.IsGhostPendingSpawn(m_NetworkObjectId), $"Client-{nonAuthority.LocalClientId} still holds the destroyed instance as pending!"); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridNetworkShowHideTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridNetworkShowHideTests.cs.meta new file mode 100644 index 0000000000..f4298ce035 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridNetworkShowHideTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 390f851a2f1606849819544685dc632b \ No newline at end of file