Code/Vida.cs

Component that implements a generic health system (Vida). Stores max/current health, invulnerability window, temporary invulnerability flag, last attacker, and a mortal impulse used for ragdolls. Handles taking damage, healing, revival, and converts engine DamageInfo via IDamageable to health changes and computes a death impulse when appropriate.

Native Interop
namespace SBH;

using Sandbox;
using System;

/// <summary>
/// Sistema de vida genérico. Sirve para sprunkis, jugadores,
/// y cualquier cosa que pueda recibir daño y morir.
/// </summary>
public sealed class Vida : Component, Component.IDamageable, IVida
{
	[Property, Description( "Vida máxima" )]
	public float VidaMaxima { get; set; } = 100f;

	[Property, Description( "Vida actual (se llena sola al arrancar)" )]
	public float VidaActual { get; set; } = 100f;

	[Property, Description( "Segundos de invulnerabilidad tras recibir daño" )]
	public float TiempoInvulnerable { get; set; } = 0.1f;

	/// <summary> Se dispara al morir. Otros componentes se suscriben acá. </summary>
	public event Action AlMorir;

	/// <summary> Se dispara al recibir daño (pasa la cantidad). Útil para sonidos/UI. </summary>
	public event Action<float> AlRecibirDanio;

	public bool EstaMuerto => VidaActual <= 0f;
	public float Fraccion => VidaMaxima > 0 ? (VidaActual / VidaMaxima).Clamp( 0f, 1f ) : 0f;

	/// <summary> Quién hizo el último daño (null si nadie o fue anónimo). </summary>
	public GameObject UltimoAtacante { get; private set; }

	/// <summary>
	/// Blindaje temporal: mientras esté activo, TODO el daño se ignora.
	/// Lo usan otros componentes (agarre de physgun, spawns, cinemáticas)
	/// — p.ej. el daño por impacto físico de sandbox al soltar el bot.
	/// </summary>
	public bool Blindada { get; set; }

	/// <summary> Impulso con el que murió; MuerteNextbot lo aplica al ragdoll. Zero = muerte tranquila. </summary>
	public Vector3 ImpulsoMortal { get; private set; }

	Vector3 _impulsoMortalPendiente;

	/// <summary>
	/// Ejecución: si el PRÓXIMO golpe mata, el ragdoll sale despedido con este
	/// impulso. Solo vale para ese golpe; después se descarta.
	/// </summary>
	public void RegistrarImpulsoMortal( Vector3 impulso ) => _impulsoMortalPendiente = impulso;

	TimeSince _desdeUltimoDanio;

	protected override void OnStart()
	{
		VidaActual = VidaMaxima;
		_desdeUltimoDanio = 999f;
	}

	public void RecibirDanio( float cantidad, GameObject atacante = null )
	{
		if ( EstaMuerto ) return;
		if ( Blindada ) return;
		if ( _desdeUltimoDanio < TiempoInvulnerable ) return;

		_desdeUltimoDanio = 0f;
		VidaActual -= cantidad;
		UltimoAtacante = atacante;

		AlRecibirDanio?.Invoke( cantidad );

		if ( VidaActual <= 0f )
		{
			VidaActual = 0f;
			ImpulsoMortal = _impulsoMortalPendiente;
			AlMorir?.Invoke();
		}

		_impulsoMortalPendiente = Vector3.Zero;
	}

	public void Curar( float cantidad )
	{
		if ( EstaMuerto ) return;
		VidaActual = (VidaActual + cantidad).Clamp( 0f, VidaMaxima );
	}

	/// <summary> Revivir con vida completa (para respawns). </summary>
	public void Revivir()
	{
		VidaActual = VidaMaxima;
		_desdeUltimoDanio = 999f;
	}

	/// <summary>
	/// Puerta de entrada del daño de sandbox: armas, explosivos, NPCs y el
	/// kill command llegan por IDamageable en vez de llamar a RecibirDanio.
	/// </summary>
	void Component.IDamageable.OnDamage( in DamageInfo damage )
	{
		// [SBH] El empujón de la muerte (2026-08-09, "el ragdoll cae en
		// seco"): el DamageInfo trae de dónde vino el tiro pero acá se
		// descartaba — solo las ejecuciones melee registraban impulso. Si
		// este daño MATA y no hay impulso de ejecución ya anotado, el
		// cadáver hereda el del disparo: dirección radial desde el origen
		// (bala o explosión), fuerza escalada por el daño. La referencia es
		// el pecho, así una granada a los pies también LEVANTA.
		if ( _impulsoMortalPendiente == Vector3.Zero && VidaActual > 0f && damage.Damage >= VidaActual )
		{
			var origen = damage.Origin;
			if ( origen == Vector3.Zero && damage.Attacker.IsValid() )
				origen = damage.Attacker.WorldPosition;

			if ( origen != Vector3.Zero )
			{
				var dir = (WorldPosition + Vector3.Up * 30f - origen).Normal;
				// Pistola ~20 → empujón suave (~350); escopeta de cerca ~90
				// → tumbo (~700); explosión 100-300 → vuelo (tope 1800, la
				// escala de FuerzaEjecucion). MuerteNextbot divide por Peso.
				var fuerza = MathF.Min( 250f + damage.Damage * 5f, 1800f );
				_impulsoMortalPendiente = dir * fuerza + Vector3.Up * (fuerza * 0.25f);
			}
		}

		RecibirDanio( damage.Damage, damage.Attacker );
	}
}