Death/Heartbeat.cs

Static helper for the downed-player heartbeat. Plays the heartbeat loop on a 5.28 s cycle, plays the flatline sound in the last 6 seconds, and exposes a 0-1 Pulse value the HUD reads to flash the screen on each beat.

namespace HorizonRP;

/// <summary>
/// Chez l'inconscient seulement : son cœur. Le fichier (sounds/mort/heartbeat.mp3, 4,9 s) porte
/// quatre battements, un toutes les 1,3 s, chacun fait d'un « boum » puis d'un « ba » 0,3 s plus
/// tard (mesurés sur l'enveloppe du son). Bouclé tel quel, le battement suivant revenait trop tôt :
/// on le relance donc soi-même toutes les 5,28 s, et le rythme reste régulier.
///
/// L'écran pulse exactement sur ces instants (<see cref="Pulse"/>, lu par le HUD) : un éclat sur le
/// « boum », un plus faible sur le « ba ». Dans les dernières secondes, le cœur s'arrête : le son
/// du tracé plat (heart_flatline.mp3, 5,9 s) et plus de pulsation.
/// </summary>
public static class Heartbeat
{
	const string BeatFile = "sounds/mort/heartbeat.mp3";
	const string FlatlineFile = "sounds/mort/heart_flatline.mp3";
	const float Cycle = 5.28f;
	const float FlatlineSeconds = 6f;

	/// <summary>Les « boum » dans le fichier ; chaque « ba » suit de <see cref="Dub"/>.</summary>
	static readonly float[] Lubs = { 0.02f, 1.38f, 2.68f, 3.98f };
	const float Dub = 0.3f;

	/// <summary>L'éclat de l'écran, de 0 à 1.</summary>
	public static float Pulse { get; private set; }

	/// <summary>Le tracé plat est en cours : l'écran s'éteint doucement au lieu de pulser.</summary>
	public static bool Flatlining { get; private set; }

	static SoundHandle beat, flat;
	static RealTimeSince sinceCycle = 100;
	static bool active;

	/// <summary>Chaque image, chez le joueur local (RoleplayPlayer.UpdateDowned).</summary>
	public static void Update( RoleplayPlayer me )
	{
		if ( me is null || !me.Downed )
		{
			Stop();
			return;
		}

		if ( !active )
		{
			active = true;
			Flatlining = false;
			sinceCycle = 100;
		}

		// Les dernières secondes : le cœur lâche.
		if ( me.DownedLeft <= FlatlineSeconds )
		{
			if ( !Flatlining )
			{
				Flatlining = true;
				beat?.Stop( 0.05f );
				beat = null;
				flat = Sounds.PlayFile( FlatlineFile, 0.7f );
			}
			Pulse = 0;
			return;
		}

		if ( sinceCycle >= Cycle )
		{
			sinceCycle = 0;
			beat = Sounds.PlayFile( BeatFile, 0.8f );
		}

		// L'éclat : monte d'un coup sur chaque battement, retombe en 0,18 s.
		var t = (float)sinceCycle;
		var pulse = 0f;
		foreach ( var lub in Lubs )
		{
			pulse = MathF.Max( pulse, Flash( t - lub, 1f ) );
			pulse = MathF.Max( pulse, Flash( t - lub - Dub, 0.55f ) );
		}
		Pulse = pulse;
	}

	static float Flash( float since, float strength ) =>
		since < 0 || since > 0.6f ? 0 : strength * MathF.Exp( -since / 0.18f );

	static void Stop()
	{
		if ( !active )
			return;
		active = false;
		Flatlining = false;
		Pulse = 0;
		beat?.Stop( 0.2f );
		flat?.Stop( 0.3f );
		beat = flat = null;
	}
}