Character/Stamina.cs

Static client-side stamina system for a roleplay game. Drains stamina while sprinting and jumping, recovers it after a delay, and drives a Blur and Vignette on the local camera plus breathing/exhaustion sounds as stamina gets low.

namespace HorizonRP;

/// <summary>
/// L'endurance (Julien, 29/10/2026) : la course la vide, elle remonte seule quand on ne court
/// plus. Vers la fin, la vue se trouble et le souffle s'entend ; à bout, tout est flou, on ne
/// peut plus que marcher, et il faut reprendre son souffle (un tiers) avant de recourir.
///
/// Chez le joueur seulement : chacun simule son propre corps (la vitesse de course est posée
/// par le propriétaire, RoleplayPlayer.RunSpeed), rien ne passe par le réseau. Mise à jour par
/// le HUD à chaque image, comme DayNight. Le flou et la vignette vivent sur la caméra du jeu.
/// Sons : sounds/endurance/ (pack fourni par Julien, converti en mp3).
/// </summary>
public static class Stamina
{
	// Réglables à chaud (statiques).

	/// <summary>Secondes de course pour vider une endurance pleine.</summary>
	public static float SprintSeconds { get; set; } = 14f;

	/// <summary>Secondes pour remonter de zéro au plein, à l'arrêt (en marchant : moitié moins vite).</summary>
	public static float RecoverSeconds { get; set; } = 10f;

	/// <summary>Attente après la course avant que l'endurance remonte.</summary>
	public static float RecoverDelay { get; set; } = 1.2f;

	/// <summary>Ce que coûte un saut.</summary>
	public static float JumpCost { get; set; } = 0.06f;

	/// <summary>À bout : la course revient à partir de ce niveau.</summary>
	public static float RecoverThreshold { get; set; } = 0.35f;

	/// <summary>Le trouble de la vue commence sous ce niveau.</summary>
	public static float BlurFrom { get; set; } = 0.3f;

	/// <summary>Le souffle s'entend sous ce niveau.</summary>
	public static float BreathFrom { get; set; } = 0.4f;

	/// <summary>
	/// Flou à bout de souffle (0 à 1, Blur.Size). À 1 on ne voyait plus rien (Julien, 29/10/2026) :
	/// il faut que la vue se trouble, pas qu'elle disparaisse.
	/// </summary>
	public static float MaxBlur { get; set; } = 0.35f;

	/// <summary>L'endurance, de 0 (à bout) à 1.</summary>
	public static float Value { get; private set; } = 1f;

	/// <summary>À bout : plus de course jusqu'à <see cref="RecoverThreshold"/>.</summary>
	public static bool Exhausted { get; private set; }

	static RealTimeSince sinceSprint;
	static RealTimeSince sinceBreath;
	static float nextBreath;
	static bool wasOnGround = true;
	static float shownBlur;
	static GameObject onCamera;
	static Blur blur;
	static Vignette vignette;

	public static void Update( Scene scene )
	{
		if ( Application.IsDedicatedServer || scene?.Camera is not { } camera )
			return;

		var me = RoleplayPlayer.Local;
		var controller = me?.GetComponent<PlayerController>();
		if ( controller.IsValid() && !me.NeedsCharacter )
			Simulate( me, controller );

		UpdateView( camera );
		UpdateBreath( me );
	}

	/// <summary>Remet l'endurance au plein (réapparition, réanimation).</summary>
	public static void Refill()
	{
		Value = 1f;
		Exhausted = false;
	}

	static void Simulate( RoleplayPlayer me, PlayerController controller )
	{
		// Ni en voiture, ni à terre, ni en noclip : l'endurance ne fait que remonter.
		var free = !me.Downed && !me.Knocked && !StaffTool.Noclip && me.GetComponentInParent<BaseChair>() is null;
		var speed = controller.Velocity.WithZ( 0 ).Length;
		var sprinting = free && !Exhausted && Input.Down( "Run" ) && controller.IsOnGround
			&& speed > controller.WalkSpeed + 10f;

		if ( sprinting )
		{
			Value -= Time.Delta / SprintSeconds;
			sinceSprint = 0;
		}

		// Un saut : le pied quitte le sol en montant.
		var onGround = controller.IsOnGround;
		if ( free && wasOnGround && !onGround && controller.Velocity.z > 50f )
		{
			Value -= JumpCost;
			sinceSprint = 0;
		}
		wasOnGround = onGround;

		if ( !sprinting && sinceSprint > RecoverDelay )
		{
			var rate = speed > 20f ? 0.5f : 1f;
			Value += Time.Delta / RecoverSeconds * rate;
		}

		Value = Math.Clamp( Value, 0f, 1f );

		if ( !Exhausted && Value <= 0f )
		{
			Exhausted = true;
			Sounds.PlayFile( $"sounds/endurance/exhausted_{Game.Random.Int( 0, 2 )}.mp3", 0.7f );
			sinceBreath = 0;
			nextBreath = 5.5f;
		}
		else if ( Exhausted && Value >= RecoverThreshold )
		{
			Exhausted = false;
		}
	}

	/// <summary>Le flou voulu : nul au-dessus de <see cref="BlurFrom"/>, la moitié à la fin, tout à bout.</summary>
	static float TargetBlur
	{
		get
		{
			if ( Exhausted )
				// À bout : flou complet, qui se dissipe à mesure qu'on reprend son souffle.
				return MathX.Lerp( MaxBlur, MaxBlur * 0.45f, Value / RecoverThreshold );
			if ( Value >= BlurFrom )
				return 0f;
			return MaxBlur * 0.5f * (1f - Value / BlurFrom);
		}
	}

	static void UpdateView( CameraComponent camera )
	{
		if ( onCamera != camera.GameObject || !blur.IsValid() || !vignette.IsValid() )
		{
			onCamera = camera.GameObject;
			blur = onCamera.Components.GetOrCreate<Blur>();
			vignette = onCamera.Components.GetOrCreate<Vignette>();
			vignette.Color = Color.Black;
			vignette.Roundness = 1f;
			vignette.Smoothness = 1f;
		}

		// Adouci : le trouble vient et part en une demi-seconde environ.
		shownBlur = MathX.Lerp( shownBlur, TargetBlur, 1f - MathF.Exp( -Time.Delta * 5f ) );
		var on = shownBlur > 0.005f;
		if ( blur.Enabled != on )
			blur.Enabled = on;
		if ( vignette.Enabled != on )
			vignette.Enabled = on;
		if ( on )
		{
			blur.Size = shownBlur;
			vignette.Intensity = shownBlur * 0.55f;
		}
	}

	/// <summary>Le souffle : de plus en plus rapproché et fort à mesure que l'endurance baisse.</summary>
	static void UpdateBreath( RoleplayPlayer me )
	{
		var low = Exhausted || Value < BreathFrom;
		if ( me is null || !low || sinceBreath < nextBreath )
			return;

		var effort = Exhausted ? 1f : 1f - Value / BreathFrom;
		Sounds.PlayFile( $"sounds/endurance/breath_{Game.Random.Int( 0, 5 )}.mp3", 0.25f + 0.4f * effort );
		sinceBreath = 0;
		// Un souffle dure 1,5 s : de 2,6 s d'écart au début à 1,6 s à bout.
		nextBreath = MathX.Lerp( 2.6f, 1.6f, effort ) + Game.Random.Float( -0.15f, 0.15f );
	}
}