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.
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 );
}
}