using Unity.Netcode; using UnityEngine; using UnityEngine.Events; public sealed class NetworkPhysicalLeverInteractable : NetworkInteractable, IPlayerInteractionSession { private const ulong NoController = ulong.MaxValue; [Header("Lever visual")] [SerializeField] private Transform leverVisual; [SerializeField] private Vector3 neutralLocalEulerAngles; [SerializeField] private Vector3 localRotationAxis = Vector3.right; [SerializeField] private Vector3 localHandleDirection = Vector3.up; [SerializeField, Range(-179f, 179f)] private float minimumAngle = -50f; [SerializeField, Range(-179f, 179f)] private float maximumAngle = 50f; [SerializeField, Min(1f)] private float movementSpeed = 180f; [Header("Control")] [SerializeField, Min(0.5f)] private float maximumControlDistance = 4f; [SerializeField] private bool returnToRestOnRelease; [SerializeField, Range(0f, 1f)] private float restValue; [Header("Activation")] [SerializeField, Range(0f, 1f)] private float activationThreshold = 0.85f; [SerializeField, Range(0f, 1f)] private float deactivationThreshold = 0.15f; [SerializeField] private UnityEvent leverValueChanged; [SerializeField] private UnityEvent leverStateChanged; private readonly NetworkVariable controllingClientId = new( NoController, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable synchronizedValue = new( 0f, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private readonly NetworkVariable isActivated = new( false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); public float Value => synchronizedValue.Value; public bool IsActivated => isActivated.Value; public bool IsControlled => controllingClientId.Value != NoController; public bool IsControlledByLocalPlayer => NetworkManager != null && IsControlledBy(NetworkManager.LocalClientId); public string ReleasePrompt => "[Hold E] Pulling lever"; public override string InteractionPrompt => IsControlledByLocalPlayer ? ReleasePrompt : IsControlled ? "Lever in use" : InteractionLabel; public override bool IsInteractionAvailable => base.IsInteractionAvailable && (!IsControlled || IsControlledByLocalPlayer); private Quaternion NeutralLocalRotation => Quaternion.Euler(neutralLocalEulerAngles); private Vector3 RotationAxis => localRotationAxis.sqrMagnitude > 0.0001f ? localRotationAxis.normalized : Vector3.right; private Vector3 HandleDirection => localHandleDirection.sqrMagnitude > 0.0001f ? localHandleDirection.normalized : Vector3.up; private void Reset() { leverVisual = transform; neutralLocalEulerAngles = leverVisual.localEulerAngles; } private void OnValidate() { if (maximumAngle < minimumAngle) maximumAngle = minimumAngle; deactivationThreshold = Mathf.Min(deactivationThreshold, activationThreshold); } public override void OnNetworkSpawn() { PlayerInteractionSessionRegistry.Register(this); synchronizedValue.OnValueChanged += HandleValueChanged; isActivated.OnValueChanged += HandleActivationChanged; ApplyVisual(synchronizedValue.Value); leverValueChanged?.Invoke(synchronizedValue.Value); leverStateChanged?.Invoke(isActivated.Value); } public override void OnNetworkDespawn() { PlayerInteractionSessionRegistry.Unregister(this); synchronizedValue.OnValueChanged -= HandleValueChanged; isActivated.OnValueChanged -= HandleActivationChanged; } private void FixedUpdate() { if (!IsServer || !IsSpawned || leverVisual == null) return; if (IsControlled) { if (!TryGetPlayerObject(controllingClientId.Value, out NetworkObject playerObject) || !IsPlayerWithinControlDistance(playerObject)) { controllingClientId.Value = NoController; return; } if (TryCalculateTargetValue(playerObject, out float targetValue)) MoveTowardsValue(targetValue); } else if (returnToRestOnRelease) { MoveTowardsValue(restValue); } } 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) { controllingClientId.Value = IsControlledBy(senderClientId) ? NoController : senderClientId; } [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] private void RequestReleaseServerRpc(RpcParams rpcParams = default) { if (IsControlledBy(rpcParams.Receive.SenderClientId)) controllingClientId.Value = NoController; } private bool IsPlayerWithinControlDistance(NetworkObject playerObject) { float maximumDistanceSquared = maximumControlDistance * maximumControlDistance; return (playerObject.transform.position - leverVisual.position).sqrMagnitude <= maximumDistanceSquared; } private bool TryCalculateTargetValue( NetworkObject playerObject, out float targetValue) { targetValue = synchronizedValue.Value; FirstPersonCameraController cameraController = playerObject.GetComponent(); Transform aimTransform = cameraController != null ? cameraController.AimTransform : playerObject.transform; Transform leverParent = leverVisual.parent; Quaternion neutralRotation = NeutralLocalRotation; Vector3 axisWorld = leverParent != null ? leverParent.TransformDirection(neutralRotation * RotationAxis) : neutralRotation * RotationAxis; Vector3 neutralDirectionWorld = leverParent != null ? leverParent.TransformDirection(neutralRotation * HandleDirection) : neutralRotation * HandleDirection; Ray aimRay = new(aimTransform.position, aimTransform.forward); Plane movementPlane = new(axisWorld, leverVisual.position); Vector3 targetPoint; if (movementPlane.Raycast(aimRay, out float enter) && enter >= 0f) { targetPoint = aimRay.GetPoint(enter); } else { float distanceAlongRay = Mathf.Max( 0.25f, Vector3.Dot(leverVisual.position - aimRay.origin, aimRay.direction)); targetPoint = aimRay.GetPoint(distanceAlongRay); } Vector3 desiredDirection = Vector3.ProjectOnPlane( targetPoint - leverVisual.position, axisWorld); if (desiredDirection.sqrMagnitude < 0.0001f) return false; float desiredAngle = Vector3.SignedAngle( neutralDirectionWorld, desiredDirection.normalized, axisWorld); desiredAngle = Mathf.Clamp(desiredAngle, minimumAngle, maximumAngle); targetValue = Mathf.InverseLerp(minimumAngle, maximumAngle, desiredAngle); return true; } private void MoveTowardsValue(float targetValue) { float angleRange = Mathf.Max(0.01f, maximumAngle - minimumAngle); float valueStep = movementSpeed / angleRange * Time.fixedDeltaTime; float nextValue = Mathf.MoveTowards( synchronizedValue.Value, Mathf.Clamp01(targetValue), valueStep); if (Mathf.Approximately(nextValue, synchronizedValue.Value)) return; synchronizedValue.Value = nextValue; UpdateActivationState(nextValue); } private void UpdateActivationState(float currentValue) { bool nextState = isActivated.Value; if (!nextState && currentValue >= activationThreshold) nextState = true; else if (nextState && currentValue <= deactivationThreshold) nextState = false; if (nextState != isActivated.Value) isActivated.Value = nextState; } private void HandleValueChanged(float _, float currentValue) { ApplyVisual(currentValue); leverValueChanged?.Invoke(currentValue); } private void HandleActivationChanged(bool _, bool currentState) => leverStateChanged?.Invoke(currentState); private void ApplyVisual(float value) { if (leverVisual == null) return; float angle = Mathf.Lerp(minimumAngle, maximumAngle, Mathf.Clamp01(value)); leverVisual.localRotation = NeutralLocalRotation * Quaternion.AngleAxis(angle, RotationAxis); } [ContextMenu("Preview/Minimum")] private void PreviewMinimum() => ApplyVisual(0f); [ContextMenu("Preview/Rest")] private void PreviewRest() => ApplyVisual(restValue); [ContextMenu("Preview/Maximum")] private void PreviewMaximum() => ApplyVisual(1f); }