using System; using Unity.Netcode; using UnityEngine; using UnityEngine.Events; [DisallowMultipleComponent] public sealed class NetworkPhysicalWheelInteractable : NetworkInteractable, IPlayerInteractionSession { private const ulong NoController = ulong.MaxValue; [Header("Wheel visual")] [SerializeField] private Transform wheelVisual; [SerializeField] private Vector3 neutralLocalEulerAngles; [SerializeField] private Vector3 localRotationAxis = Vector3.right; [SerializeField] private Vector3 localReferenceDirection = Vector3.up; [SerializeField, Range(-720f, 0f)] private float minimumAngle = -180f; [SerializeField, Range(0f, 720f)] private float maximumAngle = 180f; [SerializeField, Range(0f, 1f)] private float initialNormalizedValue = 0.5f; [SerializeField, Min(1f)] private float maximumRotationSpeed = 360f; [Header("Control")] [SerializeField, Min(0.5f)] private float maximumControlDistance = 4f; [SerializeField] private UnityEvent normalizedValueChanged; private readonly NetworkVariable controllingClientId = new( NoController, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable synchronizedAngle = new( 0f, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private float lastPointerAngle; private bool hasPointerAngle; public float Angle => synchronizedAngle.Value; public float NormalizedValue => Mathf.InverseLerp( minimumAngle, maximumAngle, synchronizedAngle.Value); public bool IsControlled => controllingClientId.Value != NoController; public bool IsControlledByLocalPlayer => NetworkManager != null && IsControlledBy(NetworkManager.LocalClientId); public string ReleasePrompt => "[Hold LMB] Turning wheel"; public override string InteractionPrompt => IsControlledByLocalPlayer ? ReleasePrompt : IsControlled ? "Wheel in use" : InteractionLabel; public override bool IsInteractionAvailable => base.IsInteractionAvailable && (!IsControlled || IsControlledByLocalPlayer); public event Action ValueChanged; private Quaternion NeutralLocalRotation => Quaternion.Euler(neutralLocalEulerAngles); private Vector3 RotationAxis => localRotationAxis.sqrMagnitude > 0.0001f ? localRotationAxis.normalized : Vector3.right; private Vector3 ReferenceDirection => localReferenceDirection.sqrMagnitude > 0.0001f ? localReferenceDirection.normalized : Vector3.up; private void Reset() { wheelVisual = transform; neutralLocalEulerAngles = wheelVisual.localEulerAngles; } private void OnValidate() { if (maximumAngle <= minimumAngle) maximumAngle = minimumAngle + 1f; } public override void OnNetworkSpawn() { PlayerInteractionSessionRegistry.Register(this); if (IsServer) { synchronizedAngle.Value = Mathf.Lerp( minimumAngle, maximumAngle, initialNormalizedValue); } synchronizedAngle.OnValueChanged += HandleAngleChanged; ApplyVisual(synchronizedAngle.Value); InvokeValueChanged(); } public override void OnNetworkDespawn() { PlayerInteractionSessionRegistry.Unregister(this); synchronizedAngle.OnValueChanged -= HandleAngleChanged; } private void FixedUpdate() { if (!IsServer || !IsSpawned || !IsControlled || wheelVisual == null) return; if (!TryGetPlayerObject(controllingClientId.Value, out NetworkObject playerObject) || !IsPlayerWithinControlDistance(playerObject)) { EndControl(); return; } if (!TryCalculatePointerAngle(playerObject, out float pointerAngle)) { hasPointerAngle = false; return; } if (!hasPointerAngle) { lastPointerAngle = pointerAngle; hasPointerAngle = true; return; } float pointerDelta = Mathf.DeltaAngle(lastPointerAngle, pointerAngle); lastPointerAngle = pointerAngle; float maximumStep = maximumRotationSpeed * Time.fixedDeltaTime; float nextAngle = Mathf.Clamp( synchronizedAngle.Value + Mathf.Clamp(pointerDelta, -maximumStep, maximumStep), minimumAngle, maximumAngle); if (!Mathf.Approximately(nextAngle, synchronizedAngle.Value)) synchronizedAngle.Value = nextAngle; } public bool IsControlledBy(ulong clientId) => controllingClientId.Value == clientId; public void RequestRelease() { if (IsSpawned && IsControlledByLocalPlayer) RequestReleaseServerRpc(); } protected override bool CanInteractOnServer(ulong senderClientId) => !IsControlled || IsControlledBy(senderClientId); protected override void InteractOnServer( ulong senderClientId, NetworkObject playerObject) { if (IsControlledBy(senderClientId)) EndControl(); else BeginControl(senderClientId, playerObject); } [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] private void RequestReleaseServerRpc(RpcParams rpcParams = default) { if (IsControlledBy(rpcParams.Receive.SenderClientId)) EndControl(); } private void BeginControl(ulong clientId, NetworkObject playerObject) { controllingClientId.Value = clientId; hasPointerAngle = TryCalculatePointerAngle(playerObject, out lastPointerAngle); } private void EndControl() { controllingClientId.Value = NoController; hasPointerAngle = false; } private bool IsPlayerWithinControlDistance(NetworkObject playerObject) { float maximumDistanceSquared = maximumControlDistance * maximumControlDistance; return (playerObject.transform.position - wheelVisual.position).sqrMagnitude <= maximumDistanceSquared; } private bool TryCalculatePointerAngle( NetworkObject playerObject, out float pointerAngle) { pointerAngle = 0f; FirstPersonCameraController cameraController = playerObject.GetComponent(); Transform aimTransform = cameraController != null ? cameraController.AimTransform : playerObject.transform; Transform wheelParent = wheelVisual.parent; Vector3 axisWorld = wheelParent != null ? wheelParent.TransformDirection(NeutralLocalRotation * RotationAxis) : NeutralLocalRotation * RotationAxis; Vector3 referenceWorld = wheelParent != null ? wheelParent.TransformDirection(NeutralLocalRotation * ReferenceDirection) : NeutralLocalRotation * ReferenceDirection; Ray aimRay = new(aimTransform.position, aimTransform.forward); Plane wheelPlane = new(axisWorld, wheelVisual.position); Vector3 targetPoint; if (wheelPlane.Raycast(aimRay, out float enter) && enter >= 0f) { targetPoint = aimRay.GetPoint(enter); } else { float distanceAlongRay = Mathf.Max( 0.25f, Vector3.Dot(wheelVisual.position - aimRay.origin, aimRay.direction)); targetPoint = aimRay.GetPoint(distanceAlongRay); } Vector3 pointerDirection = Vector3.ProjectOnPlane( targetPoint - wheelVisual.position, axisWorld); if (pointerDirection.sqrMagnitude < 0.0001f) return false; pointerAngle = Vector3.SignedAngle( referenceWorld, pointerDirection.normalized, axisWorld); return true; } private void HandleAngleChanged(float _, float current) => ApplyNetworkValue(current); private void ApplyNetworkValue(float angle) { ApplyVisual(angle); InvokeValueChanged(); } private void InvokeValueChanged() { float value = NormalizedValue; ValueChanged?.Invoke(value); normalizedValueChanged?.Invoke(value); } private void ApplyVisual(float angle) { if (wheelVisual != null) { wheelVisual.localRotation = NeutralLocalRotation * Quaternion.AngleAxis(angle, RotationAxis); } } [ContextMenu("Preview/Minimum")] private void PreviewMinimum() => ApplyVisual(minimumAngle); [ContextMenu("Preview/Center")] private void PreviewCenter() => ApplyVisual(Mathf.Lerp( minimumAngle, maximumAngle, 0.5f)); [ContextMenu("Preview/Maximum")] private void PreviewMaximum() => ApplyVisual(maximumAngle); }