Code/MuerteNextbot.cs

Component attached to enemy prefabs that handles death behavior for nextbots. When Vida signals death it stops AI and sensory components, optionally plays a death sound, turns the model into a ragdoll (or creates a Rigidbody fallback), applies impulses and damping, waits a configurable time, then fades out and destroys the GameObject.

Native Interop
namespace SBH;

using Sandbox;
using System;

/// <summary>
/// Muerte del nextbot: al llegar a 0 de Vida, apaga IA y sonido,
/// convierte el cuerpo en ragdoll (física del vmdl) y pasado un tiempo
/// lo desvanece en vez de borrarlo de golpe. Si el modelo no tiene
/// física compilada, usa Rigidbody + la cápsula como ragdoll rígido.
/// Va en el prefab del sprunki, junto a Vida.
/// </summary>
public sealed class MuerteNextbot : Component
{
	[RequireComponent] Vida Vida { get; set; }

	[Property, Description( "Sonido al morir (opcional)" )]
	public SoundEvent SonidoMuerte { get; set; }

	[Property, Description( "Segundos hasta que el cuerpo empieza a desvanecerse (0 = se queda para siempre)" )]
	public float TiempoDespawn { get; set; } = 10f;

	[Property, Description( "Segundos que dura el desvanecimiento" )]
	public float TiempoDesvanecer { get; set; } = 2f;

	[Property, Description( "Fuerza del empujón al morir (para que se desplome)" )]
	public float FuerzaEmpujon { get; set; } = 5f;

	[Property, Description( "Secuencia del vmdl para la pose del cadáver (evita que quede congelado corriendo)" )]
	public string AnimReposo { get; set; } = "idle";

	[Property, Description( "Peso aparente: los impulsos de muerte (empujón, ejecución) se DIVIDEN por esto. 1 = normal; los colosos usan su altura/150 para no volar como papel" )]
	public float Peso { get; set; } = 1f;

	[Property, Description( "Amortiguación del ragdoll articulado (colosos): frena el cadáver que se va de viaje o gira solo cuando el solver pelea entre cuerpos grandes. 0 = física pura, el default de siempre. Probar 1-3 en gigantes" )]
	public float AmortiguacionRagdoll { get; set; } = 0f;

	[Property, Description( "Amortiguación MIENTRAS la physgun lo sostiene: deja sacudir el cadáver 'como mantel sucio' sin que los joints se desarmen, y al soltarlo vuelve a caer normal (la amortiguación fija hacía que cayera en cámara lenta). 0 = desactivado. Lo maneja el puente del juego" )]
	public float AmortiguacionAgarre { get; set; } = 5f;

	/// <summary>Aplica amortiguación a los cuerpos del ragdoll YA encendido.
	/// La llama el puente de la physgun (la librería no conoce al arma del
	/// juego): alto mientras lo zarandean, de vuelta a AmortiguacionRagdoll
	/// al soltarlo para que el cadáver caiga con peso natural.</summary>
	public void AplicarAmortiguacion( float lineal )
	{
		var fisica = Components.Get<ModelPhysics>();
		if ( !fisica.IsValid() ) return;

		foreach ( var cuerpo in fisica.Bodies )
		{
			if ( !cuerpo.Component.IsValid() ) continue;
			cuerpo.Component.LinearDamping = lineal;
			cuerpo.Component.AngularDamping = lineal * 2f;
		}
	}

	[Property, Description( "Congela la animación al morir. El ragdoll se enciende SOBRE el bot vivo, así que el renderer sigue reproduciendo la secuencia y REESCRIBE los huesos cada frame peleando contra los cuerpos físicos: los huesos sin cuerpo tiemblan y, si hay pocos cuerpos, el cadáver entero flota por el mapa. Dejar en ON salvo que se necesite un cadáver animado" )]
	public bool CongelarAnimAlMorir { get; set; } = true;

	[Property, Description( "Este cadáver SIEMPRE se desvanece a los TiempoDespawn segundos, ignorando el interruptor global sbh.ragdolls (para colosos que no deben acumularse)" )]
	public bool DespawnForzado { get; set; } = false;

	protected override void OnEnabled()
	{
		Vida.AlMorir += Morir;
	}

	protected override void OnDisabled()
	{
		Vida.AlMorir -= Morir;
	}

	async void Morir()
	{
		// Marca pública de cadáver: otros sistemas (ritmos, integraciones)
		// distinguen "muerto" sin adivinar por componentes de física
		GameObject.Tags.Add( "sbh_muerto" );

		if ( SonidoMuerte != null )
			Sound.Play( SonidoMuerte, WorldPosition );

		// 1. Apagar la IA, los sentidos, la cara y el sonido de pánico.
		// La cabeza primero: debe soltar su override del hueso antes de
		// que el ragdoll tome el esqueleto, o la física pelea contra él.
		Apagar<CabezaNextbot>();
		Apagar<SaltoNextbot>();
		Apagar<NextbotBrain>();
		Apagar<PercepcionNextbot>();
		Apagar<NavMeshAgent>();
		Apagar<PanicSound>();

		var renderer = Components.Get<SkinnedModelRenderer>();

		// 2. Pose de reposo ANTES del ragdoll. Solo el hueso Hips tiene cuerpo
		// físico: el resto del esqueleto queda congelado en la pose del momento
		// de morir. Si murió corriendo, quedaría a mitad de zancada — así que
		// cambiamos a la pose de reposo (sin blending, snap instantáneo) y
		// esperamos un frame a que el esqueleto la aplique.
		if ( renderer.IsValid() && !string.IsNullOrEmpty( AnimReposo ) )
		{
			// El animgraph debe apagarse primero: Sequence solo funciona sin él,
			// y el ragdoll tampoco lo quiere activo (doc oficial de UseAnimGraph)
			renderer.UseAnimGraph = false;

			renderer.Sequence.Blending = false;
			renderer.Sequence.Name = AnimReposo;

			await Task.Frame();
			if ( !GameObject.IsValid() ) return;
		}

		// 3. Activar el ragdoll: crear apagado, configurar TODO y recién ahí
		// encender. ModelPhysics necesita Model y Renderer asignados ANTES de
		// habilitarse, si no, no construye ningún cuerpo físico.
		var fisica = Components.Get<ModelPhysics>( true );
		if ( fisica == null )
			fisica = Components.Create<ModelPhysics>( false );

		// Congelar la animación ANTES de encender la física: si el renderer
		// sigue tickeando la secuencia, cada frame reescribe los huesos y la
		// física los corrige — realimentación. Con muchos cuerpos casi no se
		// nota (todos los huesos son físicos); con pocos, el cadáver flota
		// por el mapa (caso Siren Head con un único cuerpo).
		if ( CongelarAnimAlMorir && renderer.IsValid() )
			renderer.PlaybackRate = 0f;

		fisica.Enabled = false;
		fisica.Renderer = renderer;
		fisica.Model = renderer?.Model;
		fisica.MotionEnabled = true;
		fisica.StartAsleep = false;
		fisica.Enabled = true;

		// 4. Empujón para que se desplome en vez de quedar parado en equilibrio.
		// Si Bodies queda vacío (hull sin hueso válido), el ragdoll no existe
		// aunque PhysicsWereCreated diga que sí — en ese caso, plan B.
		// Peso aparente (2026-07-29): un coloso no vuela — los impulsos se
		// dividen por Peso (1 en rigs normales = comportamiento de siempre)
		var peso = System.MathF.Max( Peso, 1f );

		var cuerpos = 0;
		foreach ( var cuerpo in fisica.Bodies )
		{
			if ( !cuerpo.Component.IsValid() ) continue;

			cuerpo.Component.AngularVelocity = Vector3.Random * (FuerzaEmpujon / peso);

			// Ejecución: si murió por un golpe con impulso, el cadáver vuela
			if ( Vida.ImpulsoMortal != Vector3.Zero )
				cuerpo.Component.Velocity = Vida.ImpulsoMortal / peso;

			// Amortiguación opcional (2026-08-01, lección Siren Head): en un
			// coloso los cuerpos grandes se empujan entre sí y el cadáver sale
			// "de viaje" contra las paredes. Con damping la energía se disipa
			// y el muñeco se acuesta. Default 0 = rigs normales sin cambios.
			if ( AmortiguacionRagdoll > 0f )
			{
				cuerpo.Component.LinearDamping = AmortiguacionRagdoll;
				cuerpo.Component.AngularDamping = AmortiguacionRagdoll * 2f;
			}

			cuerpos++;
		}

		if ( cuerpos > 0 )
		{
			// Ragdoll real: los colliders del bot VIVO ya no hacen falta — el
			// ragdoll trae su propia física. Y si quedan encendidos PELEAN
			// contra los cuerpos del ragdoll y expulsan el cadáver (caso Siren
			// Head: su BoxCollider de daño de gigante lo mandaba a volar por
			// el mapa; los rigs con CapsuleCollider nunca lo notaron porque
			// esa sí se apagaba desde siempre).
			Apagar<CapsuleCollider>();
			Apagar<BoxCollider>();
			Apagar<SphereCollider>();

			Log.Info( $"☠️ {GameObject.Name} eliminado → ragdoll con {cuerpos} cuerpo(s)" );
		}
		else
		{
			// Plan B: ragdoll rígido con Rigidbody + la cápsula
			// (que debe quedar ENCENDIDA para esto).
			fisica.Destroy();

			var rb = Components.GetOrCreate<Rigidbody>();
			rb.AngularVelocity = Vector3.Random * (FuerzaEmpujon / peso);
			// Freno anti-trompo (2026-07-27) SOLO angular. El LinearDamping
			// bajó de 0.5 a 0.05 (2026-07-29): frenaba la CAÍDA y el coloso
			// bajaba flotando como papel — el peso se tiene que NOTAR
			rb.LinearDamping = 0.05f;
			rb.AngularDamping = 2f;

			if ( Vida.ImpulsoMortal != Vector3.Zero )
				rb.Velocity = Vida.ImpulsoMortal / peso;

			Log.Warning( $"☠️ {GameObject.Name}: el ragdoll del modelo no generó cuerpos, uso Rigidbody de emergencia" );
		}

		// 5. Esperar tirado en el piso y desvanecerse (0 = se queda para siempre)
		if ( TiempoDespawn > 0f )
		{
			try
			{
				await Task.DelaySeconds( TiempoDespawn );

				// Interruptor del menú (sbh.ragdolls): con la limpieza apagada el
				// cadáver espera; si la reactivan, se desvanece al ratito.
				// DespawnForzado (colosos) lo IGNORA: este cadáver se va sí o sí
				while ( !DespawnForzado && !SbhConVars.LimpiarRagdolls )
				{
					if ( !GameObject.IsValid() ) return;
					await Task.DelaySeconds( 1f );
				}
			}
			catch ( OperationCanceledException )
			{
				// El cadáver fue borrado a mano (menú C, undo) a mitad de la
				// espera: el engine cancela la task del componente destruido
				// y el await revienta — no queda nada que desvanecer
				return;
			}

			Desvanecer( renderer );
		}
	}

	/// <summary>
	/// Congela la física en la pose actual y desvanece el cadáver:
	/// baja el alpha del tinte mientras se hunde en el piso, y al final
	/// destruye el GameObject.
	/// </summary>
	async void Desvanecer( SkinnedModelRenderer renderer )
	{
		if ( !GameObject.IsValid() ) return;

		// Congelar la física para que no pelee con el hundimiento,
		// y dejar de chocar con el jugador
		foreach ( var rb in Components.GetAll<Rigidbody>( FindMode.EverythingInSelfAndDescendants ) )
			rb.MotionEnabled = false;

		foreach ( var col in Components.GetAll<Collider>( FindMode.EverythingInSelfAndDescendants ) )
			col.Enabled = false;

		var tintInicial = renderer.IsValid() ? renderer.Tint : Color.White;
		var transcurrido = 0f;

		try
		{
			while ( transcurrido < TiempoDesvanecer )
			{
				if ( !GameObject.IsValid() ) return;

				transcurrido += Time.Delta;
				var visible = 1f - (transcurrido / TiempoDesvanecer).Clamp( 0f, 1f );

				if ( renderer.IsValid() )
					renderer.Tint = tintInicial.WithAlpha( visible );

				// Hundirse en el piso, por si el material no soporta transparencia
				WorldPosition += Vector3.Down * 25f * Time.Delta;

				await Task.Frame();
			}
		}
		catch ( OperationCanceledException )
		{
			// Borrado a mano a mitad del desvanecimiento: ya lo destruyó otro
			return;
		}

		GameObject.Destroy();
	}

	void Apagar<T>() where T : Component
	{
		var c = Components.Get<T>();
		if ( c != null ) c.Enabled = false;
	}
}