HealthComponent.cs
using Sandbox;
using System;
public sealed class HealthComponent : Component
{
[RequireComponent] private Player Player { get; set; }
[Property] public float MaxHealth { get; set; } = 100f;
/// <summary>
/// Amount of health regenerated per second
/// </summary>
[Property] public float RegenPerSec { get; set; } = 20f;
/// <summary>
/// Seconds without taking damage for health regen to start
/// </summary>
[Property] public float RegenTime { get; set; } = 5f;
// ---- THE FIX: Use a Change Hook for network synchronization ----
[Sync, Change( nameof( OnHealthChanged ) )]
public float CurrentHealth { get; private set; }
public bool IsDead => CurrentHealth <= 0f;
public Action OnDeath { get; set; }
public bool IsRegening { get; set; }
private float _respawnTime = 10f;
private bool _deathHandled;
private bool _corpseDestroyPending;
private float _corpseDestroyAt;
private Guid _lastAttackerId;
private float _lastDamagedTime;
private Vector3 _lastDamageImpulse;
private string _lastWeaponIconPath = "";
protected override void OnStart()
{
CurrentHealth = MaxHealth;
}
/// <summary>
/// Applies damage and remembers the impact impulse so a lethal hit can transfer
/// its momentum to the spawned ragdoll.
/// </summary>
[Rpc.Broadcast]
public void TakeDamage( float amount, Vector3 impulse, bool headshot, string weaponIconPath = "" )
{
if ( IsDead || DeathmatchGame.Instance.State != MatchState.IN_PROGRESS ) return;
_lastAttackerId = Rpc.Caller.Id;
_lastDamagedTime = Time.Now;
_lastDamageImpulse = impulse;
_lastWeaponIconPath = weaponIconPath ?? "";
// The player owner is the single authority for their health. Having both
// owner and host process this can produce duplicate/out-of-order deaths.
if ( !Network.IsOwner )
return;
var attackerId = Rpc.Caller.Id;
var newHealth = MathF.Max( 0f, CurrentHealth - amount );
CurrentHealth = newHealth;
if ( newHealth <= 0f )
ConfirmDeath( attackerId, impulse, headshot, _lastWeaponIconPath );
}
/// <summary>
/// Health replication updates UI/state; death effects are driven by ConfirmDeath
/// so the lethal impulse and attacker identity arrive atomically.
/// </summary>
private void OnHealthChanged( float oldVal, float newVal )
{
// Intentionally empty. See ConfirmDeath.
}
[Rpc.Broadcast]
private void ConfirmDeath( Guid attackerId, Vector3 impulse, bool headshot, string weaponIconPath = "" )
{
_lastAttackerId = attackerId;
_lastDamageImpulse = impulse;
_lastWeaponIconPath = weaponIconPath ?? "";
HandleDeathShared( headshot );
}
private void HandleDeathShared(bool headshot)
{
if ( _deathHandled )
return;
_deathHandled = true;
OnDeath?.Invoke();
Tags.Add( "dead" );
var isSuicide = _lastAttackerId == Network.OwnerId;
KillFeed.Add( _lastAttackerId, Network.OwnerId, headshot, isSuicide, _lastWeaponIconPath );
if ( Network.IsOwner )
{
Player.Inventory?.DropAllItems();
PlayerSession.CurrentSession.LastKiller = _lastAttackerId;
DeathmatchGame.Instance.HandleDeath( isSuicide );
// Create one authoritative ragdoll on the dead player's owning client.
// Its ModelPhysics transforms are then replicated to everyone instead
// of each client running a divergent local ragdoll simulation.
var ragdoll = Player.CreateRagdoll();
var impulseApplier = ragdoll.AddComponent<RagdollImpulseApplier>();
impulseApplier.Impulse = _lastDamageImpulse;
var temporaryEffect = ragdoll.AddComponent<TemporaryEffect>();
temporaryEffect.DestroyAfterSeconds = _respawnTime;
temporaryEffect.WaitForChildEffects = false;
ragdoll.NetworkSpawn();
}
if ( Connection.Local.Id == _lastAttackerId && IsProxy )
{
DeathmatchGame.Instance.HandleKill(headshot);
if ( Network.OwnerId == PlayerSession.CurrentSession.LastKiller )
{
Sandbox.Services.Achievements.Unlock( $"revenge" );
}
}
Player.HideBody( showShadows: false );
if ( Networking.IsHost )
{
_corpseDestroyPending = true;
_corpseDestroyAt = Time.Now + _respawnTime;
}
}
protected override void OnUpdate()
{
HandleHealthRegen();
HandleDestroyCorpse();
}
private void HandleHealthRegen()
{
if ( IsProxy || IsDead )
return;
IsRegening = false;
if ( CurrentHealth >= MaxHealth )
return;
// Wait until we've gone RegenTime seconds without taking damage.
if ( Time.Now - _lastDamagedTime < RegenTime )
return;
IsRegening = true;
CurrentHealth = Math.Min(
MaxHealth,
CurrentHealth + RegenPerSec * Time.Delta
);
}
private void HandleDestroyCorpse()
{
if ( !_corpseDestroyPending || Time.Now < _corpseDestroyAt )
return;
_corpseDestroyPending = false;
if ( !Networking.IsHost || !GameObject.IsValid() )
return;
var owner = Network.Owner;
NetworkManager.Instance.RespawnPlayer( owner );
}
}