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..260257bfa4
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableBehaviour.cs
@@ -0,0 +1,373 @@
+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.
+ ///
+ 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 NetworkVariable m_AttachedNodeReference = new NetworkVariable(new NetworkBehaviourReference(null));
+ private Vector3 m_OriginalLocalPosition;
+ private Quaternion m_OriginalLocalRotation;
+
+ ///
+ /// 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 must invoke
+ /// this base instance of .
+ ///
+ protected override void OnNetworkPostSpawn()
+ {
+ if (HasAuthority)
+ {
+ m_AttachedNodeReference.Value = new NetworkBehaviourReference(null);
+ }
+ m_AttachedNodeReference.OnValueChanged += OnAttachedNodeReferenceChanged;
+ base.OnNetworkPostSpawn();
+ }
+
+ ///
+ ///
+ /// 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()
+ {
+ m_AttachedNodeReference.OnValueChanged -= OnAttachedNodeReferenceChanged;
+ InternalDetach();
+ if (NetworkManager && !NetworkManager.ShutdownInProgress)
+ {
+ // Notify of the changed attached state
+ UpdateAttachState(m_AttachState, m_AttachableNode);
+ }
+ base.OnNetworkDespawn();
+ }
+
+ private void OnAttachedNodeReferenceChanged(NetworkBehaviourReference previous, NetworkBehaviourReference current)
+ {
+ UpdateAttachedState();
+ }
+
+ private void UpdateAttachedState()
+ {
+ var attachableNode = (AttachableNode)null;
+ var shouldParent = m_AttachedNodeReference.Value.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);
+ m_AttachableNode.Detach(this);
+ transform.parent = null;
+ UpdateAttachState(AttachState.Detached, 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);
+ }
+
+ ///
+ /// 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 detatched.
+ 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)
+ {
+ if (attachableNode.NetworkManager != NetworkManager)
+ {
+ Debug.Log("Blam!");
+ }
+ 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;
+ }
+
+ // Update the attached node reference to the new attachable node.
+ m_AttachedNodeReference.Value = 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)
+ {
+ m_AttachableNode.Detach(this);
+ m_AttachableNode = null;
+ 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 (!IsSpawned)
+ {
+ NetworkLog.LogError($"[{name}][Detach][Not Spawned] Cannot detach if not spawned!");
+ return;
+ }
+
+ if (!HasAuthority)
+ {
+ 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;
+ }
+
+ // Update the attached node reference to nothing-null.
+ m_AttachedNodeReference.Value = new NetworkBehaviourReference(null);
+ }
+ }
+}
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..8c5ad47cf5
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/AttachableNode.cs
@@ -0,0 +1,77 @@
+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
+{
+ ///
+ /// 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 OnNetworkDespawn()
+ {
+ for (int i = m_AttachedBehaviours.Count - 1; i > 0; i--)
+ {
+ m_AttachedBehaviours[i].InternalDetach();
+ }
+ 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..cc87d6f732
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/ComponentController.cs
@@ -0,0 +1,277 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using UnityEngine;
+using Object = UnityEngine.Object;
+
+namespace Unity.Netcode.Components
+{
+ ///
+ /// This is a serializable contianer class for entries.
+ ///
+ [Serializable]
+ public class ComponentControllerEntry
+ {
+ ///
+ /// When true, this component's enabled state will be the inverse of
+ /// the value passed into .
+ ///
+ public bool InvertEnabled;
+
+ ///
+ /// The component to control.
+ ///
+ ///
+ /// You can assign an entire to this property which will
+ /// add all components attached to the . The
+ /// and properties will be applied to all components found on the .
+ ///
+ public Object Component;
+
+ internal PropertyInfo PropertyInfo;
+ }
+
+ ///
+ /// Handles enabling or disabling commonly used components, behaviours, RenderMeshes, etc.
+ /// Anything that derives from and has an enabled property can be added
+ /// to the list of objects.
+ /// 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.
+ /// This class provides the basic functionality to synchronizing components' enabled state.
+ /// 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.Value;
+
+ internal List ValidComponents = new List();
+ private NetworkVariable m_IsEnabled = new NetworkVariable();
+
+#if UNITY_EDITOR
+ ///
+ ///
+ /// Checks for invalid entries.
+ ///
+ protected virtual void OnValidate()
+ {
+ if (Components == null || Components.Count == 0)
+ {
+ return;
+ }
+
+ var gameObjectsToScan = new List();
+ 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 (componentType.IsSubclassOf(typeof(NetworkBehaviour)))
+ {
+ Debug.LogWarning($"Removing {Components[i].Component.name} since {nameof(NetworkBehaviour)}s are not allowed to be controlled by this component.");
+ 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);
+ }
+ }
+
+ foreach (var entry in gameObjectsToScan)
+ {
+ var asGameObject = entry.Component as GameObject;
+ var components = asGameObject.GetComponents();
+ foreach (var component in components)
+ {
+ // Ignore any NetworkBehaviour derived components
+ if (component.GetType().IsSubclassOf(typeof(NetworkBehaviour)))
+ {
+ 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();
+ }
+#endif
+
+ ///
+ /// This checks to make sure that all entries are valid and will create a final
+ /// list of valid entries.
+ ///
+ 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();
+ }
+
+ ///
+ public override void OnNetworkSpawn()
+ {
+ if (HasAuthority)
+ {
+ m_IsEnabled.Value = StartEnabled;
+ }
+ base.OnNetworkSpawn();
+ }
+
+ ///
+ ///
+ /// Assures all instances subscribe to the internal of type
+ /// that synchronizes all instances when s are enabled
+ /// or disabled.
+ ///
+ protected override void OnNetworkPostSpawn()
+ {
+ m_IsEnabled.OnValueChanged += OnEnabledChanged;
+ ApplyEnabled(m_IsEnabled.Value);
+ base.OnNetworkPostSpawn();
+ }
+
+ ///
+ public override void OnNetworkDespawn()
+ {
+ m_IsEnabled.OnValueChanged -= OnEnabledChanged;
+ base.OnNetworkDespawn();
+ }
+
+ private void OnEnabledChanged(bool previous, bool current)
+ {
+ ApplyEnabled(current);
+ }
+
+ ///
+ /// 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(bool enabled)
+ {
+ 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 ? !enabled : enabled;
+ entry.PropertyInfo.SetValue(entry.Component, isEnabled);
+ }
+ }
+
+ ///
+ /// 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 (!HasAuthority)
+ {
+ Debug.Log($"[Client-{NetworkManager.LocalClientId}] Attempting to invoke {nameof(SetEnabled)} without authority!");
+ return;
+ }
+ m_IsEnabled.Value = isEnabled;
+ }
+ }
+}
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/Tests/Runtime/AttachableBehaviourTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs
new file mode 100644
index 0000000000..0efc6b6654
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/AttachableBehaviourTests.cs
@@ -0,0 +1,381 @@
+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 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");
+ // 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 (!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 (!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)
+ {
+ m_ErrorLog.Clear();
+ var target = GetTargetInstance();
+ // 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))
+ {
+ 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_TargetInstance.NetworkObjectId].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(target.NetworkObjectId))
+ {
+ m_ErrorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has no spawned instance of {target.name}!");
+ continue;
+ }
+ else
+ {
+ node = networkManager.SpawnManager.SpawnedObjects[target.NetworkObjectId].GetComponentInChildren();
+ }
+
+ 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;
+ }
+
+ [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();
+ 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}");
+ }
+
+ ///
+ /// 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)
+ {
+ Log($"[{entry.Key}][Value] The value {entry.Value.name} is not the same as {attachableNode.name}!");
+ 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