GrabPoint.cs
using Sandbox;
using System;
using System.Linq;
public sealed class GrabPoint : Component
{
[RequireComponent] public SphereCollider GrabCollider { get; private set; }
/// <summary>True for foregrip / support-hand grab points on two-handed weapons.</summary>
[Property] public bool IsSecondaryGrip { get; set; } = false;
/// <summary>Local rotation applied to the holding hand for this grip (one-handed holds). Default matches the old 55° pitch.</summary>
[Property] public Angles HandRotationOffset { get; set; } = new Angles( 55f, 0f, 0f );
/// <summary>Seconds to blend the weapon's rotation from the one-hand pose into the two-hand pose (swung toward the support hand) when it grabs. The primary grip stays pinned to the primary hand throughout. 0 = instant.</summary>
[Property, Group( "Two Hand" )]
public float TwoHandBlendTime { get; set; } = 0.08f;
[Property] public Action OnGrab { get; set; }
/// <summary>Fires every frame the trigger is held (e.g. full-auto fire).</summary>
[Property] public Action OnTrigger { get; set; }
/// <summary>Fires once on the frame the trigger is first pressed (e.g. toggles, semi-auto).</summary>
[Property] public Action OnTriggerPressed { get; set; }
/// <summary>Fires once on the frame the trigger is released.</summary>
[Property] public Action OnTriggerReleased { get; set; }
/// <summary>
/// Used by desktop users
/// </summary>
[Property] public Action OnReload { get; set; }
[Property] public Action OnDrop { get; set; }
/// <summary>Multiplier applied to the hand's velocity when throwing. 1 = realistic; higher makes throws feel stronger.</summary>
[Property] public float ThrowVelocityScale { get; set; } = 5f;
[Sync] public bool IsGrabbed { get; private set; }
private bool IsLeftHand { get; set; }
private bool _triggerDown;
// Ring buffer of recent hand transforms, used to compute throw velocity on release
private const int ThrowSampleCount = 6;
private readonly (Vector3 Position, Rotation Rotation, float Time)[] _throwSamples = new (Vector3, Rotation, float)[ThrowSampleCount];
private int _throwSampleHead;
private int _throwSampleTotal;
// 1H → 2H blend state (owned by the primary grip writer)
private int _lastActiveGripCount;
private float _twoHandBlendStartTime = -1f;
private Rotation _twoHandBlendFromRotation;
// Secondary-only hold: keep the weapon's existing pose relative to the hand (no snap)
private bool _secondaryOnlyOffsetValid;
private Vector3 _secondaryOnlyLocalPos;
private Rotation _secondaryOnlyLocalRot;
public Transform HandTransform => IsLeftHand ? Input.VR.LeftHand.Transform : Input.VR.RightHand.Transform;
/// <summary>Rumble the VR controller currently holding this grip. No-ops if this point isn't grabbed.</summary>
public void TriggerHaptic( HapticEffect effect, float lengthScale = 1f, float frequencyScale = 1f, float amplitudeScale = 1f )
{
if ( !IsGrabbed || !Game.IsRunningInVR )
return;
var controller = IsLeftHand ? Input.VR.LeftHand : Input.VR.RightHand;
controller.TriggerHaptics( effect, lengthScale, frequencyScale, amplitudeScale );
}
/// <summary>
/// The item's own root object. GameObject.Root can't be used directly because a stowed
/// item is parented to an inventory slot on the player, making Root resolve to the player.
/// </summary>
public GameObject ItemRoot => Components.GetInAncestorsOrSelf<InventoryItem>()?.GameObject ?? GameObject.Root;
/// <summary>
/// Returns true if any GrabPoint on this entire weapon tree is currently held.
/// </summary>
public bool IsAnythingHeld => ItemRoot.Components.GetAll<GrabPoint>( FindMode.InDescendants ).Any( g => g.IsGrabbed );
protected override void OnUpdate()
{
if ( !IsGrabbed || !Game.IsRunningInVR || IsProxy )
return;
var hand = HandTransform;
_throwSamples[_throwSampleHead] = (hand.Position, hand.Rotation, Time.Now);
_throwSampleHead = (_throwSampleHead + 1) % ThrowSampleCount;
if ( _throwSampleTotal < ThrowSampleCount )
_throwSampleTotal++;
}
protected override void OnPreRender()
{
if ( !IsGrabbed || !Game.IsRunningInVR ) return;
var itemRoot = ItemRoot;
var activeGrips = itemRoot.Components.GetAll<GrabPoint>( FindMode.InDescendants )
.Where( g => g.IsGrabbed )
.ToList();
var gun = itemRoot.Components.Get<Gun>( FindMode.EnabledInSelfAndDescendants );
// ---- CASE 1: ONE HANDED CONTROL ----
if ( activeGrips.Count == 1 )
{
// Only the holding grip writes the weapon transform
if ( this != activeGrips[0] )
return;
_lastActiveGripCount = 1;
_twoHandBlendStartTime = -1f;
// Foregrip alone: preserve current weapon pose relative to the hand so
// releasing the primary after a two-hand hold doesn't snap to the secondary.
if ( IsSecondaryGrip )
{
ApplySecondaryOnlyHold( itemRoot, gun );
return;
}
_secondaryOnlyOffsetValid = false;
Rotation targetRotation = HandTransform.Rotation * Rotation.From( HandRotationOffset );
if ( gun is not null )
targetRotation *= gun.VrRecoilRotation;
Vector3 localGrabAnchorOffset = GetGrabAnchorLocalOffset( itemRoot.WorldTransform );
Vector3 targetRootPosition = HandTransform.Position - (targetRotation * localGrabAnchorOffset);
itemRoot.WorldTransform = new Transform( targetRootPosition, targetRotation );
}
// ---- CASE 2: TWO HANDED CONTROL ----
else if ( activeGrips.Count >= 2 )
{
_secondaryOnlyOffsetValid = false;
var primaryGrip = activeGrips.FirstOrDefault( g => !g.IsSecondaryGrip ) ?? activeGrips[0];
var secondaryGrip = activeGrips.FirstOrDefault( g => g.IsSecondaryGrip ) ?? activeGrips[1];
if ( this == primaryGrip )
{
var primaryHand = primaryGrip.HandTransform;
Vector3 directionToFrontHand = (secondaryGrip.HandTransform.Position - primaryHand.Position).Normal;
var primaryLocal = primaryGrip.GetGrabAnchorLocalOffset( itemRoot.WorldTransform );
var secondaryLocal = secondaryGrip.GetGrabAnchorLocalOffset( itemRoot.WorldTransform );
// Start from exactly the pose the primary hand would give one-handed so its
// roll (and the grip's pitch offset) carry over into the two-hand hold.
Rotation oneHandRotation = primaryHand.Rotation * Rotation.From( primaryGrip.HandRotationOffset );
// Swing that pose by the smallest arc that points the actual grip-to-grip
// axis at the support hand. FromToRotation adds no twist, so only pitch/yaw
// change; when the support hand sits at the natural foregrip spot the
// result equals the one-hand pose and nothing snaps.
Rotation targetRotation = oneHandRotation;
Vector3 localGripAxis = secondaryLocal - primaryLocal;
if ( !localGripAxis.IsNearZeroLength )
{
Vector3 worldGripAxis = oneHandRotation * localGripAxis.Normal;
targetRotation = Rotation.FromToRotation( worldGripAxis, directionToFrontHand ) * oneHandRotation;
}
if ( gun is not null )
targetRotation *= gun.VrRecoilRotation;
// Blend from the last one-hand pose when the support hand first grabs
if ( _lastActiveGripCount < 2 )
{
_twoHandBlendFromRotation = itemRoot.WorldRotation;
_twoHandBlendStartTime = Time.Now;
}
float blendTime = Math.Max( TwoHandBlendTime, 0f );
if ( blendTime > 0f && _twoHandBlendStartTime >= 0f )
{
float t = Math.Clamp( (Time.Now - _twoHandBlendStartTime) / blendTime, 0f, 1f );
if ( t < 1f )
targetRotation = Rotation.Slerp( _twoHandBlendFromRotation, targetRotation, t );
else
_twoHandBlendStartTime = -1f;
}
// Position is always derived from the (possibly blended) rotation so the
// primary grip stays pinned to the primary hand, including mid-blend.
Vector3 targetRootPosition = primaryHand.Position - (targetRotation * primaryLocal);
itemRoot.WorldTransform = new Transform( targetRootPosition, targetRotation );
}
_lastActiveGripCount = activeGrips.Count;
}
}
public void Grab( bool isLeftHand )
{
if ( IsGrabbed )
return;
if ( IsAnythingHeld && !Network.IsOwner )
return;
// Use ItemRoot, not GameObject.Root: a stowed item is parented to the
// player, so Root would resolve to the player object.
var rootObject = ItemRoot;
var inventoryItem = rootObject.Components.Get<InventoryItem>();
rootObject.Network.TakeOwnership();
IsGrabbed = true;
IsLeftHand = isLeftHand;
// Discard stale motion samples from a previous hold
_throwSampleHead = 0;
_throwSampleTotal = 0;
_lastActiveGripCount = 0;
_twoHandBlendStartTime = -1f;
_secondaryOnlyOffsetValid = false;
inventoryItem.IsPhysicsEnabled = false;
if ( inventoryItem.IsStowed )
{
rootObject.SetParent( null, true );
inventoryItem.Unstow();
}
OnGrab?.Invoke();
}
/// <summary>
/// Releases this grab point. Pass <paramref name="throwVelocity"/> to launch the
/// item (used by desktop throws); VR throws derive velocity from hand motion instead.
/// </summary>
public void Drop( Vector3? throwVelocity = null )
{
if ( !IsGrabbed ) return;
IsGrabbed = false;
_lastActiveGripCount = 0;
_twoHandBlendStartTime = -1f;
_secondaryOnlyOffsetValid = false;
// Make sure a held trigger doesn't carry over to the next grab
SetTriggerState( false );
if ( !IsAnythingHeld )
{
var rootObject = ItemRoot;
var inventoryItem = rootObject.Components.Get<InventoryItem>();
bool isStowed = inventoryItem is not null && inventoryItem.IsStowed;
// A stowed item stays attached to the player: keep ownership and the
if ( !isStowed )
{
inventoryItem.IsPhysicsEnabled = true;
// Apply throw velocity while we still own the object so the
// physics state carries over the network before ownership drops
if ( throwVelocity.HasValue )
{
var rigidbody = rootObject.GetComponent<Rigidbody>( includeDisabled: true );
if ( rigidbody is not null )
rigidbody.Velocity = throwVelocity.Value;
}
else if ( Game.IsRunningInVR )
{
ApplyThrowVelocity( rootObject );
}
}
}
OnDrop?.Invoke();
}
public void Reload()
{
OnReload?.Invoke();
}
/// <summary>
/// Reports the current trigger state every frame. Drives the held/pressed/released
/// events via edge detection so callers don't need to track press state themselves.
/// </summary>
public void SetTriggerState( bool down )
{
if ( down && !_triggerDown ) OnTriggerPressed?.Invoke();
if ( !down && _triggerDown ) OnTriggerReleased?.Invoke();
if ( down ) OnTrigger?.Invoke();
_triggerDown = down;
}
/// <summary>
/// Transfers the hand's recent motion onto the item's rigidbody so releasing
/// mid-swing throws the object instead of dropping it straight down.
/// </summary>
private void ApplyThrowVelocity( GameObject rootObject )
{
if ( _throwSampleTotal < 2 )
return;
var rigidbody = rootObject.GetComponent<Rigidbody>( includeDisabled: true );
if ( rigidbody is null )
return;
// Best average velocity over any span in the ring buffer so a late grip
// release (after the swing slows) still keeps the throw impulse.
var playerVelocity = PlayerCharacterController.Instance?.Velocity ?? Vector3.Zero;
var bestRelative = Vector3.Zero;
float bestSpeedSq = 0f;
Rotation bestFromRotation = default;
Rotation bestToRotation = default;
float bestElapsed = 0f;
int count = _throwSampleTotal;
for ( var i = 0; i < count - 1; i++ )
{
for ( var j = i + 1; j < count; j++ )
{
var older = _throwSamples[ThrowSampleIndex( _throwSampleHead - count + i )];
var newer = _throwSamples[ThrowSampleIndex( _throwSampleHead - count + j )];
float elapsed = newer.Time - older.Time;
if ( elapsed <= 1e-4f )
continue;
var relative = (newer.Position - older.Position) / elapsed - playerVelocity;
float speedSq = relative.LengthSquared;
if ( speedSq <= bestSpeedSq )
continue;
bestSpeedSq = speedSq;
bestRelative = relative;
bestFromRotation = older.Rotation;
bestToRotation = newer.Rotation;
bestElapsed = elapsed;
}
}
if ( bestSpeedSq <= 0f || bestElapsed <= 0f )
return;
rigidbody.Velocity = playerVelocity + bestRelative * ThrowVelocityScale;
rigidbody.AngularVelocity = ComputeAngularVelocity( bestFromRotation, bestToRotation, bestElapsed );
}
private static int ThrowSampleIndex( int index )
{
return ((index % ThrowSampleCount) + ThrowSampleCount) % ThrowSampleCount;
}
/// <summary>
/// Converts the rotation change between two samples into an angular velocity
/// vector (radians/s) suitable for Rigidbody.AngularVelocity.
/// </summary>
private static Vector3 ComputeAngularVelocity( Rotation from, Rotation to, float elapsed )
{
var delta = to * from.Inverse;
// Flip to the shortest arc so the spin direction is correct
if ( delta.w < 0f )
delta = new Rotation( -delta.x, -delta.y, -delta.z, -delta.w );
var axis = new Vector3( delta.x, delta.y, delta.z );
if ( axis.LengthSquared < 1e-8f )
return Vector3.Zero;
float angle = 2f * MathF.Acos( Math.Clamp( delta.w, -1f, 1f ) );
return axis.Normal * (angle / elapsed);
}
private Vector3 GetGrabAnchorLocalOffset( Transform rootWorldTransform )
{
return rootWorldTransform.PointToLocal( WorldPosition );
}
/// <summary>
/// Follow the secondary hand using the weapon's pose at the moment it became
/// secondary-only, instead of snapping the foregrip collider onto the controller.
/// </summary>
private void ApplySecondaryOnlyHold( GameObject itemRoot, Gun gun )
{
var hand = HandTransform;
if ( !_secondaryOnlyOffsetValid )
{
var root = itemRoot.WorldTransform;
_secondaryOnlyLocalPos = hand.Rotation.Inverse * (root.Position - hand.Position);
_secondaryOnlyLocalRot = hand.Rotation.Inverse * root.Rotation;
_secondaryOnlyOffsetValid = true;
}
var targetPosition = hand.Position + hand.Rotation * _secondaryOnlyLocalPos;
var targetRotation = hand.Rotation * _secondaryOnlyLocalRot;
if ( gun is not null )
targetRotation *= gun.VrRecoilRotation;
itemRoot.WorldTransform = new Transform( targetPosition, targetRotation );
}
}