HandGrab.cs
using Sandbox;
using Sandbox.Mapping;
using Sandbox.VR;
using System;
using System.Linq;
public sealed class HandGrab : Component
{
[Property] public bool IsLeftHand { get; set; } = false;
/// <summary>Grip axis value (0-1) needed to pick up. Keep low for a light hold; must stay above Release Threshold.</summary>
[Property, Group( "Grip" )]
public float GripGrabThreshold { get; set; } = 0.10f;
/// <summary>Grip axis value (0-1) at or below which the held item is released. Keep below Grab Threshold so throws aren't re-caught.</summary>
[Property, Group( "Grip" )]
public float GripReleaseThreshold { get; set; } = 0f;
private const float GrabSphereRadius = 3.5f;
private const float GrabReachDistance = 10f;
private const float PalmOverlapRadius = 4f;
private GrabPoint _activeGrabPoint;
// Helpers to automatically pull input from the correct physical controller
private VRController ControllerInput => IsLeftHand ? Input.VR.LeftHand : Input.VR.RightHand;
private Transform HandTransform => ControllerInput.Transform;
private bool _useDown = false;
private bool _reloadDown = false;
/// <summary>
/// True after grip has fallen to the release threshold. Prevents immediately
/// re-grabbing a thrown item while the grip axis is still near zero.
/// </summary>
private bool _gripReadyToGrab = true;
protected override void OnUpdate()
{
if ( IsProxy )
return;
if ( Player.CurrentPlayer.IsValid() && Player.CurrentPlayer.Health.IsDead )
{
ForceDropHeld();
return;
}
HandleUseInput();
HandleGrabInput();
HandleActionInput();
HandleReloadInput();
}
private void HandleUseInput()
{
var triggerIsPressed = ControllerInput.Trigger.Value >= 0.75f;
if ( triggerIsPressed && !_useDown )
{
var handPosition = HandTransform.Position;
var handRotation = HandTransform.Rotation;
float reachDistance = 20f;
var angles = handRotation.Angles();
angles.pitch += 60f;
var forwardDirection = Rotation.From( angles ).Forward;
var endPosition = handPosition + (forwardDirection * reachDistance);
var trace = Scene.Trace.Ray( handPosition, endPosition ).Run();
var door = trace.GameObject?.GetComponent<Door>();
door?.Toggle( Player.CurrentPlayer?.GameObject );
}
_useDown = triggerIsPressed;
}
private void HandleGrabInput()
{
var grip = ControllerInput.Grip.Value;
// Never allow grab <= release, even if inspector values are inverted/zeroed
float releaseThreshold = Math.Clamp( GripReleaseThreshold, 0f, 0.95f );
float grabThreshold = Math.Max( GripGrabThreshold, releaseThreshold + 0.02f );
if ( grip <= releaseThreshold )
_gripReadyToGrab = true;
// Require a fresh squeeze after a full release so drops/throws aren't
// instantly re-caught by palm proximity while grip is still low.
if ( grip >= grabThreshold && _activeGrabPoint is null && _gripReadyToGrab )
{
TryGrabNearestObject();
if ( _activeGrabPoint is not null )
_gripReadyToGrab = false;
}
// Drop only after grip falls below the release threshold
if ( grip <= releaseThreshold && _activeGrabPoint is not null )
{
TryStowOrDrop();
_gripReadyToGrab = true;
}
}
/// <summary>
/// Stows the held item if the hand let go inside one of the player's slots,
/// otherwise releases it into the world.
/// </summary>
private void TryStowOrDrop()
{
if ( _activeGrabPoint is null )
return;
var inventoryItem = _activeGrabPoint.Components.GetInAncestorsOrSelf<InventoryItem>();
var inventory = Player.CurrentPlayer?.Inventory;
if ( inventoryItem is not null && inventory is not null && inventory.TryStow( inventoryItem, HandTransform.Position ) )
{
// The item stays parented to the slot, so it must not fall to the world
_activeGrabPoint.Drop();
_activeGrabPoint = null;
return;
}
ForceDropHeld();
}
private void HandleActionInput()
{
if ( _activeGrabPoint is null ) return;
// Report the index trigger state each frame so the item can decide how to
// respond (continuous fire vs. once-per-press toggles)
_activeGrabPoint.SetTriggerState( ControllerInput.Trigger.Value > 0.75f );
}
private void HandleReloadInput()
{
if ( ControllerInput.ButtonB.IsPressed && !_reloadDown )
{
_activeGrabPoint?.Reload();
}
_reloadDown = ControllerInput.ButtonB.IsPressed;
}
private void TryGrabNearestObject()
{
var handPosition = HandTransform.Position;
var handRotation = HandTransform.Rotation;
var angles = handRotation.Angles();
angles.pitch += 60f;
var forwardDirection = Rotation.From( angles ).Forward;
var endPosition = handPosition + (forwardDirection * GrabReachDistance);
// Reach cast along the pointed palm, plus a palm-centered overlap for nearby grips
var reachHits = Scene.Trace.Sphere( GrabSphereRadius, handPosition, endPosition ).HitTriggersOnly().RunAll();
var palmHits = Scene.Trace.Sphere( PalmOverlapRadius, handPosition, handPosition ).HitTriggersOnly().RunAll();
var closestGrabPoint = reachHits.Concat( palmHits )
.Select( hit => hit.Collider?.Components.Get<GrabPoint>() )
.Where( grabPoint => grabPoint is not null && !grabPoint.IsGrabbed )
.Distinct()
.OrderBy( grabPoint => handPosition.Distance( grabPoint.WorldPosition ) )
.FirstOrDefault();
if ( closestGrabPoint is not null )
{
// Grab() unstows the item; unlike desktop the slot isn't kept reserved, so the
// hand is free to stow it in any slot it reaches later on.
_activeGrabPoint = closestGrabPoint;
_activeGrabPoint.Grab( IsLeftHand );
_activeGrabPoint.Components.GetInAncestorsOrSelf<InventoryItem>()?.ClearSlot();
}
}
/// <summary>Releases the held item, stowing it if the hand is at a matching slot.</summary>
public void DropHeld() => TryStowOrDrop();
/// <summary>Drops the held item into the world, ignoring nearby inventory slots.</summary>
public void ForceDropHeld()
{
if ( _activeGrabPoint is null )
return;
_activeGrabPoint.Components.GetInAncestorsOrSelf<InventoryItem>()?.DropToWorld();
_activeGrabPoint.Drop();
_activeGrabPoint = null;
}
}