Npcs/Layers/AnimationLayer.Hold.cs

Animation layer code that handles picking up, holding, updating IK for, and dropping props for an NPC. It chooses hold pose based on object size and mass, parents the prop to a bone or spine, disables/enables rigidbody, and updates hand IK each frame.

File AccessNative Interop
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 ) );
	}
}