Death/Pain.cs

Static helper that reacts to a player's synced Health and Downed changes. Plays pain, death and revive sound files at the player's position, and for the local player drives the screen blood overlay and black and white downed effect. Skips work on dedicated servers.

namespace HorizonRP;

/// <summary>
/// Ce qui se voit et s'entend quand un joueur est blessé (Julien, 31/10/2026) :
/// <list type="bullet">
/// <item>sa santé baisse d'un coup : un cri de douleur à sa position (sounds/degats, au hasard) et,
/// chez lui seul, du sang à l'écran (ui/sang_ecran.png, HUD) qui s'efface ;</item>
/// <item>il tombe inconscient : un cri (sounds/mort/deces_*, au hasard) et, chez lui, l'écran passe
/// en noir et blanc (DayNight, <see cref="Grey"/>) tant qu'il est à terre ;</item>
/// <item>il est réanimé : un des sons de réanimation (sounds/mort/reanimation_*, au hasard), là où
/// était le corps (<see cref="Revived"/>).</item>
/// </list>
/// Blessure et chute, chaque machine les déduit des valeurs déjà synchronisées de chaque joueur
/// (Health, Downed) : rien de plus ne passe sur le réseau. La réanimation, elle, est annoncée par
/// l'hôte (HorizonGame.Revive) : vue d'un client, elle ne se distingue pas d'une mort (même fondu,
/// même déplacement). Le serveur dédié, sans carte son, ne joue rien.
/// </summary>
public static class Pain
{
	public const int DeathSounds = 12;
	public const int PainSounds = 24;
	/// <summary>Les sons de réanimation (sounds/mort/reanimation_N.wav, dossier « done » de Julien).</summary>
	public const int ReviveSounds = 3;

	/// <summary>Une baisse plus faible n'est pas une blessure (la faim retire un peu chaque seconde).</summary>
	const float MinHurt = 2f;
	/// <summary>Deux cris de douleur au plus par seconde (une rafale touche plusieurs fois).</summary>
	const float PainGap = 0.45f;
	const float BloodSeconds = 1.4f;
	const float GreyFade = 1.2f;

	sealed class State
	{
		public float Health = -1;
		public bool Downed;
		public RealTimeSince SincePain = 10;
	}

	static readonly Dictionary<RoleplayPlayer, State> states = new();
	static RealTimeSince sinceBlood = 10;
	static float bloodStrength;
	static RealTimeSince sinceDownedChange = 10;
	static bool localDowned;

	/// <summary>Le sang à l'écran du joueur local (0 à 1), pour le HUD.</summary>
	public static float Blood => sinceBlood >= BloodSeconds ? 0 : bloodStrength * (1 - sinceBlood / BloodSeconds);

	/// <summary>Le gris de l'écran du joueur local (0 : couleurs, 1 : noir et blanc).</summary>
	public static float Grey
	{
		get
		{
			var k = Math.Clamp( sinceDownedChange / GreyFade, 0f, 1f );
			return localDowned ? k : 1 - k;
		}
	}

	/// <summary>Chaque image, sur toutes les machines, pour chaque joueur (RoleplayPlayer.OnUpdate).</summary>
	public static void Update( RoleplayPlayer player )
	{
		if ( Application.IsDedicatedServer )
			return;
		if ( !states.TryGetValue( player, out var s ) )
		{
			states[player] = new State { Health = player.Health, Downed = player.Downed };
			if ( states.Count > 256 )
				foreach ( var gone in states.Keys.Where( x => !x.IsValid() ).ToList() )
					states.Remove( gone );
			return;
		}

		var local = player == RoleplayPlayer.Local;
		if ( player.Downed != s.Downed )
		{
			s.Downed = player.Downed;
			if ( local )
			{
				localDowned = player.Downed;
				sinceDownedChange = 0;
			}
			if ( player.Downed )
				Play( $"sounds/mort/deces_{Random.Shared.Int( 0, DeathSounds - 1 )}.wav", player.WorldPosition, 1f );
		}

		var lost = s.Health - player.Health;
		s.Health = player.Health;
		// Tombé (Health à 0 arrive parfois avant Downed) : c'est le cri de la chute, pas la douleur.
		if ( lost < MinHurt || player.Downed || player.Health <= 0 )
			return;
		if ( s.SincePain >= PainGap )
		{
			s.SincePain = 0;
			Play( $"sounds/degats/douleur_{Random.Shared.Int( 0, PainSounds - 1 )}.wav", player.WorldPosition, 0.9f );
		}
		if ( local )
		{
			// Plus la blessure est forte, plus le sang couvre l'écran ; une nouvelle blessure le ravive.
			bloodStrength = Math.Clamp( MathF.Max( Blood, 0.35f + lost / 40f ), 0f, 1f );
			sinceBlood = 0;
		}
	}

	/// <summary>Sur toutes les machines, à l'annonce de l'hôte : un son de réanimation près du corps.</summary>
	public static void Revived( Vector3 body, int sound ) =>
		Play( $"sounds/mort/reanimation_{Math.Clamp( sound, 0, ReviveSounds - 1 )}.wav", body, 0.9f );

	static void Play( string path, Vector3 position, float volume )
	{
		if ( !Application.IsDedicatedServer )
			Sounds.PlayFileAt( path, position + Vector3.Up * 60, volume );
	}
}