Added leveler

This commit is contained in:
2026-08-07 11:40:20 +03:00
parent d9d7ee49d2
commit e3746e3e9d
22 changed files with 2824 additions and 383 deletions
@@ -0,0 +1,46 @@
using System.Collections.Generic;
using UnityEngine;
public interface IPlayerInteractionSession
{
string ReleasePrompt { get; }
bool IsControlledBy(ulong clientId);
void RequestRelease();
}
public static class PlayerInteractionSessionRegistry
{
private static readonly HashSet<IPlayerInteractionSession> Sessions = new();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetRegistry() => Sessions.Clear();
public static void Register(IPlayerInteractionSession session)
{
if (session != null)
Sessions.Add(session);
}
public static void Unregister(IPlayerInteractionSession session)
{
if (session != null)
Sessions.Remove(session);
}
public static bool TryGetControlledSession(
ulong clientId,
out IPlayerInteractionSession session)
{
foreach (IPlayerInteractionSession candidate in Sessions)
{
if (candidate != null && candidate.IsControlledBy(clientId))
{
session = candidate;
return true;
}
}
session = null;
return false;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 766a505389ebff84cb581f905b9736dd
@@ -2,7 +2,7 @@ using UnityEngine;
public sealed class NetworkCarryableInteractable : NetworkHeldInteractable
{
[SerializeField] private Vector3 carryOffset = new(0f, 1.15f, 0.75f);
[SerializeField] private Vector3 carryOffset = new(0f, -0.3f, 1.25f);
protected override Vector3 HoldingOffset => carryOffset;
protected override string DropPrompt => "Put down";
@@ -1,9 +1,282 @@
using System.Collections.Generic;
using Unity.Netcode;
using Unity.Netcode.Components;
using UnityEngine;
public sealed class NetworkDraggableInteractable : NetworkHeldInteractable
[RequireComponent(typeof(Rigidbody), typeof(NetworkRigidbody))]
public sealed class NetworkDraggableInteractable : NetworkInteractable
{
[SerializeField] private Vector3 dragOffset = new(0f, 0.35f, 1.8f);
private const ulong NoDragger = ulong.MaxValue;
protected override Vector3 HoldingOffset => dragOffset;
protected override string DropPrompt => "Release";
[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] private bool keepCurrentHeight;
[Header("Physics")]
[SerializeField] private Rigidbody physicsBody;
[SerializeField] private Collider[] objectColliders;
[SerializeField, Min(0.05f)] private float collisionRefreshInterval = 0.25f;
private readonly NetworkVariable<ulong> firstDraggerClientId = new(
NoDragger,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<ulong> secondDraggerClientId = new(
NoDragger,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly HashSet<Collider> ignoredPlayerColliders = new();
private float nextCollisionRefreshTime;
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;
private bool HasAnyDragger => ActiveDraggerCount > 0;
private bool IsDraggedByLocalPlayer =>
NetworkManager != null && IsDragger(NetworkManager.LocalClientId);
public override string InteractionPrompt
{
get
{
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 &&
(IsDraggedByLocalPlayer || ActiveDraggerCount < maximumDraggers);
private void Awake()
{
ResolvePhysicsReferences();
ApplyWeight();
}
private void OnValidate()
{
ResolvePhysicsReferences();
ApplyWeight();
}
public override void OnNetworkSpawn()
{
firstDraggerClientId.OnValueChanged += HandleDraggerChanged;
secondDraggerClientId.OnValueChanged += HandleDraggerChanged;
ApplyDraggingState();
}
public override void OnNetworkDespawn()
{
firstDraggerClientId.OnValueChanged -= HandleDraggerChanged;
secondDraggerClientId.OnValueChanged -= HandleDraggerChanged;
RestorePlayerCollisions();
}
private void Update()
{
if (!HasAnyDragger || Time.time < nextCollisionRefreshTime)
return;
nextCollisionRefreshTime = Time.time + collisionRefreshInterval;
IgnoreCollisionsWithPlayers();
}
private void FixedUpdate()
{
if (!IsServer || !IsSpawned || physicsBody == null)
return;
RemoveInvalidDraggers();
if (!HasEnoughPullStrength || !TryGetCombinedDragTarget(out Vector3 target))
return;
if (keepCurrentHeight)
target.y = physicsBody.position.y;
float totalStrength = ActiveDraggerCount * pullStrengthPerPlayer;
float loadRatio = Mathf.Clamp01(weight / totalStrength);
float speedMultiplier = Mathf.Lerp(1f, minimumSpeedMultiplier, loadRatio);
float movementStep = maximumDragSpeed * speedMultiplier * Time.fixedDeltaTime;
Vector3 nextPosition = Vector3.MoveTowards(physicsBody.position, target, movementStep);
physicsBody.MovePosition(nextPosition);
}
protected override bool CanInteractOnServer(ulong senderClientId) =>
IsDragger(senderClientId) ||
ActiveDraggerCount < maximumDraggers;
protected override void InteractOnServer(ulong senderClientId, NetworkObject playerObject)
{
if (IsDragger(senderClientId))
RemoveDragger(senderClientId);
else
AddDragger(senderClientId);
}
private void AddDragger(ulong clientId)
{
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)
{
target = Vector3.zero;
int validTargets = 0;
AddDragTarget(firstDraggerClientId.Value, ref target, ref validTargets);
AddDragTarget(secondDraggerClientId.Value, ref target, ref validTargets);
if (validTargets == 0)
return false;
target /= validTargets;
return true;
}
private void AddDragTarget(ulong clientId, ref Vector3 target, ref int validTargets)
{
if (clientId == NoDragger || !TryGetPlayerObject(clientId, out NetworkObject playerObject))
return;
target += playerObject.transform.TransformPoint(dragOffset);
validTargets++;
}
private bool IsDragger(ulong clientId) =>
firstDraggerClientId.Value == clientId || secondDraggerClientId.Value == clientId;
private void HandleDraggerChanged(ulong _, ulong __) => ApplyDraggingState();
private void ApplyDraggingState()
{
if (physicsBody != null)
{
physicsBody.isKinematic = HasAnyDragger || !IsServer;
physicsBody.linearVelocity = Vector3.zero;
physicsBody.angularVelocity = Vector3.zero;
}
if (HasAnyDragger)
IgnoreCollisionsWithPlayers();
else
RestorePlayerCollisions();
}
private void ResolvePhysicsReferences()
{
if (physicsBody == null)
physicsBody = GetComponent<Rigidbody>();
if (objectColliders == null || objectColliders.Length == 0)
objectColliders = GetComponentsInChildren<Collider>(true);
}
private void ApplyWeight()
{
if (physicsBody != null)
physicsBody.mass = Mathf.Max(0.1f, weight);
}
private void IgnoreCollisionsWithPlayers()
{
foreach (CharacterController playerCollider in FindObjectsByType<CharacterController>(
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();
}
}
@@ -1,16 +1,23 @@
using System.Collections.Generic;
using Unity.Netcode;
using Unity.Netcode.Components;
using UnityEngine;
[RequireComponent(typeof(Rigidbody), typeof(NetworkRigidbody))]
public abstract class NetworkHeldInteractable : NetworkInteractable
{
protected const ulong NoHolder = ulong.MaxValue;
private static readonly HashSet<NetworkHeldInteractable> SpawnedHeldObjects = new();
[Header("Physics")]
[SerializeField] private Rigidbody physicsBody;
[SerializeField] private Collider[] objectColliders;
[SerializeField, Min(0.05f)] private float collisionRefreshInterval = 0.25f;
[Header("Throw")]
[SerializeField, Min(0f)] private float throwForce = 6f;
[SerializeField, Min(0f)] private float throwUpwardForce = 0.5f;
private readonly NetworkVariable<ulong> holderClientId = new(
NoHolder,
NetworkVariableReadPermission.Everyone,
@@ -22,9 +29,10 @@ public abstract class NetworkHeldInteractable : NetworkInteractable
protected abstract Vector3 HoldingOffset { get; }
protected abstract string DropPrompt { get; }
private bool IsHeld => holderClientId.Value != NoHolder;
private bool IsHeldByLocalPlayer =>
public bool IsHeld => holderClientId.Value != NoHolder;
public bool IsHeldByLocalPlayer =>
NetworkManager != null && holderClientId.Value == NetworkManager.LocalClientId;
public string HeldObjectName => InteractionLabel;
public override string InteractionPrompt => !IsHeld
? InteractionLabel
@@ -46,6 +54,7 @@ public abstract class NetworkHeldInteractable : NetworkInteractable
public override void OnNetworkSpawn()
{
SpawnedHeldObjects.Add(this);
holderClientId.OnValueChanged += HandleHolderChanged;
ApplyHoldingState(IsHeld);
}
@@ -53,6 +62,7 @@ public abstract class NetworkHeldInteractable : NetworkInteractable
public override void OnNetworkDespawn()
{
holderClientId.OnValueChanged -= HandleHolderChanged;
SpawnedHeldObjects.Remove(this);
ApplyHoldingState(false);
}
@@ -69,9 +79,10 @@ public abstract class NetworkHeldInteractable : NetworkInteractable
return;
}
Transform anchor = ResolveHoldingAnchor(holder);
transform.SetPositionAndRotation(
holder.transform.TransformPoint(HoldingOffset),
holder.transform.rotation);
anchor.TransformPoint(HoldingOffset),
anchor.rotation);
if (Time.time >= nextCollisionRefreshTime)
{
@@ -80,8 +91,38 @@ public abstract class NetworkHeldInteractable : NetworkInteractable
}
}
protected override bool CanInteractOnServer(ulong senderClientId) =>
!IsHeld || holderClientId.Value == senderClientId;
public void RequestThrow()
{
if (IsSpawned && IsHeldByLocalPlayer)
RequestThrowServerRpc();
}
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 (holderClientId.Value == senderClientId)
return true;
return !IsHeld &&
!TryGetHeldObject(senderClientId, out _) &&
!NetworkHandItemInteractable.TryGetHeldItem(senderClientId, out _);
}
protected override void InteractOnServer(ulong senderClientId, NetworkObject playerObject)
{
@@ -91,6 +132,30 @@ public abstract class NetworkHeldInteractable : NetworkInteractable
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<FirstPersonCameraController>();
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)
{
holderClientId.Value = clientId;
@@ -103,6 +168,13 @@ public abstract class NetworkHeldInteractable : NetworkInteractable
ApplyHoldingState(false);
}
private Transform ResolveHoldingAnchor(NetworkObject holder)
{
FirstPersonCameraController cameraController =
holder.GetComponent<FirstPersonCameraController>();
return cameraController != null ? cameraController.AimTransform : holder.transform;
}
private void HandleHolderChanged(ulong _, ulong currentHolder) =>
ApplyHoldingState(currentHolder != NoHolder);
@@ -110,9 +182,9 @@ public abstract class NetworkHeldInteractable : NetworkInteractable
{
if (physicsBody != null)
{
physicsBody.isKinematic = isHeld;
if (!isHeld)
physicsBody.linearVelocity = Vector3.zero;
physicsBody.isKinematic = isHeld || !IsServer;
physicsBody.linearVelocity = Vector3.zero;
physicsBody.angularVelocity = Vector3.zero;
}
if (isHeld)
@@ -129,10 +201,8 @@ public abstract class NetworkHeldInteractable : NetworkInteractable
{
foreach (Collider objectCollider in objectColliders)
{
if (objectCollider == null || playerCollider == null)
continue;
Physics.IgnoreCollision(objectCollider, playerCollider, true);
if (objectCollider != null && playerCollider != null)
Physics.IgnoreCollision(objectCollider, playerCollider, true);
}
ignoredPlayerColliders.Add(playerCollider);
@@ -0,0 +1,267 @@
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<float> leverValueChanged;
[SerializeField] private UnityEvent<bool> leverStateChanged;
private readonly NetworkVariable<ulong> controllingClientId = new(
NoController,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<float> synchronizedValue = new(
0f,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<bool> 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<FirstPersonCameraController>();
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);
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 262775fc01b9c8a47a82f30b50d7b12a