diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index a84878a451..6d7e8ae4e5 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -10,6 +10,10 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Added +- Added `AttachableBehaviour` helper component to provide an alternate approach to parenting items without using the `NetworkObject` parenting. (#3518) +- Added `AttachableNode` helper component that is used by `AttachableBehaviour` as the target node for parenting. (#3518) +- Added `ComponentController` helper component that can be used to synchronize the enabling and disabling of components and can be used in conjunction with `AttachableBehaviour`. (#3518) +- Added `NetworkBehaviour.OnNetworkPreDespawn` that is invoked before running through the despawn sequence for the `NetworkObject` and all `NetworkBehaviour` children of the `NetworkObject` being despawned. (#3518) - Added methods `GetDefaultNetworkSettings` and `GetDefaultPipelineConfigurations` to `UnityTransport`. These can be used to retrieve the default settings and pipeline stages that are used by `UnityTransport`. This is useful when providing a custom driver constructor through `UnityTransport.s_DriverConstructor`, since it allows reusing or tuning the existing configuration instead of trying to recreate it. This means a transport with a custom driver can now easily benefit from most of the features of `UnityTransport`, like integration with the Network Simulator and Network Profiler from the multiplayer tools package. (#3501) - Added mappings between `ClientId` and `TransportId`. (#3516) - Added `NetworkPrefabInstanceHandlerWithData`, a variant of `INetworkPrefabInstanceHandler` that provides access to custom instantiation data directly within the `Instantiate()` method. (#3430) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers.meta b/com.unity.netcode.gameobjects/Runtime/Components/Helpers.meta new file mode 100644 index 0000000000..28b2944c3d --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fbc8738cd4ff119499520131b7aa7232 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableBehaviour.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableBehaviour.cs new file mode 100644 index 0000000000..1962ca6222 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableBehaviour.cs @@ -0,0 +1,411 @@ +using System; +#if UNITY_EDITOR +using UnityEditor; +#endif +using UnityEngine; + +namespace Unity.Netcode.Components +{ + /// + /// Attachable NetworkBehaviours
+ /// This component handles the parenting synchronization of the that this component is attached to.
+ /// under another 's .
+ /// The to be parented must have this component attached to it and must be nested on any child under the 's .
+ /// The target parent must have an component attached to it and must belong to a + /// different than that of the 's. + ///
+ /// + /// The term "attach" is used in place of parenting in order to distinguish between parenting and + /// parenting ("attaching" and "detaching").
+ /// This component can be used along with one or more in order to enable or disable different components depending + /// upon the instance's current state.
+ /// invocation order: + /// - When attaching, the 's is invoked just before the is invoked with the state.
+ /// - When detaching, the 's is invoked right after the is invoked with the notification.
+ ///
+ public class AttachableBehaviour : NetworkBehaviour + { +#if UNITY_EDITOR + /// + /// + /// In the event an is placed on the same + /// as the , this will automatically create a child and add an + /// to that. + /// + protected virtual void OnValidate() + { + var networkObject = gameObject.GetComponentInParent(); + if (!networkObject) + { + networkObject = gameObject.GetComponent(); + } + if (networkObject && networkObject.gameObject == gameObject) + { + Debug.LogWarning($"[{name}][{nameof(AttachableBehaviour)}] Cannot be placed on the same {nameof(GameObject)} as the {nameof(NetworkObject)}!"); + // Wait for the next editor update to create a nested child and add the AttachableBehaviour + EditorApplication.update += CreatedNestedChild; + } + } + + private void CreatedNestedChild() + { + EditorApplication.update -= CreatedNestedChild; + var childGameObject = new GameObject($"{name}-Child"); + childGameObject.transform.parent = transform; + childGameObject.AddComponent(); + Debug.Log($"[{name}][Created Child] Adding {nameof(AttachableBehaviour)} to newly created child {childGameObject.name}."); + DestroyImmediate(this); + } +#endif + + /// + /// Invoked when the of this instance has changed. + /// + public event Action AttachStateChange; + + /// + /// The various states of . + /// + public enum AttachState + { + /// + /// The instance is not attached to anything. + /// When not attached to anything, the instance will be parented under the original + /// . + /// + Detached, + /// + /// The instance is attaching to an . + /// + /// + /// One example usage:
+ /// When using an with one or more component(s), + /// this would be a good time to enable or disable components. + ///
+ Attaching, + /// + /// The instance is attached to an . + /// + /// + /// This would be a good time to apply any additional local position or rotation values to this instance. + /// + Attached, + /// + /// The instance is detaching from an . + /// + /// + /// One example usage:
+ /// When using an with one or more component(s), + /// this would be a good time to enable or disable components. + ///
+ Detaching + } + + /// + /// The current instance's . + /// + protected AttachState m_AttachState { get; private set; } + + /// + /// The original parent of this instance. + /// + protected GameObject m_DefaultParent { get; private set; } + + /// + /// If attached, attaching, or detaching this will be the this instance is attached to. + /// + protected AttachableNode m_AttachableNode { get; private set; } + + private NetworkBehaviourReference m_AttachedNodeReference = new NetworkBehaviourReference(null); + private Vector3 m_OriginalLocalPosition; + private Quaternion m_OriginalLocalRotation; + + /// + protected override void OnSynchronize(ref BufferSerializer serializer) + { + // Example of how to synchronize late joining clients when using an RPC to update + // a local property's state. + if (serializer.IsWriter) + { + serializer.SerializeValue(ref m_AttachedNodeReference); + } + else + { + serializer.SerializeValue(ref m_AttachedNodeReference); + } + base.OnSynchronize(ref serializer); + } + + /// + /// If you create a custom and override this method, you must invoke + /// this base instance of . + /// + protected virtual void Awake() + { + m_DefaultParent = transform.parent == null ? gameObject : transform.parent.gameObject; + m_OriginalLocalPosition = transform.localPosition; + m_OriginalLocalRotation = transform.localRotation; + m_AttachState = AttachState.Detached; + m_AttachableNode = null; + } + + /// + /// + /// If you create a custom and override this method, you will want to + /// invoke this base instance of if you want the current + /// state to have been applied before executing the derived class's + /// script. + /// + protected override void OnNetworkSessionSynchronized() + { + UpdateAttachedState(); + base.OnNetworkSessionSynchronized(); + } + + /// + public override void OnNetworkDespawn() + { + InternalDetach(); + if (NetworkManager && !NetworkManager.ShutdownInProgress) + { + // Notify of the changed attached state + UpdateAttachState(m_AttachState, m_AttachableNode); + } + m_AttachedNodeReference = new NetworkBehaviourReference(null); + base.OnNetworkDespawn(); + } + + private void UpdateAttachedState() + { + var attachableNode = (AttachableNode)null; + var shouldParent = m_AttachedNodeReference.TryGet(out attachableNode, NetworkManager); + var preState = shouldParent ? AttachState.Attaching : AttachState.Detaching; + var preNode = shouldParent ? attachableNode : m_AttachableNode; + shouldParent = shouldParent && attachableNode != null; + + if (shouldParent && m_AttachableNode != null && m_AttachState == AttachState.Attached) + { + // If we are attached to some other AttachableNode, then detach from that before attaching to a new one. + if (m_AttachableNode != attachableNode) + { + // Run through the same process without being triggerd by a NetVar update. + UpdateAttachState(AttachState.Detaching, m_AttachableNode); + InternalDetach(); + UpdateAttachState(AttachState.Detached, m_AttachableNode); + + m_AttachableNode.Detach(this); + m_AttachableNode = null; + } + } + + // Change the state to attaching or detaching + UpdateAttachState(preState, preNode); + + if (shouldParent) + { + InternalAttach(attachableNode); + } + else + { + InternalDetach(); + } + + // Notify of the changed attached state + UpdateAttachState(m_AttachState, m_AttachableNode); + + // When detaching, we want to make our final action + // the invocation of the AttachableNode's Detach method. + if (!shouldParent && m_AttachableNode) + { + m_AttachableNode.Detach(this); + m_AttachableNode = null; + } + } + + /// + /// For customized/derived s, override this method to receive notifications + /// when the has changed. + /// + /// The new . + /// The being attached to or from. Will be null when completely detached. + protected virtual void OnAttachStateChanged(AttachState attachState, AttachableNode attachableNode) + { + + } + + /// + /// Update the attached state. + /// + private void UpdateAttachState(AttachState attachState, AttachableNode attachableNode) + { + try + { + AttachStateChange?.Invoke(attachState, attachableNode); + } + catch (Exception ex) + { + Debug.LogException(ex); + } + + try + { + OnAttachStateChanged(attachState, attachableNode); + } + catch (Exception ex) + { + Debug.LogException(ex); + } + } + + /// + /// Internal attach method that just handles changing state, parenting, and sending the a + /// notification that an has attached. + /// + internal void InternalAttach(AttachableNode attachableNode) + { + m_AttachState = AttachState.Attached; + m_AttachableNode = attachableNode; + // Attachables are always local space relative + transform.SetParent(m_AttachableNode.transform, false); + m_AttachableNode.Attach(this); + } + + /// + /// Attaches the of this instance to the of the . + /// + /// + /// This effectively applies a new parent to a nested and all children + /// of the nested .
+ /// Both the and this instances should be in the spawned state before this + /// is invoked. + ///
+ /// The to attach this instance to. + public void Attach(AttachableNode attachableNode) + { + if (!IsSpawned) + { + NetworkLog.LogError($"[{name}][Attach][Not Spawned] Cannot attach before being spawned!"); + return; + } + + if (!HasAuthority) + { + NetworkLog.LogError($"[{name}][Attach][Not Authority] Client-{NetworkManager.LocalClientId} is not the authority!"); + return; + } + + if (attachableNode.NetworkObject == NetworkObject) + { + NetworkLog.LogError($"[{name}][Attach] Cannot attach to the original {NetworkObject} instance!"); + return; + } + + if (m_AttachableNode != null && m_AttachState == AttachState.Attached && m_AttachableNode == attachableNode) + { + NetworkLog.LogError($"[{name}][Attach] Cannot attach! {name} is already attached to {attachableNode.name}!"); + return; + } + + ChangeReference(new NetworkBehaviourReference(attachableNode)); + } + + /// + /// Internal detach method that just handles changing state, parenting, and sending the a + /// notification that an has detached. + /// + internal void InternalDetach() + { + if (m_AttachableNode) + { + if (m_DefaultParent) + { + // Set the original parent and origianl local position and rotation + transform.SetParent(m_DefaultParent.transform, false); + transform.localPosition = m_OriginalLocalPosition; + transform.localRotation = m_OriginalLocalRotation; + } + m_AttachState = AttachState.Detached; + } + } + + /// + /// Invoke to detach from a . + /// + public void Detach() + { + if (!gameObject) + { + return; + } + if (!IsSpawned) + { + NetworkLog.LogError($"[{name}][Detach][Not Spawned] Cannot detach if not spawned!"); + return; + } + + if (!OnHasAuthority()) + { + NetworkLog.LogError($"[{name}][Detach][Not Authority] Client-{NetworkManager.LocalClientId} is not the authority!"); + return; + } + + if (m_AttachState != AttachState.Attached || m_AttachableNode == null) + { + // Check for the unlikely scenario that an instance has mismatch between the state and assigned attachable node. + if (!m_AttachableNode && m_AttachState == AttachState.Attached) + { + NetworkLog.LogError($"[{name}][Detach] Invalid state detected! {name}'s state is still {m_AttachState} but has no {nameof(AttachableNode)} assigned!"); + } + + // Developer only notification for the most likely scenario where this method is invoked but the instance is not attached to anything. + if (NetworkManager && NetworkManager.LogLevel <= LogLevel.Developer) + { + NetworkLog.LogWarning($"[{name}][Detach] Cannot detach! {name} is not attached to anything!"); + } + + // If we have the attachable node set and we are not in the middle of detaching, then log an error and note + // this could potentially occur if inoked more than once for the same instance in the same frame. + if (m_AttachableNode && m_AttachState != AttachState.Detaching) + { + NetworkLog.LogError($"[{name}][Detach] Invalid state detected! {name} is still referencing {nameof(AttachableNode)} {m_AttachableNode.name}! Could {nameof(Detach)} be getting invoked more than once for the same instance?"); + } + return; + } + + ChangeReference(new NetworkBehaviourReference(null)); + } + + /// + /// Override this method to change how the instance determines the authority.
+ /// The default is to use the method. + ///
+ /// + /// Useful when using a network topology and you would like + /// to have the owner be the authority of this instance. + /// + /// true = has authoriy | false = does not have authority + protected virtual bool OnHasAuthority() + { + return HasAuthority; + } + + private void ChangeReference(NetworkBehaviourReference networkBehaviourReference) + { + // Update the attached node reference to the new attachable node. + m_AttachedNodeReference = networkBehaviourReference; + UpdateAttachedState(); + + if (OnHasAuthority()) + { + // Send notification of the change in this property's state. + UpdateAttachStateRpc(m_AttachedNodeReference); + } + } + + [Rpc(SendTo.NotMe)] + private void UpdateAttachStateRpc(NetworkBehaviourReference attachedNodeReference, RpcParams rpcParams = default) + { + ChangeReference(attachedNodeReference); + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableBehaviour.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableBehaviour.cs.meta new file mode 100644 index 0000000000..ade010ae67 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableBehaviour.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7aa87489bfc51d448940c66c2a2cf840 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableNode.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableNode.cs new file mode 100644 index 0000000000..c62e16085b --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableNode.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using Unity.Netcode; +using Unity.Netcode.Components; + + +/// +/// This component is used in conjunction with and is used to +/// denote a specific child that an +/// can attach itself to. +/// +/// +/// Primarily, the can be used as it is or can be extended to perform additional +/// logical operations when something attaches to or detaches from the instance. +/// +public class AttachableNode : NetworkBehaviour +{ + /// + /// Returns true if the instance has one or more attached components. + /// + public bool HasAttachments => m_AttachedBehaviours.Count > 0; + + /// + /// When enabled, any attached s will be automatically detached and re-parented under its original parent. + /// + public bool DetachOnDespawn = true; + + /// + /// A of the currently attached s. + /// + protected readonly List m_AttachedBehaviours = new List(); + + /// + /// + /// If the this belongs to is despawned, + /// then any attached will be detached during . + /// + public override void OnNetworkPreDespawn() + { + if (IsSpawned && HasAuthority && DetachOnDespawn) + { + for (int i = m_AttachedBehaviours.Count - 1; i >= 0; i--) + { + m_AttachedBehaviours[i]?.Detach(); + } + } + base.OnNetworkPreDespawn(); + } + + /// + public override void OnNetworkDespawn() + { + m_AttachedBehaviours.Clear(); + base.OnNetworkDespawn(); + } + + /// + /// Override this method to be notified when an has attached to this node. + /// + /// The that has been attached. + protected virtual void OnAttached(AttachableBehaviour attachableBehaviour) + { + + } + + internal void Attach(AttachableBehaviour attachableBehaviour) + { + if (m_AttachedBehaviours.Contains(attachableBehaviour)) + { + NetworkLog.LogError($"[{nameof(AttachableNode)}][{name}][Attach] {nameof(AttachableBehaviour)} {attachableBehaviour.name} is already attached!"); + return; + } + + m_AttachedBehaviours.Add(attachableBehaviour); + OnAttached(attachableBehaviour); + } + + /// + /// Override this method to be notified when an has detached from this node. + /// + /// The that has been detached. + protected virtual void OnDetached(AttachableBehaviour attachableBehaviour) + { + + } + + internal void Detach(AttachableBehaviour attachableBehaviour) + { + if (!m_AttachedBehaviours.Contains(attachableBehaviour)) + { + NetworkLog.LogError($"[{nameof(AttachableNode)}][{name}][Detach] {nameof(AttachableBehaviour)} {attachableBehaviour.name} is not attached!"); + return; + } + + m_AttachedBehaviours.Remove(attachableBehaviour); + OnDetached(attachableBehaviour); + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableNode.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableNode.cs.meta new file mode 100644 index 0000000000..dda530aca5 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b870dfbc4cccf6244b4ed1a9b379c701 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/ComponentController.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/ComponentController.cs new file mode 100644 index 0000000000..9c7cc43d5d --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/ComponentController.cs @@ -0,0 +1,529 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.CompilerServices; +#if UNITY_EDITOR +using System.Text.RegularExpressions; +#endif +using UnityEngine; +using Object = UnityEngine.Object; + + +namespace Unity.Netcode.Components +{ + /// + /// This is a serializable contianer class for entries. + /// + [Serializable] + public class ComponentControllerEntry + { + + // Ignoring the naming convention in order to auto-assign element names +#pragma warning disable IDE1006 + /// + /// Used for naming each element entry. + /// + [HideInInspector] + public string name; +#pragma warning restore IDE1006 + + /// + /// When true, this component's enabled state will be the inverse of the value passed into . + /// + [Tooltip("When enabled, this component will inversely mirror the currently applied ComponentController's enabled state.")] + public bool InvertEnabled; + + /// + /// The amount of time to delay enabling this component when the has just transitioned from a disabled to enabled state. + /// + /// + /// This can be useful under scenarios where you might want to prevent a component from being enabled too early prior to making any adjustments.
+ /// As an example, you might find that delaying the enabling of a until at least the next frame will avoid any single frame + /// rendering anomalies until the has updated the . + ///
+ [Range(0.0f, 2.0f)] + [Tooltip("The amount of time to delay when transitioning this component from disabled to enabled. When 0, the change is immediate.")] + public float EnableDelay; + + /// + /// The amount of time to delay disabling this component when the has just transitioned from an enabled to disabled state. + /// + /// + /// This can be useful under scenarios where you might want to prevent a component from being disabled too early prior to making any adjustments.
+ ///
+ [Tooltip("The amount of time to delay when transitioning this component from enabled to disabled. When 0, the change is immediate.")] + [Range(0f, 2.0f)] + public float DisableDelay; + + /// + /// The component that will have its enabled property synchronized. + /// + /// + /// You can assign an entire to this property which will add all components attached to the and its children. + /// + [Tooltip("The component that will have its enabled status synchonized. You can drop a GameObject onto this field and all valid components will be added to the list.")] + public Object Component; + internal PropertyInfo PropertyInfo; + + internal bool GetRelativeEnabled(bool enabled) + { + return InvertEnabled ? !enabled : enabled; + } + + private List m_PendingStateUpdates = new List(); + + /// + /// Invoke prior to setting the state. + /// + internal bool QueueForDelay(bool enabled) + { + var relativeEnabled = GetRelativeEnabled(enabled); + + if (relativeEnabled ? EnableDelay > 0.0f : DisableDelay > 0.0f) + { + // Start with no relative time offset + var relativeTimeOffset = 0.0f; + // If we have pending state updates, then get that time of the last state update + // and use that as the time to add this next state update. + if (m_PendingStateUpdates.Count > 0) + { + relativeTimeOffset = m_PendingStateUpdates[m_PendingStateUpdates.Count - 1].DelayTimeDelta; + } + + // We process backwards, so insert new entries at the front + m_PendingStateUpdates.Insert(0, new PendingStateUpdate(this, enabled, relativeTimeOffset)); + return true; + } + return false; + } + + internal void SetValue(bool isEnabled) + { + // If invert enabled is true, then use the inverted value passed in. + // Otherwise, directly apply the value passed in. + PropertyInfo.SetValue(Component, GetRelativeEnabled(isEnabled)); + } + + internal bool HasPendingStateUpdates() + { + for (int i = m_PendingStateUpdates.Count - 1; i >= 0; i--) + { + if (!m_PendingStateUpdates[i].CheckTimeDeltaDelay()) + { + m_PendingStateUpdates.RemoveAt(i); + continue; + } + } + return m_PendingStateUpdates.Count > 0; + } + + private class PendingStateUpdate + { + internal bool TimeDeltaDelayInProgress; + internal bool PendingState; + internal float DelayTimeDelta; + + internal ComponentControllerEntry ComponentControllerEntry; + + internal bool CheckTimeDeltaDelay() + { + if (!TimeDeltaDelayInProgress) + { + return false; + } + + var isDeltaDelayInProgress = DelayTimeDelta > Time.realtimeSinceStartup; + + if (!isDeltaDelayInProgress) + { + ComponentControllerEntry.SetValue(PendingState); + } + TimeDeltaDelayInProgress = isDeltaDelayInProgress; + return TimeDeltaDelayInProgress; + } + + internal PendingStateUpdate(ComponentControllerEntry componentControllerEntry, bool isEnabled, float relativeTimeOffset) + { + ComponentControllerEntry = componentControllerEntry; + // If there is a pending state, then add the delay to the end of the last pending state's. + var referenceTime = relativeTimeOffset > 0.0f ? relativeTimeOffset : Time.realtimeSinceStartup; + + if (ComponentControllerEntry.GetRelativeEnabled(isEnabled)) + { + DelayTimeDelta = referenceTime + ComponentControllerEntry.EnableDelay; + } + else + { + DelayTimeDelta = referenceTime + ComponentControllerEntry.DisableDelay; + } + TimeDeltaDelayInProgress = true; + PendingState = isEnabled; + } + } + } + + /// + /// Handles enabling or disabling commonly used components like , , , etc.
+ /// Anything that derives from and has an enabled property can be added to the list of objects.
+ /// NOTE: derived components are not allowed and will be automatically removed. + ///
+ /// + /// This will synchronize the enabled or disabled state of the s with connected and late joining clients.
+ /// - Use to determine the current synchronized enabled state.
+ /// - Use to change the enabled state and have the change applied to all components this is synchronizing.
+ /// It is encouraged to create custom derived versions of this class to provide any additional functionality required for your project specific needs. + ///
+ public class ComponentController : NetworkBehaviour + { + /// + /// Determines whether the selected s will start enabled or disabled when spawned. + /// + [Tooltip("The initial state of the component controllers enabled status when instnatiated.")] + public bool StartEnabled = true; + + /// + /// The list of s to be enabled and disabled. + /// + [Tooltip("The list of components to control. You can drag and drop an entire GameObject on this to include all components.")] + public List Components; + + /// + /// Returns the current enabled state of the . + /// + public bool EnabledState => m_IsEnabled; + + internal List ValidComponents = new List(); + private bool m_IsEnabled; + +#if UNITY_EDITOR + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsValidComponentType(Object component) + { + return !(component.GetType().IsSubclassOf(typeof(NetworkBehaviour)) || component.GetType() == typeof(NetworkObject) || component.GetType() == typeof(NetworkManager)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private string GetComponentNameFormatted(Object component) + { + // Split the class name up based on capitalization + var classNameDisplay = Regex.Replace(component.GetType().Name, "([A-Z])", " $1", RegexOptions.Compiled).Trim(); + return $"{component.name} ({classNameDisplay})"; + } + + /// + /// + /// Checks for invalid entries. + /// + protected virtual void OnValidate() + { + if (Components == null || Components.Count == 0) + { + return; + } + + var gameObjectsToScan = new List(); + // First pass is to verify all entries are valid and look for any GameObjects added as an entry to process next + for (int i = Components.Count - 1; i >= 0; i--) + { + if (Components[i] == null) + { + continue; + } + + if (Components[i].Component == null) + { + continue; + } + var componentType = Components[i].Component.GetType(); + if (componentType == typeof(GameObject)) + { + gameObjectsToScan.Add(Components[i]); + Components.RemoveAt(i); + continue; + } + + if (!IsValidComponentType(Components[i].Component)) + { + Debug.LogWarning($"Removing {GetComponentNameFormatted(Components[i].Component)} since {Components[i].Component.GetType().Name} is not an allowed component type."); + Components.RemoveAt(i); + continue; + } + + var propertyInfo = Components[i].Component.GetType().GetProperty("enabled", BindingFlags.Instance | BindingFlags.Public); + if (propertyInfo == null && propertyInfo.PropertyType != typeof(bool)) + { + Debug.LogWarning($"{Components[i].Component.name} does not contain a public enabled property! (Removing)"); + Components.RemoveAt(i); + } + } + + // Second pass is to process any GameObjects added. + // Scan the GameObject and all of its children and add all valid components to the list. + foreach (var entry in gameObjectsToScan) + { + var asGameObject = entry.Component as GameObject; + var components = asGameObject.GetComponentsInChildren(); + foreach (var component in components) + { + // Ignore any NetworkBehaviour derived, NetworkObject, or NetworkManager components + if (!IsValidComponentType(component)) + { + continue; + } + + var propertyInfo = component.GetType().GetProperty("enabled", BindingFlags.Instance | BindingFlags.Public); + if (propertyInfo != null && propertyInfo.PropertyType == typeof(bool)) + { + var componentEntry = new ComponentControllerEntry() + { + Component = component, + PropertyInfo = propertyInfo, + }; + Components.Add(componentEntry); + } + } + } + gameObjectsToScan.Clear(); + + // Final (third) pass is to name each list element item as the component is normally viewed in the inspector view. + for (int i = 0; i < Components.Count; i++) + { + if (!Components[i].Component) + { + continue; + } + Components[i].name = GetComponentNameFormatted(Components[i].Component); + } + } +#endif + + /// + protected override void OnSynchronize(ref BufferSerializer serializer) + { + // Example of how to synchronize late joining clients when using an RPC to update + // a local property's state. + if (serializer.IsWriter) + { + serializer.SerializeValue(ref m_IsEnabled); + } + else + { + serializer.SerializeValue(ref m_IsEnabled); + } + base.OnSynchronize(ref serializer); + } + + /// + /// This checks to make sure that all entries are valid and will create a final + /// list of valid entries. + /// + /// + /// If overriding this method, it is required that you invoke this base method. + /// + protected virtual void Awake() + { + ValidComponents.Clear(); + + // If no components then don't try to initialize. + if (Components == null) + { + return; + } + + var emptyEntries = 0; + + foreach (var entry in Components) + { + if (entry == null) + { + emptyEntries++; + continue; + } + var propertyInfo = entry.Component.GetType().GetProperty("enabled", BindingFlags.Instance | BindingFlags.Public); + if (propertyInfo != null && propertyInfo.PropertyType == typeof(bool)) + { + entry.PropertyInfo = propertyInfo; + ValidComponents.Add(entry); + } + else + { + NetworkLog.LogWarning($"{name} does not contain a public enable property! (Ignoring)"); + } + } + if (emptyEntries > 0) + { + NetworkLog.LogWarning($"{name} has {emptyEntries} emtpy(null) entries in the {nameof(Components)} list!"); + } + + // Apply the initial state of all components this instance is controlling. + InitializeComponents(); + } + + /// + /// + /// If overriding this method, it is required that you invoke this base method. + /// + public override void OnNetworkSpawn() + { + if (OnHasAuthority()) + { + m_IsEnabled = StartEnabled; + } + base.OnNetworkSpawn(); + } + + /// + /// + /// If overriding this method, it is required that you invoke this base method.
+ /// Assures all instances subscribe to the internal of type + /// that synchronizes all instances when s are enabled + /// or disabled. + ///
+ protected override void OnNetworkPostSpawn() + { + ApplyEnabled(); + base.OnNetworkPostSpawn(); + } + + /// + /// + /// If overriding this method, it is required that you invoke this base method. + /// + public override void OnDestroy() + { + if (m_CoroutineObject.IsRunning) + { + StopCoroutine(m_CoroutineObject.Coroutine); + m_CoroutineObject.IsRunning = false; + } + base.OnDestroy(); + } + + /// + /// Initializes each component entry to its initial state. + /// + private void InitializeComponents() + { + foreach (var entry in ValidComponents) + { + // If invert enabled is true, then use the inverted value passed in. + // Otherwise, directly apply the value passed in. + var isEnabled = entry.InvertEnabled ? !StartEnabled : StartEnabled; + entry.PropertyInfo.SetValue(entry.Component, isEnabled); + } + } + + /// + /// Applies states changes to all components being controlled by this instance. + /// + /// the state update to apply + private void ApplyEnabled() + { + foreach (var entry in ValidComponents) + { + if (entry.QueueForDelay(m_IsEnabled)) + { + if (!m_CoroutineObject.IsRunning) + { + m_CoroutineObject.Coroutine = StartCoroutine(PendingAppliedState()); + m_CoroutineObject.IsRunning = true; + } + } + else + { + entry.SetValue(m_IsEnabled); + } + } + } + + private class CoroutineObject + { + public Coroutine Coroutine; + public bool IsRunning; + } + + private CoroutineObject m_CoroutineObject = new CoroutineObject(); + + + private IEnumerator PendingAppliedState() + { + var continueProcessing = true; + + while (continueProcessing) + { + continueProcessing = false; + foreach (var entry in ValidComponents) + { + if (entry.HasPendingStateUpdates()) + { + continueProcessing = true; + } + } + if (continueProcessing) + { + yield return null; + } + } + m_CoroutineObject.IsRunning = false; + } + + /// + /// Invoke on the authority side to enable or disable components assigned to this instance. + /// + /// + /// If any component entry has the set to true, + /// then the inverse of the isEnabled property passed in will be used. If the component entry has the + /// set to false (default), then the value of the + /// isEnabled property will be applied. + /// + /// true = enabled | false = disabled + public void SetEnabled(bool isEnabled) + { + if (!IsSpawned) + { + Debug.Log($"[{name}] Must be spawned to use {nameof(SetEnabled)}!"); + return; + } + + if (!OnHasAuthority()) + { + Debug.Log($"[Client-{NetworkManager.LocalClientId}] Attempting to invoke {nameof(SetEnabled)} without authority!"); + return; + } + ChangeEnabled(isEnabled); + } + + private void ChangeEnabled(bool isEnabled) + { + m_IsEnabled = isEnabled; + ApplyEnabled(); + + if (OnHasAuthority()) + { + ToggleEnabledRpc(m_IsEnabled); + } + } + + /// + /// Override this method to change how the instance determines the authority.
+ /// The default is to use the method. + ///
+ /// + /// Useful when using a network topology and you would like + /// to have the owner be the authority of this instance. + /// + /// true = has authoriy | false = does not have authority + protected virtual bool OnHasAuthority() + { + return HasAuthority; + } + + [Rpc(SendTo.NotMe)] + private void ToggleEnabledRpc(bool enabled, RpcParams rpcParams = default) + { + ChangeEnabled(enabled); + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/ComponentController.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/ComponentController.cs.meta new file mode 100644 index 0000000000..e7482640fa --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/ComponentController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d0b25b3f95e5324abc80c09cb29f271 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs index 96cd6b79e0..d368465cf3 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs @@ -1121,7 +1121,7 @@ private void ApplyFixedJoint(NetworkRigidbodyBase bodyToConnectTo, Vector3 posit /// - This instance can be viewed as the child. /// - The can be viewed as the parent. ///
- /// This is the recommended way, as opposed to parenting, to attached/detatch two rigid bodies to one another when is enabled. + /// This is the recommended way, as opposed to parenting, to attached/detach two rigid bodies to one another when is enabled. /// For more details on using and . ///
/// This provides a simple joint solution between two rigid bodies and serves as an example. You can add different joint types by creating a customized/derived @@ -1187,7 +1187,7 @@ private void RemoveFromParentBody() #if COM_UNITY_MODULES_PHYSICS2D [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void DetatchFromFixedJoint2D() + private void DetachFromFixedJoint2D() { if (FixedJoint2D == null) { @@ -1206,7 +1206,7 @@ private void DetatchFromFixedJoint2D() #endif #if COM_UNITY_MODULES_PHYSICS [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void DetatchFromFixedJoint3D() + private void DetachFromFixedJoint3D() { if (FixedJoint == null) { @@ -1227,7 +1227,7 @@ private void DetatchFromFixedJoint3D() /// this will detach from the fixed joint and destroy the fixed joint component. /// /// - /// This is the recommended way, as opposed to parenting, to attached/detatch two rigid bodies to one another when is enabled. + /// This is the recommended way, as opposed to parenting, to attached/detach two rigid bodies to one another when is enabled. /// public void DetachFromFixedJoint() { @@ -1240,18 +1240,18 @@ public void DetachFromFixedJoint() #if COM_UNITY_MODULES_PHYSICS && COM_UNITY_MODULES_PHYSICS2D if (m_IsRigidbody2D) { - DetatchFromFixedJoint2D(); + DetachFromFixedJoint2D(); } else { - DetatchFromFixedJoint3D(); + DetachFromFixedJoint3D(); } #endif #if COM_UNITY_MODULES_PHYSICS && !COM_UNITY_MODULES_PHYSICS2D - DetatchFromFixedJoint3D(); + DetachFromFixedJoint3D(); #endif #if !COM_UNITY_MODULES_PHYSICS && COM_UNITY_MODULES_PHYSICS2D - DetatchFromFixedJoint2D(); + DetachFromFixedJoint2D(); #endif } } diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs index 62e60fb436..73696fa0a4 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkBehaviour.cs @@ -737,6 +737,11 @@ protected virtual void OnInSceneObjectsSpawned() { } /// public virtual void OnNetworkDespawn() { } + /// + /// Gets called before has been invoked for all s associated with the currently spawned instance. + /// + public virtual void OnNetworkPreDespawn() { } + internal void NetworkPreSpawn(ref NetworkManager networkManager) { try @@ -816,6 +821,18 @@ internal void InSceneNetworkObjectsSpawned() } } + internal void InternalOnNetworkPreDespawn() + { + try + { + OnNetworkPreDespawn(); + } + catch (Exception e) + { + Debug.LogException(e); + } + } + internal void InternalOnNetworkDespawn() { IsSpawned = false; diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index cff2768405..3c6662b7be 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1552,12 +1552,27 @@ internal void ShutdownInternal() DeferredMessageManager?.CleanupAllTriggers(); CustomMessagingManager = null; - RpcTarget?.Dispose(); - RpcTarget = null; - BehaviourUpdater?.Shutdown(); BehaviourUpdater = null; + /// Despawning upon shutdown + + // We need to clean up NetworkObjects before we reset the IsServer + // and IsClient properties. This provides consistency of these two + // property values for NetworkObjects that are still spawned when + // the shutdown cycle begins. + + // We need to handle despawning prior to shutting down the connection + // manager or disposing of the RpcTarget so any final updates can take + // place (i.e. sending any last state updates or the like). + + SpawnManager?.DespawnAndDestroyNetworkObjects(); + SpawnManager?.ServerResetShudownStateForSceneObjects(); + //// + + RpcTarget?.Dispose(); + RpcTarget = null; + // Shutdown connection manager last which shuts down transport ConnectionManager.Shutdown(); @@ -1567,17 +1582,12 @@ internal void ShutdownInternal() MessageManager = null; } - // We need to clean up NetworkObjects before we reset the IsServer - // and IsClient properties. This provides consistency of these two - // property values for NetworkObjects that are still spawned when - // the shutdown cycle begins. - SpawnManager?.DespawnAndDestroyNetworkObjects(); - SpawnManager?.ServerResetShudownStateForSceneObjects(); - SpawnManager = null; - // Let the NetworkSceneManager clean up its two SceneEvenData instances SceneManager?.Dispose(); SceneManager = null; + + SpawnManager = null; + IsListening = false; m_ShuttingDown = false; diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs index 94308a2839..754b83653d 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs @@ -218,7 +218,7 @@ private static void CheckPrefabStage(PrefabStage prefabStage) s_PrefabAsset = AssetDatabase.LoadAssetAtPath(s_PrefabStage.assetPath); } - if (s_PrefabInstance.GlobalObjectIdHash != s_PrefabAsset.GlobalObjectIdHash) + if (s_PrefabAsset && s_PrefabInstance && s_PrefabInstance.GlobalObjectIdHash != s_PrefabAsset.GlobalObjectIdHash) { s_PrefabInstance.GlobalObjectIdHash = s_PrefabAsset.GlobalObjectIdHash; // For InContext mode, we don't want to record these modifications (the in-scene GlobalObjectIdHash is serialized with the scene). @@ -2604,6 +2604,12 @@ internal void InternalInSceneNetworkObjectsSpawned() internal void InvokeBehaviourNetworkDespawn() { + // Invoke OnNetworkPreDespawn on all child behaviours + for (int i = 0; i < ChildNetworkBehaviours.Count; i++) + { + ChildNetworkBehaviours[i].InternalOnNetworkPreDespawn(); + } + NetworkManager.SpawnManager.UpdateOwnershipTable(this, OwnerClientId, true); NetworkManager.SpawnManager.RemoveNetworkObjectFromSceneChangedUpdates(this); diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index 92b90972a7..d9a571b97b 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -1474,16 +1474,8 @@ internal void DespawnAndDestroyNetworkObjects() } } - // If spawned, then despawn and potentially destroy. - if (networkObjects[i].IsSpawned) - { - OnDespawnObject(networkObjects[i], shouldDestroy); - } - else // Otherwise, if we are not spawned and we should destroy...then destroy. - if (shouldDestroy) - { - UnityEngine.Object.Destroy(networkObjects[i].gameObject); - } + //Despawn and potentially destroy. + OnDespawnObject(networkObjects[i], shouldDestroy); } } } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs new file mode 100644 index 0000000000..af0568ab7f --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs @@ -0,0 +1,433 @@ +using System.Collections; +using System.Collections.Generic; +using System.Text; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + [TestFixture(HostOrServer.Host)] + [TestFixture(HostOrServer.Server)] + [TestFixture(HostOrServer.DAHost)] + internal class AttachableBehaviourTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 2; + + public AttachableBehaviourTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + private GameObject m_SourcePrefab; + private GameObject m_TargetPrefabA; + private GameObject m_TargetPrefabB; + + /// + /// All of the below instances belong to the authority + /// + private ulong m_TargetInstanceId; + private NetworkObject m_SourceInstance; + private NetworkObject m_TargetInstance; + private NetworkObject m_TargetInstanceB; + private TestAttachable m_AttachableBehaviourInstance; + private TestNode m_AttachableNodeInstance; + private TestNode m_AttachableNodeInstanceB; + + private bool m_UseTargetB; + + private StringBuilder m_ErrorLog = new StringBuilder(); + + protected override IEnumerator OnSetup() + { + m_ErrorLog.Clear(); + return base.OnSetup(); + } + + protected override void OnServerAndClientsCreated() + { + // The source prefab contains the nested NetworkBehaviour that + // will be parented under the target prefab. + m_SourcePrefab = CreateNetworkObjectPrefab("Source"); + m_SourcePrefab.GetComponent().DontDestroyWithOwner = true; + // The target prefab that the source prefab will attach + // will be parented under the target prefab. + m_TargetPrefabA = CreateNetworkObjectPrefab("TargetA"); + m_TargetPrefabB = CreateNetworkObjectPrefab("TargetB"); + var sourceChild = new GameObject("SourceChild"); + var targetChildA = new GameObject("TargetChildA"); + var targetChildB = new GameObject("TargetChildB"); + sourceChild.transform.parent = m_SourcePrefab.transform; + targetChildA.transform.parent = m_TargetPrefabA.transform; + targetChildB.transform.parent = m_TargetPrefabB.transform; + + sourceChild.AddComponent(); + targetChildA.AddComponent(); + targetChildB.AddComponent(); + base.OnServerAndClientsCreated(); + } + + private NetworkObject GetTargetInstance() + { + return m_UseTargetB ? m_TargetInstanceB : m_TargetInstance; + } + + private bool AllClientsSpawnedInstances() + { + m_ErrorLog.Clear(); + foreach (var networkManager in m_NetworkManagers) + { + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_SourceInstance.NetworkObjectId)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has not spawned {m_SourceInstance.name} yet!"); + } + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_TargetInstance.NetworkObjectId)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has not spawned {m_TargetInstance.name} yet!"); + } + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_TargetInstanceB.NetworkObjectId)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has not spawned {m_TargetInstanceB.name} yet!"); + } + } + return m_ErrorLog.Length == 0; + } + + private bool ResetAllStates() + { + m_ErrorLog.Clear(); + var target = GetTargetInstance(); + + + // The attachable can move between the two spawned instances. + var currentAttachableRoot = m_AttachableBehaviourInstance.State == AttachableBehaviour.AttachState.Attached ? target : m_SourceInstance; + + foreach (var networkManager in m_NetworkManagers) + { + // Source + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_SourceInstance.NetworkObjectId)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has no spawned instance of {currentAttachableRoot.name}!"); + } + else + { + var attachable = networkManager.SpawnManager.SpawnedObjects[currentAttachableRoot.NetworkObjectId].GetComponentInChildren(); + attachable.ResetStates(); + } + + // Target + if (m_TargetInstance && !networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_TargetInstance.NetworkObjectId)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has no spawned instance of {m_TargetInstance.name}!"); + } + else + { + var node = networkManager.SpawnManager.SpawnedObjects[m_TargetInstance.NetworkObjectId].GetComponentInChildren(); + node.ResetStates(); + } + + // Target B + if (m_TargetInstanceB && !networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_TargetInstanceB.NetworkObjectId)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has no spawned instance of {m_TargetInstanceB.name}!"); + } + else + { + var node = networkManager.SpawnManager.SpawnedObjects[m_TargetInstanceB.NetworkObjectId].GetComponentInChildren(); + node.ResetStates(); + } + } + return m_ErrorLog.Length == 0; + } + + private bool AllInstancesAttachedStateChanged(bool checkAttached, bool ignoreIfDespawned = false) + { + m_ErrorLog.Clear(); + var target = GetTargetInstance(); + var targetId = target == null ? m_TargetInstanceId : target.NetworkObjectId; + // The attachable can move between the two spawned instances so we have to use the appropriate one depending upon the authority's current state. + var currentAttachableRoot = m_AttachableBehaviourInstance.State == AttachableBehaviour.AttachState.Attached ? target : m_SourceInstance; + var attachable = (TestAttachable)null; + var node = (TestNode)null; + foreach (var networkManager in m_NetworkManagers) + { + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(currentAttachableRoot.NetworkObjectId)) + { + if (!ignoreIfDespawned) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has no spawned instance of {currentAttachableRoot.name}!"); + } + continue; + } + else + { + attachable = networkManager.SpawnManager.SpawnedObjects[currentAttachableRoot.NetworkObjectId].GetComponentInChildren(); + } + + if (!attachable) + { + attachable = networkManager.SpawnManager.SpawnedObjects[m_TargetInstanceId].GetComponentInChildren(); + if (!attachable) + { + attachable = networkManager.SpawnManager.SpawnedObjects[m_TargetInstanceB.NetworkObjectId].GetComponentInChildren(); + if (!attachable) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}][Attachable] Attachable was not found!"); + } + } + continue; + } + + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(targetId)) + { + if (!ignoreIfDespawned) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has no spawned instance of {target.name}!"); + } + continue; + } + else + { + node = networkManager.SpawnManager.SpawnedObjects[targetId].GetComponentInChildren(); + } + + if (!node && ignoreIfDespawned) + { + VerboseDebug("Skipping check during despawn."); + continue; + } + + if (!attachable.CheckStateChangedOverride(checkAttached, false, node)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}][{attachable.name}] Did not have its override invoked!"); + } + if (!attachable.CheckStateChangedOverride(checkAttached, true, node)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}][{attachable.name}] Did not have its event invoked!"); + } + if ((checkAttached && !node.OnAttachedInvoked) || (!checkAttached && !node.OnDetachedInvoked)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}][{node.name}] Did not have its override invoked!"); + } + if (checkAttached && attachable.transform.parent != node.transform) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}][{attachable.name}] {node.name} is not the parent of {attachable.name}!"); + } + else if (!checkAttached && attachable.transform.parent != attachable.DefaultParent.transform) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}][{attachable.name}] {attachable.DefaultParent.name} is not the parent of {attachable.name}!"); + } + } + return m_ErrorLog.Length == 0; + } + + private bool AllInstancesDespawned() + { + foreach (var networkManager in m_NetworkManagers) + { + if (networkManager.SpawnManager != null && networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_TargetInstanceId)) + { + return false; + } + } + return true; + } + + [UnityTest] + public IEnumerator AttachAndDetachTests() + { + var authority = GetAuthorityNetworkManager(); + m_SourceInstance = SpawnObject(m_SourcePrefab, authority).GetComponent(); + m_TargetInstance = SpawnObject(m_TargetPrefabA, authority).GetComponent(); + m_TargetInstanceB = SpawnObject(m_TargetPrefabB, authority).GetComponent(); + m_TargetInstanceId = m_TargetInstance.NetworkObjectId; + yield return WaitForConditionOrTimeOut(AllClientsSpawnedInstances); + AssertOnTimeout($"Timed out waiting for all clients to spawn {m_SourceInstance.name} and {m_TargetInstance.name}!\n {m_ErrorLog}"); + + m_AttachableBehaviourInstance = m_SourceInstance.GetComponentInChildren(); + Assert.NotNull(m_AttachableBehaviourInstance, $"{m_SourceInstance.name} does not have a nested child {nameof(AttachableBehaviour)}!"); + + m_AttachableNodeInstance = m_TargetInstance.GetComponentInChildren(); + Assert.NotNull(m_AttachableNodeInstance, $"{m_TargetInstance.name} does not have a nested child {nameof(AttachableNode)}!"); + + m_AttachableNodeInstanceB = m_TargetInstanceB.GetComponentInChildren(); + Assert.NotNull(m_AttachableNodeInstanceB, $"{m_TargetInstanceB.name} does not have a nested child {nameof(AttachableNode)}!"); + + Assert.True(ResetAllStates(), $"Failed to reset all states!\n {m_ErrorLog}"); + m_AttachableBehaviourInstance.Attach(m_AttachableNodeInstance); + + yield return WaitForConditionOrTimeOut(() => AllInstancesAttachedStateChanged(true)); + AssertOnTimeout($"Timed out waiting for all clients to attach {m_AttachableBehaviourInstance.name} to {m_AttachableNodeInstance.name}!\n {m_ErrorLog}"); + + // Wait a brief period of time + yield return s_DefaultWaitForTick; + + // Now late join a client to make sure it synchronizes properly + yield return CreateAndStartNewClient(); + yield return WaitForConditionOrTimeOut(() => AllInstancesAttachedStateChanged(true)); + AssertOnTimeout($"Timed out waiting for all clients to attach {m_AttachableBehaviourInstance.name} to {m_AttachableNodeInstance.name}!\n {m_ErrorLog}"); + + // Wait a brief period of time + yield return s_DefaultWaitForTick; + + // Reset all states and prepare for 2nd attach test + Assert.True(ResetAllStates(), $"Failed to reset all states!\n {m_ErrorLog}"); + + // Now, while attached, attach to another attachable node which should detach from the current and attach to the new. + m_AttachableBehaviourInstance.Attach(m_AttachableNodeInstanceB); + + // The attachable should detach from the current AttachableNode first + yield return WaitForConditionOrTimeOut(() => AllInstancesAttachedStateChanged(false)); + AssertOnTimeout($"Timed out waiting for all clients to detach {m_AttachableBehaviourInstance.name} from {m_AttachableNodeInstance.name}!\n {m_ErrorLog}"); + + // Switch the conditional to check the target B attachable node + m_UseTargetB = true; + + // Then the attachable should attach to the target B attachable node + yield return WaitForConditionOrTimeOut(() => AllInstancesAttachedStateChanged(true)); + AssertOnTimeout($"Timed out waiting for all clients to attach {m_AttachableBehaviourInstance.name} to {m_AttachableNodeInstanceB.name}!\n {m_ErrorLog}"); + + // Reset all states and prepare for final detach test + Assert.True(ResetAllStates(), $"Failed to reset all states!\n {m_ErrorLog}"); + + // Now verify complete detaching works + m_AttachableBehaviourInstance.Detach(); + yield return WaitForConditionOrTimeOut(() => AllInstancesAttachedStateChanged(false)); + AssertOnTimeout($"Timed out waiting for all clients to detach {m_AttachableBehaviourInstance.name} from {m_AttachableNodeInstance.name}!\n {m_ErrorLog}"); + + // Finally, re-attach to the original spawned instance + Assert.True(ResetAllStates(), $"Failed to reset all states!\n {m_ErrorLog}"); + m_AttachableBehaviourInstance.Attach(m_AttachableNodeInstance); + + // Switch back to using the first target attachable node + m_UseTargetB = false; + + yield return WaitForConditionOrTimeOut(() => AllInstancesAttachedStateChanged(true)); + AssertOnTimeout($"[Despawn Detach Phase] Timed out waiting for all clients to attach {m_AttachableBehaviourInstance.name} to {m_AttachableNodeInstance.name}!\n {m_ErrorLog}"); + + var targetInstanceName = m_TargetInstance.name; + VerboseDebug("======== DESPAWN & DETACH ========"); + m_TargetInstance.Despawn(); + m_TargetInstance = null; + yield return WaitForConditionOrTimeOut(() => AllInstancesAttachedStateChanged(false, true)); + AssertOnTimeout($"[Despawn Detach Phase] Timed out waiting for all clients to detach {m_AttachableBehaviourInstance.name} from {targetInstanceName}!\n {m_ErrorLog}"); + + yield return WaitForConditionOrTimeOut(AllInstancesDespawned); + AssertOnTimeout($"[Despawn Detach Phase] Timed out waiting for all clients to despawn {targetInstanceName}!"); + } + + /// + /// Helps to validate that the overrides and events are invoked when an attachable attaches or detaches from the instance. + /// This also helps to validate that the appropriate instance is passed in as a parameter. + /// + public class TestAttachable : AttachableBehaviour + { + private Dictionary m_StateUpdates = new Dictionary(); + + private Dictionary m_StateUpdateEvents = new Dictionary(); + + public GameObject DefaultParent => m_DefaultParent; + public AttachState State => m_AttachState; + + public override void OnNetworkSpawn() + { + AttachStateChange += OnAttachStateChangeEvent; + name = $"{name}-{NetworkManager.LocalClientId}"; + base.OnNetworkSpawn(); + } + + public override void OnNetworkDespawn() + { + AttachStateChange -= OnAttachStateChangeEvent; + base.OnNetworkDespawn(); + } + + private void OnAttachStateChangeEvent(AttachState attachState, AttachableNode attachableNode) + { + m_StateUpdateEvents.Add(attachState, attachableNode); + } + + protected override void OnAttachStateChanged(AttachState attachState, AttachableNode attachableNode) + { + m_StateUpdates.Add(attachState, attachableNode); + base.OnAttachStateChanged(attachState, attachableNode); + } + + public void ResetStates() + { + m_StateUpdates.Clear(); + m_StateUpdateEvents.Clear(); + } + + private void Log(string message) + { + Debug.Log($"[{name}] {message}"); + } + + public bool CheckStateChangedOverride(bool checkAttached, bool checkEvent, AttachableNode attachableNode) + { + var tableToCheck = checkEvent ? m_StateUpdateEvents : m_StateUpdates; + var checkStatus = checkAttached ? (tableToCheck.ContainsKey(AttachState.Attaching) && tableToCheck.ContainsKey(AttachState.Attached)) : + (tableToCheck.ContainsKey(AttachState.Detaching) && tableToCheck.ContainsKey(AttachState.Detached)); + if (checkStatus) + { + foreach (var entry in tableToCheck) + { + // Ignore any states that don't match what is being checked + if ((checkStatus && (entry.Key == AttachState.Detaching || entry.Key == AttachState.Detached)) || + (!checkStatus && (entry.Key == AttachState.Attaching || entry.Key == AttachState.Attached))) + { + continue; + } + + // Special case for completely detached + if (entry.Key == AttachState.Detached) + { + if (entry.Value != null) + { + Log($"[Value] The value {entry.Value.name} is not null!"); + checkStatus = false; + break; + } + } + else if (entry.Value != attachableNode) + { + var attachableName = attachableNode == null ? "null" : attachableNode.name; + var entryName = entry.Value == null ? "null" : entry.Value.name; + Log($"[{entry.Key}][Value] The value {entryName} is not the same as {attachableName}!"); + checkStatus = false; + break; + } + } + } + return checkStatus; + } + } + + /// + /// Helps to validate that the overrides are invoked when an attachable attaches or detaches from the instance. + /// + public class TestNode : AttachableNode + { + public bool OnAttachedInvoked { get; private set; } + public bool OnDetachedInvoked { get; private set; } + + public void ResetStates() + { + OnAttachedInvoked = false; + OnDetachedInvoked = false; + } + + protected override void OnAttached(AttachableBehaviour attachableBehaviour) + { + OnAttachedInvoked = true; + base.OnAttached(attachableBehaviour); + } + + protected override void OnDetached(AttachableBehaviour attachableBehaviour) + { + OnDetachedInvoked = true; + base.OnDetached(attachableBehaviour); + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs.meta new file mode 100644 index 0000000000..5e7eb6db85 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 016a03eb97e603345a44bca4defacf24 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/ComponentControllerTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/ComponentControllerTests.cs new file mode 100644 index 0000000000..7d2d6e86cb --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/ComponentControllerTests.cs @@ -0,0 +1,191 @@ +using System.Collections; +using System.Collections.Generic; +using System.Text; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + [TestFixture(HostOrServer.Host)] + [TestFixture(HostOrServer.Server)] + [TestFixture(HostOrServer.DAHost)] + internal class ComponentControllerTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 2; + + private StringBuilder m_ErrorLog = new StringBuilder(); + private GameObject m_TestPrefab; + + private NetworkManager m_Authority; + private ComponentController m_AuthorityController; + + public ComponentControllerTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override IEnumerator OnSetup() + { + m_ErrorLog.Clear(); + yield return base.OnSetup(); + } + + protected override void OnServerAndClientsCreated() + { + // The source prefab contains the nested NetworkBehaviour that + // will be parented under the target prefab. + m_TestPrefab = CreateNetworkObjectPrefab("TestObject"); + var sourceChild = new GameObject("Child"); + sourceChild.transform.parent = m_TestPrefab.transform; + var meshRenderer = sourceChild.AddComponent(); + var light = sourceChild.AddComponent(); + var controller = m_TestPrefab.AddComponent(); + controller.Components = new List + { + new ComponentControllerEntry() + { + Component = meshRenderer, + }, + new ComponentControllerEntry() + { + InvertEnabled = true, + Component = light, + } + }; + base.OnServerAndClientsCreated(); + } + + private bool AllClientsSpawnedInstances() + { + m_ErrorLog.Clear(); + foreach (var networkManager in m_NetworkManagers) + { + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_AuthorityController.NetworkObjectId)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has not spawned {m_AuthorityController.name} yet!"); + } + } + return m_ErrorLog.Length == 0; + } + + private void ControllerStateMatches(ComponentController controller) + { + if (m_AuthorityController.EnabledState != controller.EnabledState) + { + m_ErrorLog.AppendLine($"[Client-{controller.NetworkManager.LocalClientId}] The authority controller state ({m_AuthorityController.EnabledState})" + + $" does not match the local controller state ({controller.EnabledState})!"); + return; + } + + if (m_AuthorityController.ValidComponents.Count != controller.ValidComponents.Count) + { + m_ErrorLog.AppendLine($"[Client-{controller.NetworkManager.LocalClientId}] The authority controller has {m_AuthorityController.ValidComponents.Count} valid components but " + + $"the local instance has {controller.ValidComponents.Count}!"); + return; + } + + for (int i = 0; i < m_AuthorityController.ValidComponents.Count; i++) + { + var authorityEntry = m_AuthorityController.ValidComponents[i]; + var nonAuthorityEntry = controller.ValidComponents[i]; + if (authorityEntry.InvertEnabled != nonAuthorityEntry.InvertEnabled) + { + m_ErrorLog.AppendLine($"[Client-{controller.NetworkManager.LocalClientId}] The authority controller's component entry ({i}) " + + $"has an inverted state of {authorityEntry.InvertEnabled} but the local instance has a value of " + + $"{nonAuthorityEntry.InvertEnabled}!"); + } + + var authorityIsEnabled = (bool)authorityEntry.PropertyInfo.GetValue(authorityEntry.Component); + var nonAuthorityIsEnabled = (bool)nonAuthorityEntry.PropertyInfo.GetValue(authorityEntry.Component); + if (authorityIsEnabled != nonAuthorityIsEnabled) + { + m_ErrorLog.AppendLine($"[Client-{controller.NetworkManager.LocalClientId}] The authority controller's component ({i}) " + + $"entry's enabled state is {authorityIsEnabled} but the local instance's value is {nonAuthorityIsEnabled}!"); + } + } + } + + private bool AllComponentStatesMatch() + { + m_ErrorLog.Clear(); + foreach (var networkManager in m_NetworkManagers) + { + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_AuthorityController.NetworkObjectId)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Does not have a spawned instance of {m_AuthorityController.name}!"); + } + var controller = networkManager.SpawnManager.SpawnedObjects[m_AuthorityController.NetworkObjectId].GetComponent(); + ControllerStateMatches(controller); + } + return m_ErrorLog.Length == 0; + } + + private bool AllComponentStatesAreCorrect(bool isEnabled) + { + m_ErrorLog.Clear(); + foreach (var networkManager in m_NetworkManagers) + { + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_AuthorityController.NetworkObjectId)) + { + m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Does not have a spawned instance of {m_AuthorityController.name}!"); + } + var controller = networkManager.SpawnManager.SpawnedObjects[m_AuthorityController.NetworkObjectId].GetComponent(); + for (int i = 0; i < controller.ValidComponents.Count; i++) + { + var componentEntry = controller.ValidComponents[i]; + + var componentEntryIsEnabled = (bool)componentEntry.PropertyInfo.GetValue(componentEntry.Component); + var valueToCheck = componentEntry.InvertEnabled ? !isEnabled : isEnabled; + + if (valueToCheck != componentEntryIsEnabled) + { + m_ErrorLog.AppendLine($"[Client-{controller.NetworkManager.LocalClientId}] The enabled state for entry ({i}) " + + $"should be {valueToCheck} but is {componentEntryIsEnabled}!"); + } + } + } + return m_ErrorLog.Length == 0; + } + + [UnityTest] + public IEnumerator EnabledDisabledSynchronizationTests() + { + m_Authority = GetAuthorityNetworkManager(); + + m_AuthorityController = SpawnObject(m_TestPrefab, m_Authority).GetComponent(); + + yield return WaitForConditionOrTimeOut(AllClientsSpawnedInstances); + AssertOnTimeout($"All clients did not spawn an instance of {m_AuthorityController.name}!\n {m_ErrorLog}"); + + // Validate that clients start off with matching states. + yield return WaitForConditionOrTimeOut(AllComponentStatesMatch); + AssertOnTimeout($"Not all client instances matched the authority instance {m_AuthorityController.name}! \n {m_ErrorLog}"); + + // Validate that all controllers have the correct enabled value for the current authority controller instance's value. + yield return WaitForConditionOrTimeOut(() => AllComponentStatesAreCorrect(m_AuthorityController.EnabledState)); + AssertOnTimeout($"Not all client instances have the correct enabled state!\n {m_ErrorLog}"); + + // Toggle the enabled state of the authority controller + m_AuthorityController.SetEnabled(!m_AuthorityController.EnabledState); + + // Validate that all controllers' states match + yield return WaitForConditionOrTimeOut(AllComponentStatesMatch); + AssertOnTimeout($"Not all client instances matched the authority instance {m_AuthorityController.name}! \n {m_ErrorLog}"); + + // Validate that all controllers have the correct enabled value for the current authority controller instance's value. + yield return WaitForConditionOrTimeOut(() => AllComponentStatesAreCorrect(m_AuthorityController.EnabledState)); + AssertOnTimeout($"Not all client instances have the correct enabled state!\n {m_ErrorLog}"); + + // Late join a client to assure the late joining client's values are synchronized properly + yield return CreateAndStartNewClient(); + + // Validate that all controllers' states match + yield return WaitForConditionOrTimeOut(AllComponentStatesMatch); + AssertOnTimeout($"Not all client instances matched the authority instance {m_AuthorityController.name}! \n {m_ErrorLog}"); + + // Validate that all controllers have the correct enabled value for the current authority controller instance's value. + yield return WaitForConditionOrTimeOut(() => AllComponentStatesAreCorrect(m_AuthorityController.EnabledState)); + AssertOnTimeout($"Not all client instances have the correct enabled state!\n {m_ErrorLog}"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/ComponentControllerTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/ComponentControllerTests.cs.meta new file mode 100644 index 0000000000..c5cb8ed883 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/ComponentControllerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 22e1625a8e8c9a24ab0408e95a5250a9 \ No newline at end of file