using System.Collections.Generic; using Unity.Netcode; using Unity.Netcode.Components; using UnityEngine; [RequireComponent(typeof(Rigidbody), typeof(NetworkRigidbody))] public abstract class NetworkHeldInteractable : NetworkInteractable, IWorkstationItemControl { protected const ulong NoHolder = ulong.MaxValue; private static readonly HashSet SpawnedHeldObjects = new(); [Header("Physics")] [SerializeField] private Rigidbody physicsBody; [SerializeField] private Collider[] objectColliders; [SerializeField, Min(0.05f)] private float collisionRefreshInterval = 0.25f; [Header("Holding Follow")] [SerializeField, Min(0.1f)] private float maximumFollowSpeed = 12f; [SerializeField, Min(0.5f)] private float maximumHoldDistance = 4.5f; [SerializeField, Min(0.1f)] private float rotationFollowSpeed = 14f; [Header("Local Presentation")] [Tooltip("Removes the server round-trip delay for the player currently carrying the item.")] [SerializeField] private bool correctLocalHolderPresentation = true; [SerializeField, Min(0.1f)] private float maximumLocalCorrectionDistance = 2f; [Header("Throw")] [SerializeField, Min(0f)] private float throwForce = 6f; [SerializeField, Min(0f)] private float throwUpwardForce = 0.5f; private readonly NetworkVariable holderClientId = new( NoHolder, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable isExternallyControlled = new( false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly HashSet ignoredPlayerColliders = new(); private readonly HashSet desiredIgnoredColliders = new(); private readonly List collidersToRestore = new(); private readonly RaycastHit[] localCorrectionHits = new RaycastHit[24]; private float nextCollisionRefreshTime; private bool initialUseGravity; private CollisionDetectionMode initialCollisionDetectionMode; protected abstract Vector3 HoldingOffset { get; } protected abstract string DropPrompt { get; } public bool IsHeld => holderClientId.Value != NoHolder; public bool IsExternallyControlled => isExternallyControlled.Value; public bool IsHeldByLocalPlayer => NetworkManager != null && holderClientId.Value == NetworkManager.LocalClientId; public string HeldObjectName => InteractionLabel; public override string InteractionPrompt => IsExternallyControlled ? "In machine" : !IsHeld ? InteractionLabel : IsHeldByLocalPlayer ? DropPrompt : "In use"; public override bool IsInteractionAvailable => base.IsInteractionAvailable && !IsExternallyControlled && (!IsHeld || IsHeldByLocalPlayer); private void Awake() { if (physicsBody == null) physicsBody = GetComponent(); if (objectColliders == null || objectColliders.Length == 0) objectColliders = GetComponentsInChildren(true); if (physicsBody != null) { initialUseGravity = physicsBody.useGravity; initialCollisionDetectionMode = physicsBody.collisionDetectionMode; } } public override void OnNetworkSpawn() { SpawnedHeldObjects.Add(this); holderClientId.OnValueChanged += HandleHolderChanged; isExternallyControlled.OnValueChanged += HandleExternalControlChanged; ApplyHoldingState(); } public override void OnNetworkDespawn() { holderClientId.OnValueChanged -= HandleHolderChanged; isExternallyControlled.OnValueChanged -= HandleExternalControlChanged; SpawnedHeldObjects.Remove(this); RestorePlayerCollisions(); } private void Update() { if (!IsSpawned || !IsHeld || IsExternallyControlled || Time.time < nextCollisionRefreshTime) { return; } nextCollisionRefreshTime = Time.time + collisionRefreshInterval; RefreshHolderCollisions(); } private void FixedUpdate() { if (!IsServer || !IsSpawned || !IsHeld || IsExternallyControlled || physicsBody == null) return; if (!TryGetPlayerObject(holderClientId.Value, out NetworkObject holder)) { if (IsServer) StopHolding(); return; } Transform anchor = ResolveHoldingAnchor(holder); if (!DriveTowardAnchor(anchor)) return; } private void LateUpdate() { if (!correctLocalHolderPresentation || IsServer || !IsSpawned || !IsHeldByLocalPlayer || IsExternallyControlled || physicsBody == null || !TryGetPlayerObject(holderClientId.Value, out NetworkObject holder)) { return; } Transform anchor = ResolveHoldingAnchor(holder); Vector3 targetPosition = anchor.TransformPoint(HoldingOffset); Vector3 targetDelta = targetPosition - physicsBody.position; if (targetDelta.sqrMagnitude > maximumLocalCorrectionDistance * maximumLocalCorrectionDistance || HasBlockingColliderForLocalCorrection(targetDelta)) { return; } transform.SetPositionAndRotation(targetPosition, anchor.rotation); } public void RequestThrow() { if (IsSpawned && IsHeldByLocalPlayer) RequestThrowServerRpc(); } public bool TryAcquireExternalControl() { if (!IsServer || !IsSpawned || IsExternallyControlled) return false; isExternallyControlled.Value = true; if (IsHeld) holderClientId.Value = NoHolder; ApplyHoldingState(); return true; } public bool TryReleaseExternalControl() { if (!IsServer || !IsSpawned || !IsExternallyControlled) return false; isExternallyControlled.Value = false; ApplyHoldingState(); return true; } public bool TryReleaseExternalControlToPlayer(ulong clientId) { if (!IsServer || !IsSpawned || !IsExternallyControlled || TryGetHeldObject(clientId, out _) || NetworkHandItemInteractable.TryGetHeldItem(clientId, out _) || !TryGetPlayerObject(clientId, out _)) { return false; } isExternallyControlled.Value = false; holderClientId.Value = clientId; ApplyHoldingState(); return true; } public static bool TryGetHeldObject( ulong clientId, out NetworkHeldInteractable heldObject) { foreach (NetworkHeldInteractable candidate in SpawnedHeldObjects) { if (candidate != null && candidate.holderClientId.Value == clientId) { heldObject = candidate; return true; } } heldObject = null; return false; } protected override bool CanInteractOnServer(ulong senderClientId) { if (IsExternallyControlled) return false; if (holderClientId.Value == senderClientId) return true; return !IsHeld && !TryGetHeldObject(senderClientId, out _) && !NetworkHandItemInteractable.TryGetHeldItem(senderClientId, out _); } protected override void InteractOnServer(ulong senderClientId, NetworkObject playerObject) { if (holderClientId.Value == senderClientId) StopHolding(); else if (!IsHeld) StartHolding(senderClientId); } [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] private void RequestThrowServerRpc(RpcParams rpcParams = default) { ulong senderClientId = rpcParams.Receive.SenderClientId; if (holderClientId.Value != senderClientId || !TryGetPlayerObject(senderClientId, out NetworkObject playerObject)) { return; } FirstPersonCameraController cameraController = playerObject.GetComponent(); Vector3 throwDirection = cameraController != null ? cameraController.AimForward : playerObject.transform.forward; Vector3 throwVelocity = throwDirection * throwForce + Vector3.up * throwUpwardForce; StopHolding(); if (physicsBody != null) physicsBody.linearVelocity = throwVelocity; } private void StartHolding(ulong clientId) { if (IsExternallyControlled) return; holderClientId.Value = clientId; ApplyHoldingState(); } private void StopHolding() { holderClientId.Value = NoHolder; ApplyHoldingState(); } private Transform ResolveHoldingAnchor(NetworkObject holder) { FirstPersonCameraController cameraController = holder.GetComponent(); return cameraController != null ? cameraController.AimTransform : holder.transform; } private bool DriveTowardAnchor(Transform anchor) { Vector3 targetPosition = anchor.TransformPoint(HoldingOffset); Vector3 targetDelta = targetPosition - physicsBody.position; if (targetDelta.sqrMagnitude > maximumHoldDistance * maximumHoldDistance) { StopHolding(); return false; } Vector3 targetVelocity = targetDelta / Mathf.Max(Time.fixedDeltaTime, 0.001f); physicsBody.linearVelocity = Vector3.ClampMagnitude( targetVelocity, maximumFollowSpeed); DriveRotation(anchor.rotation); return true; } private void DriveRotation(Quaternion targetRotation) { Quaternion rotationDelta = targetRotation * Quaternion.Inverse(physicsBody.rotation); rotationDelta.ToAngleAxis(out float angle, out Vector3 axis); if (axis.sqrMagnitude < 0.0001f || float.IsNaN(axis.x)) return; if (angle > 180f) angle -= 360f; Vector3 targetAngularVelocity = axis.normalized * (angle * Mathf.Deg2Rad / Mathf.Max(Time.fixedDeltaTime, 0.001f)); physicsBody.angularVelocity = Vector3.ClampMagnitude( targetAngularVelocity, rotationFollowSpeed); } private bool HasBlockingColliderForLocalCorrection(Vector3 targetDelta) { float distance = targetDelta.magnitude; if (distance <= 0.0001f) return false; int hitCount = Physics.RaycastNonAlloc( physicsBody.position, targetDelta / distance, localCorrectionHits, distance, ~0, QueryTriggerInteraction.Ignore); for (int hitIndex = 0; hitIndex < hitCount; hitIndex++) { Collider hitCollider = localCorrectionHits[hitIndex].collider; if (hitCollider == null || ignoredPlayerColliders.Contains(hitCollider) || IsObjectCollider(hitCollider)) { continue; } return true; } return false; } private bool IsObjectCollider(Collider candidate) { foreach (Collider objectCollider in objectColliders) { if (objectCollider == candidate) return true; } return false; } private void HandleHolderChanged(ulong _, ulong __) => ApplyHoldingState(); private void HandleExternalControlChanged(bool _, bool __) => ApplyHoldingState(); private void ApplyHoldingState() { if (physicsBody != null) { bool isPhysicallyHeld = IsHeld && !IsExternallyControlled; bool shouldBeKinematic = IsExternallyControlled || !IsServer; if (shouldBeKinematic) { physicsBody.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative; physicsBody.isKinematic = true; physicsBody.useGravity = false; } else { physicsBody.isKinematic = false; physicsBody.useGravity = isPhysicallyHeld ? false : initialUseGravity; physicsBody.collisionDetectionMode = ResolveDynamicCollisionDetectionMode(); } if (!isPhysicallyHeld) { physicsBody.linearVelocity = Vector3.zero; physicsBody.angularVelocity = Vector3.zero; } } if (IsHeld && !IsExternallyControlled) RefreshHolderCollisions(); else RestorePlayerCollisions(); } private CollisionDetectionMode ResolveDynamicCollisionDetectionMode() => initialCollisionDetectionMode == CollisionDetectionMode.Discrete ? CollisionDetectionMode.ContinuousDynamic : initialCollisionDetectionMode; private void RefreshHolderCollisions() { desiredIgnoredColliders.Clear(); if (TryGetPlayerObject(holderClientId.Value, out NetworkObject holder)) { foreach (Collider holderCollider in holder.GetComponentsInChildren(true)) desiredIgnoredColliders.Add(holderCollider); } collidersToRestore.Clear(); foreach (Collider ignoredCollider in ignoredPlayerColliders) { if (ignoredCollider == null || !desiredIgnoredColliders.Contains(ignoredCollider)) collidersToRestore.Add(ignoredCollider); } foreach (Collider colliderToRestore in collidersToRestore) { SetCollisionIgnored(colliderToRestore, false); ignoredPlayerColliders.Remove(colliderToRestore); } foreach (Collider desiredCollider in desiredIgnoredColliders) { if (desiredCollider != null && ignoredPlayerColliders.Add(desiredCollider)) SetCollisionIgnored(desiredCollider, true); } } private void SetCollisionIgnored(Collider playerCollider, bool ignored) { if (playerCollider == null) return; foreach (Collider objectCollider in objectColliders) { if (objectCollider != null) Physics.IgnoreCollision(objectCollider, playerCollider, ignored); } } private void RestorePlayerCollisions() { foreach (Collider playerCollider in ignoredPlayerColliders) { if (playerCollider == null) continue; SetCollisionIgnored(playerCollider, false); } ignoredPlayerColliders.Clear(); desiredIgnoredColliders.Clear(); collidersToRestore.Clear(); } }