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