Code/AgarreNextbot.cs

Component for nextbots that allows the physgun to pick them up without breaking their AI. It creates a temporary Rigidbody when the physgun looks at a live nextbot, suspends AI components, plays a fall animation while suspended, and restores AI and navmesh position when the bot lands or after a grace period.

Native Interop
namespace SBH;

using Sandbox;
using System.Collections.Generic;

/// <summary>
/// [SBHBase] Permite agarrar al nextbot con la physgun sin romperle la IA.
/// El problema histórico: la physgun solo agarra Rigidbody con MotionEnabled,
/// pero un Rigidbody PERMANENTE pelea con el NavMeshAgent aunque nadie lo
/// agarre (bots atorados en el aire — feature eliminada 2026-07-16). La
/// solución: el Rigidbody se crea RECIÉN cuando la physgun mira al bot
/// (gancho en Physgun.cs → PrepararSiEsNextbot) y se destruye al aterrizar,
/// devolviéndole la IA en el lugar donde cayó.
/// Mientras está suspendido reproduce la anim "fall" vía el animgraph
/// compartido (parámetro b_fall) y el Rigidbody sale sin daño por impacto
/// (EnableImpactDamage = false — la physgun ya no lastima).
/// El componente se crea solo bajo demanda: los prefabs quedan limpios.
/// </summary>
public sealed class AgarreNextbot : Component
{
	[Property, Description( "Segundos sin que la physgun lo mire/sostenga antes de devolverle la IA (si además está en el piso)" )]
	public float Gracia { get; set; } = 0.6f;

	[Property, Description( "Velocidad por debajo de la cual se considera que aterrizó" )]
	public float VelocidadReposo { get; set; } = 80f;

	[Property, Description( "Nombre del parámetro bool del animgraph para la anim de caída" )]
	public string ParametroCaida { get; set; } = "b_fall";

	bool _suspendido;
	bool _fueAgarrado;        // pasó de hover a agarre real
	TimeSince _interes;       // desde la última vez que la physgun lo miró
	TimeSince _sostenido;     // desde el último frame sostenido/tironeado
	TimeSince _quieto;        // desde que está lento y apoyado
	Rigidbody _rb;
	Vida _vida;
	SkinnedModelRenderer _renderer;
	readonly List<Component> _apagados = new();

	// ---------- API estática (la llama el parche de Physgun.cs) ----------

	/// <summary>
	/// La physgun está mirando este objeto: si es un nextbot vivo, prepararlo
	/// (Rigidbody al vuelo + IA suspendida). Un frame después ya es agarrable.
	/// </summary>
	public static void PrepararSiEsNextbot( GameObject go )
	{
		if ( go == null || !go.IsValid() ) return;

		var root = go.Root;
		if ( !root.IsValid() ) return;
		if ( root.Components.Get<NextbotBrain>( true ) == null ) return;

		// Cadáveres no: el ragdoll de MuerteNextbot es quien manda ahí
		var vida = root.Components.Get<Vida>();
		if ( vida.IsValid() && vida.EstaMuerto ) return;
		if ( root.Components.Get<ModelPhysics>().IsValid() ) return;

		root.Components.GetOrCreate<AgarreNextbot>().Preparar();
	}

	/// <summary> La physgun lo está sosteniendo o tironeando este frame. </summary>
	public static void MarcarSostenido( GameObject go )
	{
		if ( go == null || !go.IsValid() ) return;
		go.Root?.Components.Get<AgarreNextbot>()?.RegistrarSostenido();
	}

	// ---------- Ciclo ----------

	protected override void OnEnabled()
	{
		_vida = Components.Get<Vida>();
		if ( _vida.IsValid() )
			_vida.AlMorir += MuerteEnElAire;
	}

	protected override void OnDisabled()
	{
		if ( _vida.IsValid() )
			_vida.AlMorir -= MuerteEnElAire;
	}

	void Preparar()
	{
		_interes = 0f;
		if ( _suspendido ) return;

		_suspendido = true;
		_fueAgarrado = false;
		_quieto = 0f;

		// Suspender la IA, recordando SOLO lo que estaba encendido
		_apagados.Clear();
		Apagar<CabezaNextbot>();
		Apagar<SaltoNextbot>();
		Apagar<NextbotBrain>();
		Apagar<NavMeshAgent>();

		// El Rigidbody usa la cápsula existente como forma. Sin daño por
		// impacto: que revolear al bot no lo lastime (ni a lo que golpea).
		// Pitch/roll BLOQUEADOS: el secreto de estabilidad de los personajes
		// oficiales — la cápsula no puede volcarse, así que siempre cae
		// parado (el yaw queda libre para girarlo con la physgun)
		_rb = Components.GetOrCreate<Rigidbody>();
		_rb.MotionEnabled = true;
		_rb.EnableImpactDamage = false;
		_rb.Locking = new PhysicsLock { Pitch = true, Roll = true };

		// La anim de caída NO se activa acá: OnFixedUpdate la liga a la
		// realidad (colgando o en el aire) — un hover con el bot apoyado
		// en el piso debe seguir viéndose en idle
		_renderer = Components.Get<SkinnedModelRenderer>();
	}

	void RegistrarSostenido()
	{
		_sostenido = 0f;
		_interes = 0f;
		_fueAgarrado = true;
	}

	protected override void OnFixedUpdate()
	{
		if ( !_suspendido ) return;

		var enPiso = EnPiso();

		// ¿Sigue en manos de la physgun? (agarrado, o apenas mirado de hover)
		var enManos = _fueAgarrado ? _sostenido < 0.15f : _interes < Gracia;

		// La anim de caída refleja la REALIDAD: colgando de la physgun o en
		// el aire. Parado en el piso — aunque siga suspendido — es idle
		_renderer?.Set( ParametroCaida, (_fueAgarrado && enManos) || !enPiso );

		if ( enManos )
		{
			_quieto = 0f;
			return;
		}

		// Suelto: esperar el aterrizaje para devolver la IA
		if ( _rb.IsValid() && _rb.MotionEnabled && _rb.Velocity.Length > VelocidadReposo )
		{
			_quieto = 0f;
			return;
		}

		// Congelado (o quieto) en el aire = estatua: queda suspendido ahí
		if ( !enPiso )
		{
			_quieto = 0f;
			return;
		}

		if ( _quieto < 0.1f ) return;

		Restaurar();
	}

	void Restaurar()
	{
		_suspendido = false;

		if ( _rb.IsValid() ) _rb.Destroy();
		_rb = null;

		// Enderezar por las dudas: solo yaw, sin ninguna inclinación residual
		WorldRotation = Rotation.FromYaw( WorldRotation.Yaw() );

		_renderer?.Set( ParametroCaida, false );

		foreach ( var c in _apagados )
		{
			if ( c.IsValid() ) c.Enabled = true;
		}
		_apagados.Clear();

		// Re-anclar el agente al navmesh donde cayó (si quedó fuera, el agente
		// lo acomoda al punto navegable más cercano)
		Components.Get<NavMeshAgent>()?.SetAgentPosition( WorldPosition );
	}

	/// <summary>
	/// Murió mientras estaba suspendido (un tiro en el aire): nuestro
	/// Rigidbody debe desaparecer ANTES de que MuerteNextbot arme el ragdoll,
	/// o la física pelea. La IA queda apagada — el muerto no camina.
	/// </summary>
	void MuerteEnElAire()
	{
		if ( !_suspendido ) return;

		_suspendido = false;
		_apagados.Clear();

		if ( _rb.IsValid() ) _rb.Destroy();
		_rb = null;

		_renderer?.Set( ParametroCaida, false );
	}

	bool EnPiso()
	{
		var tr = Scene.Trace.Ray( WorldPosition + Vector3.Up * 4f, WorldPosition + Vector3.Down * 12f )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();
		return tr.Hit;
	}

	void Apagar<T>() where T : Component
	{
		var c = Components.Get<T>();
		if ( c.IsValid() && c.Enabled )
		{
			c.Enabled = false;
			_apagados.Add( c );
		}
	}
}