PropGrabber.cs
using System;
using System.Collections.Generic;
using Sandbox;

/// <summary>
/// A general purpose prop  grabber.
/// Works with only a transform for the raycast (like a camera). Optionally interacts with an IPropUser for custom behavior.
/// </summary>
public sealed class PropGrabber : Component
{
	/// <summary>
	/// The GameObject to use as the transform origin of the raycast for picking up props (e.g. a camera).
	/// </summary>
	[Property] public GameObject RaycastSource { get; set; }

	/// <summary>
	/// How far the grabber can pick up props from, in units.
	/// </summary>
	[Property, Group( "Settings" )] public float PickupRange { get; set; } = 200f;
	/// <summary>
	/// The distance from the RaycastSource where the prop is held.
	/// </summary>
	[Property, Group( "Settings" )] public float HoldDistance { get; set; } = 70f;
	/// <summary>
	/// The force applied to the prop when thrown.
	/// </summary>
	[Property, Group( "Settings" )] public float ThrowForce { get; set; } = 300f;
	/// <summary>
	/// The max velocity allowed before the prop is automatically dropped.
	/// Only matters if <see cref="DropFromVelocity"/> is true.
	/// </summary>
	[Property, Group( "Settings" )] public float MaxHoldVelocity { get; set; } = 2000f;
	/// <summary>
	/// The max angle (degrees) from the forward direction before the prop is dropped from being out of view.
	/// Only matters if <see cref="DropFromAngle"/> is true.
	/// </summary>
	[Property, Group( "Settings" )] public float MaxFovAngle { get; set; } = 60f;
	/// <summary>
	/// Increase to make the pop move faster. Decrease for smoother movement, but too low makes it move stubbornly.
	/// </summary>
	[Property, Group( "Settings" )] public float MaxMoveSpeed { get; set; } = 500f;

	/// <summary>
	/// Whether the player is allowed to throw the held prop.
	/// </summary>
	[Property, Group( "Toggles" )] public bool CanThrow { get; set; } = true;
	/// <summary>
	/// Whether the prop should be dropped if its velocity exceeds <see cref="MaxHoldVelocity"/>.
	/// </summary>
	[Property, Group( "Toggles" )] public bool DropFromVelocity { get; set; } = true;
	/// <summary>
	/// Whether the prop should be dropped if it moves outside the field of view defined by <see cref="MaxFovAngle"/>.
	/// </summary>
	[Property, Group( "Toggles" )] public bool DropFromAngle { get; set; } = true;

	/// <summary>
	/// Limits pickup to objects with a <see cref="Prop"/> component.
	/// </summary>
	[Property, Group( "Pickup Requirements" )]
	public bool RequirePropComponent = true;

	/// <summary>
	/// Tags that the pickupable object must have in order to be picked up, like "prop".
	/// Leave empty to ignore requiring any tag.
	/// </summary>
	[Property, Group( "Pickup Requirements" )]
	public TagSet RequireTags { get; set; } = new();

	[Sync] private Rigidbody HeldRigidbody { get; set; }
	[Sync] private Vector3 TargetPosition { get; set; }

	[Property]
	private IPropUser PropUser;

	protected override void OnUpdate()
	{
		if ( !RaycastValid() )
		{
			Log.Warning("No RaycastSource found for PropGrabber.");
			return;
		}

		HandleInput();
		UpdateHeldPropPosition();
		CheckDropConditions();
	}

	private bool RaycastValid() => RaycastSource != null && RaycastSource.IsValid();

	private void HandleInput()
	{
		if ( Network.IsProxy ) return;

		if ( Input.Pressed( "use" ) )
		{
			if ( HeldRigidbody == null )
				TryPickup();
			else
				DropProp();
		}
		else if ( Input.Pressed( "attack1" ) && HeldRigidbody != null && CanThrow )
		{
			ThrowProp();
		}
	}

	private void TryPickup()
	{
		var ray = new Ray( RaycastSource.WorldPosition, RaycastSource.WorldRotation.Forward );

		var trace = Scene.Trace
			.Ray( ray, PickupRange )
			.WithAnyTags( RequireTags )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		if ( !trace.Hit || trace.GameObject == null ) return;

		Rigidbody rigidbody = trace.GameObject.Components.Get<Rigidbody>();
		if ( rigidbody == null ) return;

		if ( RequirePropComponent )
		{
			Prop prop = trace.GameObject.Components.Get<Prop>();
			if ( prop == null || !prop.IsValid() )
				return;
		}

		HeldRigidbody = rigidbody;

		if ( !trace.GameObject.Network.IsOwner )
			trace.GameObject.Network.TakeOwnership();

		HeldRigidbody.Gravity = false;
		PropUser?.OnPropGrabbed();
	}

	private void UpdateHeldPropPosition()
	{
		if ( HeldRigidbody == null ) return;

		Vector3 sourcePos = RaycastSource.WorldPosition;
		Rotation sourceRot = RaycastSource.WorldRotation;
		TargetPosition = sourcePos + sourceRot.Forward * HoldDistance;

		Vector3 desiredVelocity = (TargetPosition - HeldRigidbody.WorldPosition) * 20f;

		if ( desiredVelocity.Length > MaxMoveSpeed )
		{
			Vector3 direction = desiredVelocity.Normal;
			desiredVelocity = direction * MaxMoveSpeed;
		}

		HeldRigidbody.Velocity = desiredVelocity;
		HeldRigidbody.AngularVelocity *= 0.9f;
	}

	private void CheckDropConditions()
	{
		if ( HeldRigidbody == null ) return;

		if ( DropFromVelocity && HeldRigidbody.Velocity.Length > MaxHoldVelocity )
		{
			DropProp();
			return;
		}

		Vector3 propDirection = (HeldRigidbody.WorldPosition - RaycastSource.WorldPosition).Normal;
		Vector3 sourceForward = RaycastSource.WorldRotation.Forward;
		float dot = Vector3.Dot( sourceForward, propDirection );
		float angle = MathF.Acos( dot ) * 180f / MathF.PI;

		if ( DropFromAngle && angle > MaxFovAngle )
		{
			DropProp();
		}
	}

	private void DropProp()
	{
		if ( HeldRigidbody == null ) return;

		HeldRigidbody.Gravity = true;
		HeldRigidbody.AngularVelocity = Vector3.Zero;

		PropUser?.OnPropReleased();
		CleanupHeldProp();
	}

	private void ThrowProp()
	{
		if ( HeldRigidbody == null ) return;

		HeldRigidbody.Gravity = true;
		HeldRigidbody.Velocity = RaycastSource.WorldRotation.Forward * ThrowForce;

		PropUser?.OnPropReleased();
		CleanupHeldProp();
	}

	private void CleanupHeldProp()
	{
		if ( HeldRigidbody != null && HeldRigidbody.IsValid() && HeldRigidbody.GameObject.Network.IsOwner )
		{
			HeldRigidbody.GameObject.Network.DropOwnership();
		}

		HeldRigidbody = null;
	}

	protected override void OnDestroy()
	{
		DropProp();
	}
}