Npcs/Layers/AnimationLayer.Hold.cs
using Sandbox.Citizen;
namespace Sandbox.Npcs.Layers;
public sealed partial class AnimationLayer
{
/// <summary>
/// The prop currently being held, if any.
/// </summary>
public GameObject HeldProp => _heldProp;
private GameObject _heldProp;
private float _holdPose;
private bool _oneHanded;
/// <summary>
/// Pick up and hold a prop — disables physics
///
/// holdtype_pose ranges:
/// 0-2 : close grip (~16u out), interpolates weight poses — used for heavy objects, but small enough to hold close
/// 2-4 : arms extend outwards — used for normal objects, mapped by width
/// 4-5 : above the head — used for large objects
/// </summary>
public void SetHeldProp( GameObject prop )
{
if ( !prop.IsValid() ) return;
_heldProp = prop;
// Grab mass before disabling physics
var rb = prop.GetComponent<Rigidbody>( true );
var mass = rb?.Mass ?? 1f;
if ( rb.IsValid() )
rb.Enabled = false;
// Measure the object
var bounds = prop.GetBounds();
var size = bounds.Size;
var width = MathF.Max( size.x, size.y );
var diagonal = size.Length;
// Determine pose and hold offset from object properties
Vector3 holdOffset;
var holdRotation = Npc.WorldRotation;
// Small, light objects can be held one-handed
_oneHanded = diagonal < 32f && mass <= 128;
// TODO: too many magic numbers
if ( diagonal >= 64f )
{
// Large — above the citizen's head (pose 4-5)
var t = ((diagonal - 64f) / 64f).Clamp( 0f, 1f );
_holdPose = 4f + t;
holdOffset = Vector3.Up * 66f + Npc.WorldRotation.Forward * 4f;
// Orient the long axis forward so it doesn't stick out sideways
prop.WorldRotation = holdRotation;
var heldSize = prop.GetBounds().Size;
var left = holdRotation.Left;
var fwd = holdRotation.Forward;
var sideExtent = MathF.Abs( heldSize.x * left.x ) + MathF.Abs( heldSize.y * left.y );
var fwdExtent = MathF.Abs( heldSize.x * fwd.x ) + MathF.Abs( heldSize.y * fwd.y );
if ( sideExtent > fwdExtent * 1.2f )
{
holdRotation *= Rotation.FromAxis( Vector3.Up, 90f );
}
}
else if ( mass > 128 )
{
// Heavy — close grip (pose 0-2)
var t = ((mass - 30f) / 170f).Clamp( 0f, 1f );
_holdPose = t * 2f;
holdOffset = Npc.WorldRotation.Forward * 8f + Vector3.Up * 30f;
}
else
{
// Normal — arms extend by width (pose 2-4, distance 16-32)
var t = (width / 32f).Clamp( 0f, 1f );
_holdPose = 2f + t * 2f;
var forwardDist = 8 + t * 8f;
holdOffset = Npc.WorldRotation.Forward * forwardDist + Vector3.Up * 30f;
}
// One-handed: parent directly to the right hand bone
// Two-handed: parent to spine so it sways with the walk cycle
GameObject parent;
if ( _oneHanded )
{
var handBone = _renderer?.GetBoneObject( "hold_R" );
parent = handBone ?? Npc.GameObject;
prop.WorldPosition = parent.WorldPosition;
prop.WorldRotation = holdRotation;
prop.SetParent( parent, true );
}
else
{
var bone = _renderer?.GetBoneObject( "spine_2" );
parent = bone ?? Npc.GameObject;
prop.WorldPosition = Npc.WorldPosition + holdOffset;
prop.WorldRotation = holdRotation;
prop.SetParent( parent, true );
}
_renderer?.Set( "holdtype", (int)CitizenAnimationHelper.HoldTypes.HoldItem );
_renderer?.Set( "holdtype_pose", _holdPose );
_renderer?.Set( "holdtype_pose_hand", 0.005f );
_renderer?.Set( "holdtype_handedness",
(int)(_oneHanded ? CitizenAnimationHelper.Hand.Right : CitizenAnimationHelper.Hand.Left) );
}
/// <summary>
/// Drop the held prop — clears IK, holdtype, holdtype_pose, places the prop
/// on the ground in front of the NPC, unparents, and re-enables physics.
/// Safe to call when nothing is held.
/// </summary>
public void ClearHeldProp()
{
if ( _renderer is not null )
{
_renderer.Set( "holdtype", 0 );
_renderer.Set( "holdtype_pose", 0f );
_renderer.Set( "holdtype_handedness", 0 );
_renderer.ClearIk( "hand_right" );
_renderer.ClearIk( "hand_left" );
}
if ( _heldProp.IsValid() )
{
// Use the prop's forward extent + padding so it lands clear of the Npc
var bounds = _heldProp.GetBounds();
var fwd = Npc.WorldRotation.Forward;
var forwardExtent = MathF.Abs( bounds.Extents.x * fwd.x )
+ MathF.Abs( bounds.Extents.y * fwd.y );
var dropDist = forwardExtent + 12f;
var dropPos = Npc.WorldPosition
+ fwd * dropDist
+ Vector3.Up * bounds.Extents.z;
_heldProp.WorldPosition = dropPos;
_heldProp.WorldRotation = Npc.WorldRotation;
_heldProp.SetParent( null, true );
if ( _heldProp.GetComponent<Rigidbody>( true ) is { } rb )
rb.Enabled = true;
}
_heldProp = null;
_holdPose = 0f;
_oneHanded = false;
}
/// <summary>
/// Update IK hand targets each frame from the held prop's bounds.
/// If the object is too wide to grip from the sides, support from below with palms up.
/// </summary>
private void UpdateHeldPropIk()
{
if ( _renderer is null || !_heldProp.IsValid() ) return;
if ( _oneHanded )
{
_renderer.ClearIk( "hand_right" );
_renderer.ClearIk( "hand_left" );
return;
}
var bounds = _heldProp.GetBounds();
var center = bounds.Center;
var forward = _heldProp.WorldRotation.Forward;
var left = _heldProp.WorldRotation.Left;
var halfSpread = MathF.Max( MathF.Max( bounds.Extents.x, bounds.Extents.y ), 12f );
Rotation rightRot;
Rotation leftRot;
Vector3 rightHandPos;
Vector3 leftHandPos;
if ( halfSpread > 24 )
{
// Too wide to grip from the sides — support from below, palms up
rightHandPos = center - left * halfSpread + Vector3.Down * bounds.Extents.z;
leftHandPos = center + left * halfSpread + Vector3.Down * bounds.Extents.z;
rightRot = Rotation.LookAt( forward, Vector3.Down );
leftRot = Rotation.LookAt( forward, Vector3.Up );
}
else
{
// Narrow enough to grip from the sides, palms inward
rightHandPos = center - left * halfSpread;
leftHandPos = center + left * halfSpread;
rightRot = Rotation.LookAt( forward, -left );
leftRot = Rotation.LookAt( forward, -left );
}
_renderer.SetIk( "hand_right", new Transform( rightHandPos, rightRot ) );
_renderer.SetIk( "hand_left", new Transform( leftHandPos, leftRot ) );
}
}