using System.Collections.Generic; using Unity.Netcode; using Unity.Netcode.Components; using UnityEngine; using UnityEngine.Serialization; public enum DraggableMovementMode { Free3D = 0, KeepWorldHeight = 1, FollowGround = 2 } [RequireComponent(typeof(Rigidbody), typeof(NetworkRigidbody))] public sealed class NetworkDraggableInteractable : NetworkInteractable, IWorkstationItemControl { private const ulong NoDragger = ulong.MaxValue; private static readonly HashSet SpawnedDraggables = new(); [Header("Weight")] [SerializeField, Min(0.1f)] private float weight = 35f; [SerializeField, Min(0.1f)] private float pullStrengthPerPlayer = 50f; [SerializeField, Range(1, 2)] private int maximumDraggers = 2; [Header("Dragging")] [SerializeField] private Vector3 dragOffset = new(0f, 1f, 1.8f); [SerializeField, Min(0.1f)] private float maximumDragSpeed = 3f; [SerializeField, Range(0.05f, 1f)] private float minimumSpeedMultiplier = 0.35f; [SerializeField, Min(0.5f)] private float maximumDraggerDistance = 4.5f; [SerializeField, Min(0.01f)] private float positionSmoothTime = 0.12f; [SerializeField, Min(0.1f)] private float rotationFollowSpeed = 8f; [SerializeField] private Vector3 rotationOffset; [FormerlySerializedAs("keepCurrentHeight")] [SerializeField] private DraggableMovementMode movementMode; [Header("Ground Following")] [SerializeField] private LayerMask groundLayers = ~0; [SerializeField] private Transform groundContactPoint; [SerializeField, Min(0.01f)] private float groundProbeRadius = 0.2f; [SerializeField, Min(0.1f)] private float groundProbeHeight = 1.25f; [SerializeField, Min(0.1f)] private float maximumGroundDrop = 2f; [SerializeField, Range(0f, 89f)] private float maximumGroundAngle = 55f; [SerializeField, Min(0f)] private float groundClearance = 0.02f; [SerializeField, Min(0.1f)] private float maximumGroundVerticalSpeed = 6f; [SerializeField] private bool alignToGround = true; [SerializeField, Min(0.1f)] private float groundAlignmentSpeed = 10f; [Header("Physics")] [SerializeField] private Rigidbody physicsBody; [SerializeField] private Collider[] objectColliders; [SerializeField, Min(0.05f)] private float collisionRefreshInterval = 0.25f; private readonly NetworkVariable firstDraggerClientId = new( NoDragger, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable secondDraggerClientId = new( NoDragger, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable isExternallyControlled = new( false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly HashSet ignoredPlayerColliders = new(); private readonly RaycastHit[] groundHits = new RaycastHit[16]; private float nextCollisionRefreshTime; private Vector3 localGroundContactPoint; private Vector3 smoothedGroundNormal = Vector3.up; private Vector3 dragVelocity; public float Weight => weight; public int ActiveDraggerCount { get { int count = firstDraggerClientId.Value == NoDragger ? 0 : 1; if (secondDraggerClientId.Value != NoDragger) count++; return count; } } public int RequiredDraggerCount => Mathf.Max(1, Mathf.CeilToInt(weight / pullStrengthPerPlayer)); public bool HasEnoughPullStrength => ActiveDraggerCount >= RequiredDraggerCount; public bool IsExternallyControlled => isExternallyControlled.Value; private bool HasAnyDragger => ActiveDraggerCount > 0; private bool IsDraggedByLocalPlayer => NetworkManager != null && IsDragger(NetworkManager.LocalClientId); public override string InteractionPrompt { get { if (IsExternallyControlled) return "In machine"; if (IsDraggedByLocalPlayer) return "Release"; int activeDraggers = ActiveDraggerCount; if (activeDraggers == 0) return $"{InteractionLabel} ({weight:0.#} kg)"; if (activeDraggers < maximumDraggers) return !HasEnoughPullStrength ? $"Help drag ({activeDraggers}/{RequiredDraggerCount})" : $"Join drag ({activeDraggers}/{maximumDraggers})"; return HasEnoughPullStrength ? "In use" : "Too heavy"; } } public override bool IsInteractionAvailable => base.IsInteractionAvailable && !IsExternallyControlled && (IsDraggedByLocalPlayer || (ActiveDraggerCount < maximumDraggers && (NetworkManager == null || !TryGetDraggedObject(NetworkManager.LocalClientId, this, out _)))); private void Awake() { ResolvePhysicsReferences(); ApplyWeight(); } private void OnValidate() { ResolvePhysicsReferences(); ApplyWeight(); } public override void OnNetworkSpawn() { SpawnedDraggables.Add(this); firstDraggerClientId.OnValueChanged += HandleDraggerChanged; secondDraggerClientId.OnValueChanged += HandleDraggerChanged; isExternallyControlled.OnValueChanged += HandleExternalControlChanged; ApplyDraggingState(); } public override void OnNetworkDespawn() { SpawnedDraggables.Remove(this); firstDraggerClientId.OnValueChanged -= HandleDraggerChanged; secondDraggerClientId.OnValueChanged -= HandleDraggerChanged; isExternallyControlled.OnValueChanged -= HandleExternalControlChanged; RestorePlayerCollisions(); } private void Update() { if (!HasAnyDragger || Time.time < nextCollisionRefreshTime) return; nextCollisionRefreshTime = Time.time + collisionRefreshInterval; IgnoreCollisionsWithPlayers(); } private void FixedUpdate() { if (!IsServer || !IsSpawned || physicsBody == null || IsExternallyControlled) return; RemoveInvalidDraggers(); if (!HasEnoughPullStrength || !TryGetCombinedDragTarget(out Vector3 target, out Vector3 forward)) { dragVelocity = Vector3.zero; return; } float totalStrength = ActiveDraggerCount * pullStrengthPerPlayer; float loadRatio = Mathf.Clamp01(weight / totalStrength); float speedMultiplier = Mathf.Lerp(1f, minimumSpeedMultiplier, loadRatio); float movementSpeed = maximumDragSpeed * speedMultiplier; switch (movementMode) { case DraggableMovementMode.KeepWorldHeight: target.y = physicsBody.position.y; FollowDragTarget(target, forward, movementSpeed, Vector3.up); break; case DraggableMovementMode.FollowGround: MoveAlongGround(target, forward, movementSpeed); break; default: FollowDragTarget(target, forward, movementSpeed, Vector3.up); break; } } protected override bool CanInteractOnServer(ulong senderClientId) { if (IsExternallyControlled) return false; if (IsDragger(senderClientId)) return true; return ActiveDraggerCount < maximumDraggers && !TryGetDraggedObject(senderClientId, this, out _); } protected override void InteractOnServer(ulong senderClientId, NetworkObject playerObject) { if (IsDragger(senderClientId)) RemoveDragger(senderClientId); else AddDragger(senderClientId); } private void AddDragger(ulong clientId) { if (IsExternallyControlled || TryGetDraggedObject(clientId, this, out _)) return; if (firstDraggerClientId.Value == NoDragger) firstDraggerClientId.Value = clientId; else if (maximumDraggers > 1 && secondDraggerClientId.Value == NoDragger) secondDraggerClientId.Value = clientId; } private void RemoveDragger(ulong clientId) { if (firstDraggerClientId.Value == clientId) { firstDraggerClientId.Value = secondDraggerClientId.Value; secondDraggerClientId.Value = NoDragger; } else if (secondDraggerClientId.Value == clientId) { secondDraggerClientId.Value = NoDragger; } } private void RemoveInvalidDraggers() { ulong first = firstDraggerClientId.Value; ulong second = secondDraggerClientId.Value; if (first != NoDragger && !IsDraggerValid(first)) RemoveDragger(first); if (second != NoDragger && !IsDraggerValid(second)) RemoveDragger(second); } private bool IsDraggerValid(ulong clientId) { if (!TryGetPlayerObject(clientId, out NetworkObject playerObject)) return false; float maximumDistanceSquared = maximumDraggerDistance * maximumDraggerDistance; return (playerObject.transform.position - physicsBody.position).sqrMagnitude <= maximumDistanceSquared; } private bool TryGetCombinedDragTarget(out Vector3 target, out Vector3 forward) { target = Vector3.zero; forward = Vector3.zero; int validTargets = 0; AddDragTarget(firstDraggerClientId.Value, ref target, ref forward, ref validTargets); AddDragTarget(secondDraggerClientId.Value, ref target, ref forward, ref validTargets); if (validTargets == 0) return false; target /= validTargets; forward = Vector3.ProjectOnPlane(forward, Vector3.up); if (forward.sqrMagnitude < 0.0001f) forward = Vector3.ProjectOnPlane(target - physicsBody.position, Vector3.up); if (forward.sqrMagnitude < 0.0001f) forward = Vector3.ProjectOnPlane(physicsBody.transform.forward, Vector3.up); forward.Normalize(); return true; } private void AddDragTarget( ulong clientId, ref Vector3 target, ref Vector3 forward, ref int validTargets) { if (clientId == NoDragger || !TryGetPlayerObject(clientId, out NetworkObject playerObject)) return; FirstPersonCameraController cameraController = playerObject.GetComponent(); Vector3 aimForward = cameraController != null ? cameraController.AimForward : playerObject.transform.forward; target += playerObject.transform.position + playerObject.transform.right * dragOffset.x + Vector3.up * dragOffset.y + aimForward * dragOffset.z; forward += aimForward; validTargets++; } private static bool TryGetDraggedObject( ulong clientId, NetworkDraggableInteractable except, out NetworkDraggableInteractable draggedObject) { foreach (NetworkDraggableInteractable candidate in SpawnedDraggables) { if (candidate != null && candidate != except && candidate.IsDragger(clientId)) { draggedObject = candidate; return true; } } draggedObject = null; return false; } public static bool TryGetDraggedObject( ulong clientId, out NetworkDraggableInteractable draggedObject) => TryGetDraggedObject(clientId, null, out draggedObject); public void RequestRelease() { if (IsSpawned && IsDraggedByLocalPlayer) RequestReleaseRpc(); } public bool TryAcquireExternalControl() { if (!IsServer || !IsSpawned || IsExternallyControlled) return false; isExternallyControlled.Value = true; firstDraggerClientId.Value = NoDragger; secondDraggerClientId.Value = NoDragger; ApplyDraggingState(); return true; } public bool TryReleaseExternalControl() { if (!IsServer || !IsSpawned || !IsExternallyControlled) return false; isExternallyControlled.Value = false; ApplyDraggingState(); return true; } [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] private void RequestReleaseRpc(RpcParams rpcParams = default) { ulong senderClientId = rpcParams.Receive.SenderClientId; if (!IsExternallyControlled && IsDragger(senderClientId)) RemoveDragger(senderClientId); } private bool IsDragger(ulong clientId) => firstDraggerClientId.Value == clientId || secondDraggerClientId.Value == clientId; private void HandleDraggerChanged(ulong _, ulong __) => ApplyDraggingState(); private void HandleExternalControlChanged(bool _, bool __) => ApplyDraggingState(); private void ApplyDraggingState() { if (physicsBody != null) { bool shouldBeKinematic = HasAnyDragger || IsExternallyControlled || !IsServer; if (physicsBody.isKinematic && !shouldBeKinematic) physicsBody.isKinematic = false; if (!physicsBody.isKinematic) { physicsBody.linearVelocity = Vector3.zero; physicsBody.angularVelocity = Vector3.zero; } physicsBody.isKinematic = shouldBeKinematic; } if (HasAnyDragger || IsExternallyControlled) { dragVelocity = Vector3.zero; CacheGroundContactPoint(); smoothedGroundNormal = physicsBody != null ? physicsBody.transform.up : Vector3.up; IgnoreCollisionsWithPlayers(); } else { dragVelocity = Vector3.zero; RestorePlayerCollisions(); } } private void ResolvePhysicsReferences() { if (physicsBody == null) physicsBody = GetComponent(); if (objectColliders == null || objectColliders.Length == 0) objectColliders = GetComponentsInChildren(true); CacheGroundContactPoint(); } private void ApplyWeight() { if (physicsBody != null) physicsBody.mass = Mathf.Max(0.1f, weight); } private void FollowDragTarget( Vector3 target, Vector3 forward, float movementSpeed, Vector3 surfaceNormal) { Vector3 nextPosition = Vector3.SmoothDamp( physicsBody.position, target, ref dragVelocity, positionSmoothTime, movementSpeed, Time.fixedDeltaTime); physicsBody.MovePosition(nextPosition); physicsBody.MoveRotation(CalculateDragRotation(forward, surfaceNormal)); } private void MoveAlongGround( Vector3 dragTarget, Vector3 forward, float movementSpeed) { Vector3 currentPosition = physicsBody.position; Vector3 planarTarget = new(dragTarget.x, currentPosition.y, dragTarget.z); Vector3 planarVelocity = new(dragVelocity.x, 0f, dragVelocity.z); Vector3 nextPlanarPosition = Vector3.SmoothDamp( currentPosition, planarTarget, ref planarVelocity, positionSmoothTime, movementSpeed, Time.fixedDeltaTime); nextPlanarPosition.y = currentPosition.y; dragVelocity = new Vector3(planarVelocity.x, dragVelocity.y, planarVelocity.z); Vector3 probePosition = nextPlanarPosition; if (!TryGetGroundSurface(probePosition, out Vector3 groundPoint, out Vector3 groundNormal)) return; Quaternion targetRotation = CalculateDragRotation(forward, groundNormal); Vector3 scaledContactPoint = Vector3.Scale(localGroundContactPoint, physicsBody.transform.lossyScale); float contactOffsetY = (targetRotation * scaledContactPoint).y; float targetHeight = groundPoint.y - contactOffsetY + groundClearance; float nextHeight = Mathf.MoveTowards( currentPosition.y, targetHeight, maximumGroundVerticalSpeed * Time.fixedDeltaTime); physicsBody.MovePosition(new Vector3(nextPlanarPosition.x, nextHeight, nextPlanarPosition.z)); physicsBody.MoveRotation(targetRotation); } private bool TryGetGroundSurface( Vector3 probePosition, out Vector3 groundPoint, out Vector3 groundNormal) { Vector3 origin = probePosition + Vector3.up * groundProbeHeight; float castDistance = groundProbeHeight + maximumGroundDrop; int hitCount = Physics.SphereCastNonAlloc( origin, groundProbeRadius, Vector3.down, groundHits, castDistance, groundLayers, QueryTriggerInteraction.Ignore); float nearestDistance = float.PositiveInfinity; groundPoint = default; groundNormal = Vector3.up; for (int index = 0; index < hitCount; index++) { RaycastHit hit = groundHits[index]; if (hit.collider == null || IsOwnCollider(hit.collider) || hit.collider.GetComponentInParent() != null) { continue; } if (Vector3.Angle(Vector3.up, hit.normal) > maximumGroundAngle || hit.distance >= nearestDistance) { continue; } nearestDistance = hit.distance; groundPoint = hit.point; groundNormal = hit.normal; } return !float.IsPositiveInfinity(nearestDistance); } private Quaternion CalculateDragRotation(Vector3 forward, Vector3 groundNormal) { Vector3 targetUp = alignToGround ? groundNormal : Vector3.up; float normalBlend = 1f - Mathf.Exp(-groundAlignmentSpeed * Time.fixedDeltaTime); smoothedGroundNormal = Vector3.Slerp( smoothedGroundNormal, targetUp, normalBlend).normalized; Vector3 surfaceForward = Vector3.ProjectOnPlane(forward, smoothedGroundNormal); if (surfaceForward.sqrMagnitude < 0.0001f) surfaceForward = Vector3.ProjectOnPlane(physicsBody.transform.forward, smoothedGroundNormal); Quaternion desiredRotation = Quaternion.LookRotation( surfaceForward.normalized, smoothedGroundNormal) * Quaternion.Euler(rotationOffset); float rotationBlend = 1f - Mathf.Exp(-rotationFollowSpeed * Time.fixedDeltaTime); return Quaternion.Slerp(physicsBody.rotation, desiredRotation, rotationBlend); } private void CacheGroundContactPoint() { if (physicsBody == null) return; if (groundContactPoint != null) { localGroundContactPoint = physicsBody.transform.InverseTransformPoint(groundContactPoint.position); return; } bool hasBounds = false; Bounds combinedBounds = default; if (objectColliders != null) { foreach (Collider objectCollider in objectColliders) { if (objectCollider == null || objectCollider.isTrigger || !objectCollider.enabled) continue; if (!hasBounds) { combinedBounds = objectCollider.bounds; hasBounds = true; } else { combinedBounds.Encapsulate(objectCollider.bounds); } } } Vector3 contactWorldPosition = hasBounds ? new Vector3(combinedBounds.center.x, combinedBounds.min.y, combinedBounds.center.z) : physicsBody.position; localGroundContactPoint = physicsBody.transform.InverseTransformPoint(contactWorldPosition); } private bool IsOwnCollider(Collider candidate) { if (objectColliders == null) return false; foreach (Collider objectCollider in objectColliders) { if (objectCollider == candidate) return true; } return false; } private void IgnoreCollisionsWithPlayers() { foreach (CharacterController playerCollider in FindObjectsByType( FindObjectsInactive.Exclude, FindObjectsSortMode.None)) { foreach (Collider objectCollider in objectColliders) { if (objectCollider != null && playerCollider != null) Physics.IgnoreCollision(objectCollider, playerCollider, true); } ignoredPlayerColliders.Add(playerCollider); } } private void RestorePlayerCollisions() { foreach (Collider playerCollider in ignoredPlayerColliders) { if (playerCollider == null) continue; foreach (Collider objectCollider in objectColliders) { if (objectCollider != null) Physics.IgnoreCollision(objectCollider, playerCollider, false); } } ignoredPlayerColliders.Clear(); } }