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