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; [Header("Two positions")] [SerializeField, Range(0.05f, 0.95f)] private float switchThreshold = 0.5f; [SerializeField] private bool initialPositionIsMaximum; [SerializeField] private bool returnToInitialPositionAfterSwitch; [SerializeField, Min(0f)] private float automaticReturnDelay = 0.25f; [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); private bool hasSnapTarget; private float snapTargetValue; private float automaticReturnTime = -1f; 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 float InitialPositionValue => initialPositionIsMaximum ? 1f : 0f; private void Reset() { leverVisual = transform; neutralLocalEulerAngles = leverVisual.localEulerAngles; } private void OnValidate() { if (maximumAngle < minimumAngle) maximumAngle = minimumAngle; } public override void OnNetworkSpawn() { PlayerInteractionSessionRegistry.Register(this); if (IsServer) { synchronizedValue.Value = InitialPositionValue; isActivated.Value = initialPositionIsMaximum; } 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)) { EndControl(); return; } if (TryCalculateTargetValue(playerObject, out float targetValue)) { MoveTowardsValue(targetValue); UpdateActivationAtEndpoint(); } return; } if (hasSnapTarget) { if (MoveTowardsValue(snapTargetValue)) CompleteSnap(); return; } if (automaticReturnTime >= 0f && Time.time >= automaticReturnTime) BeginSnap(InitialPositionValue); } 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); } [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] private void RequestReleaseServerRpc(RpcParams rpcParams = default) { if (IsControlledBy(rpcParams.Receive.SenderClientId)) EndControl(); } private void BeginControl(ulong clientId) { hasSnapTarget = false; automaticReturnTime = -1f; controllingClientId.Value = clientId; } private void EndControl() { if (!IsControlled) return; controllingClientId.Value = NoController; float targetPosition = synchronizedValue.Value >= switchThreshold ? 1f : 0f; BeginSnap(targetPosition); } private void BeginSnap(float targetValue) { automaticReturnTime = -1f; snapTargetValue = targetValue >= 0.5f ? 1f : 0f; hasSnapTarget = true; } private void CompleteSnap() { hasSnapTarget = false; bool snappedToMaximum = snapTargetValue >= 0.5f; SetActivation(snappedToMaximum); bool movedAwayFromInitialPosition = !Mathf.Approximately(snapTargetValue, InitialPositionValue); automaticReturnTime = returnToInitialPositionAfterSwitch && movedAwayFromInitialPosition ? Time.time + automaticReturnDelay : -1f; } private void UpdateActivationAtEndpoint() { const float endpointTolerance = 0.001f; if (synchronizedValue.Value >= 1f - endpointTolerance) SetActivation(true); else if (synchronizedValue.Value <= endpointTolerance) SetActivation(false); } private void SetActivation(bool activated) { if (isActivated.Value != activated) isActivated.Value = activated; } 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 bool 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)) synchronizedValue.Value = nextValue; return Mathf.Approximately(nextValue, targetValue); } 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/Initial Position")] private void PreviewInitialPosition() => ApplyVisual(InitialPositionValue); [ContextMenu("Preview/Maximum")] private void PreviewMaximum() => ApplyVisual(1f); }