Player stamina component. Tracks a stamina pool that drains while sprinting on a fixed 0.05s tick, applies regen after a delay, blocks sprint by lowering RunSpeed to WalkSpeed when exhausted, accounts for per-player perk multipliers and tech modifiers, and exposes debug/refill methods.
using Sandbox;
using System;
namespace NZombies;
/// <summary>
/// Sprint stamina — a pool that drains while running and refills after a pause.
///
/// ⛔ ENTIRELY NEW. s&box has no stamina at all; sprinting is unlimited. So
/// unlike the zombie curves, there is nothing here to port — only the numbers,
/// which come from the original's map settings.
///
/// ⚠️ TICKS ON A FIXED 0.05s INTERVAL, applying an AMOUNT PER TICK rather than
/// a rate per second. That is how the original does it (sh_sprint.lua: "the rate
/// is fixed on 0.05 seconds"), and the configured numbers only mean what they
/// say under that model — 0.9 per tick is roughly 8 seconds of sprint, whereas
/// 0.9 per second would be nearly two minutes.
/// </summary>
public sealed class Stamina : Component
{
/// <summary>The original's fixed tick. Not a setting there, so not one here.</summary>
public const float TickInterval = 0.05f;
[Property, ReadOnly] public float Current { get; private set; }
/// <summary>Is the pool empty? While true, sprinting is blocked.</summary>
public bool Exhausted => Current <= 0.01f;
/// <summary>Give stamina back, up to the pool. Dynamo's shots (2026-10-04).</summary>
public void Add( float amount ) => Current = MathF.Min( PoolMax, Current + MathF.Max( 0f, amount ) );
/// <summary>
/// Give back a SHARE of this player's whole pool, perks included (`PoolMax`), up to the pool. Catch Breath's kills
/// (SMG tier 3, 2026-10-04): "5% of max stamina" means of the bar Stamin-Up has made, not of the configured base.
/// </summary>
public void AddShare( float share ) => Add( share * PoolMax );
public float Fraction => PoolMax <= 0f
? 1f : Current.Clamp( 0f, PoolMax ) / PoolMax;
static PlayerSettings Settings => ActiveConfig.Player;
/// <summary>This player's perk multipliers.
///
/// ⚠️ Read PER TICK from the owned list rather than cached. PlayerSettings is
/// SHARED between everyone, so the pool cannot be scaled in place — a factor
/// applied at the point of use is the only version that can differ per player
/// and cannot be left behind when a perk is lost.</summary>
NZPlayer Owner => Components.Get<NZPlayer>( FindMode.EverythingInSelf );
// ⚠️ FROM THE MATCH'S STAMINA, AND THE SPRINT CACHED BELOW FROM ITS SPRINT SPEED (the lobby's Difficulty, 2026-10-05)
float PoolMax => Difficulty.StaminaMax * PerkEffects.StaminaMaxMultiplier( Owner );
float RegenPerTick => Settings.StaminaRegenPerTick
* PerkEffects.StaminaRegenMultiplier( Owner );
PlayerController _controller;
TimeSince _sinceTick;
TimeSince _sinceSprint;
/// <summary>Cached so sprint can be restored to the configured value rather
/// than to whatever RunSpeed happened to be when we blocked it.</summary>
float _sprintSpeed;
/// <summary>
/// The RunSpeed this component last wrote, or -1. While RunSpeed still holds it, nobody else has touched the value since,
/// so it is still ours to move — down as well as up (`ApplySprintBlock`).
/// </summary>
float _wrote = -1f;
/// <summary>
/// Sprint speed with the perk applied.
///
/// ⛔ THE CACHED `_sprintSpeed` IS WHY STAMIN-UP DID NOTHING TO RUNNING. It is
/// read once at spawn, so a perk bought later never reached it — and NZPlayer
/// deliberately does not touch RunSpeed because, as its own comment says,
/// Stamina owns that value. Walk got faster, run did not, which is exactly
/// what was reported.
///
/// ⚠️ Derived per use rather than re-cached on purchase, for the same reason
/// the rest of these effects are: nothing to set, nothing to unset.
///
/// ⚠️ THE WEAPON TECH TERM IS DELIBERATELY NOT IN HERE. ApplySprintBlock has to
/// branch on whether that factor is a reduction, so it needs the factor and the
/// unpenalised speed separately; folding it in here would apply it twice.
/// </summary>
/// <remarks>
/// ⛔ M2 LIGHTWEIGHT IS APPLIED HERE AND NOWHERE ELSE, because it is SPRINT-ONLY.
/// `PerkEffects.SpeedMultiplier` is read by NZPlayer for walk as well, so putting a
/// sprint augment in there would silently make it a walk augment too — the mirror of
/// the mistake that method's own comment warns about, where a walk-and-sprint term
/// applied to only one of them.
/// </remarks>
float SprintSpeed => _sprintSpeed
* PerkEffects.SpeedMultiplier( Owner )
* StaminUpAugments.SprintMultiplier( Owner );
protected override void OnStart()
{
_controller = Components.Get<PlayerController>();
Current = PoolMax;
_sprintSpeed = Difficulty.SprintSpeed;
}
protected override void OnUpdate()
{
// ⛔ RE-RESOLVED, NOT TRUSTED FROM OnStart (2026-10-05). `_controller` was read once there, and `Components.Get` passes
// over a component that is not enabled, so a controller it missed left this returning here every frame: nothing
// drained, nothing blocked sprint — "unlimited stamina", which the user saw on clients together with no health regen.
// `HealthRegen` cached its Health the same way; both look again now, and say when they had to.
if ( !_controller.IsValid() )
{
_controller = Components.Get<PlayerController>( FindMode.EverythingInSelf );
if ( !_controller.IsValid() ) return;
Log.Warning( $"[nz-stamina] '{GameObject.Name}' had no PlayerController — found it; stamina drains and blocks sprint again" );
}
if ( _sinceTick < TickInterval ) return;
_sinceTick = 0f;
// Sprinting means holding Run AND actually moving — standing still with
// shift held should not drain the pool.
var moving = _controller.Velocity.WithZ( 0 ).Length > 5f;
var wantsSprint = Input.Down( "Run" ) && moving;
// ⛔ M1 MARATHON REFILLS RATHER THAN SKIPPING THE DRAIN, matching the original
// (`nzAug_staminup_Tick` tops the pool up every tick). Skipping the drain looks
// equivalent and is not: `_sinceSprint` would never be stamped, so the regen branch
// below would fire while sprinting and every OTHER consumer of `Fraction` — the HUD
// bar included — would show a full pool that had never moved. Refilling leaves the
// drain visible for one tick, which is what makes the bar twitch and read as alive.
if ( StaminUpAugments.InfiniteStamina( Owner ) )
Current = PoolMax;
if ( wantsSprint && !Exhausted )
{
// ⚠️ SKELETON STOCK DRAINS 15% FASTER, AND DYNAMO TURNS WHAT WAS SPENT INTO ROUNDS (2026-10-04): both are the gun
// in hand (`ClassTech`).
var before = Current;
Current = MathF.Max( 0f, Current - Settings.StaminaDrainPerTick * ClassTech.StaminaDrainScale( Owner ) );
ClassTech.OnStaminaSpent( Owner, before - Current );
_sinceSprint = 0f;
}
else if ( _sinceSprint >= Settings.StaminaRegenDelay )
{
Current = MathF.Min( PoolMax, Current + RegenPerTick );
}
ApplySprintBlock();
}
/// <summary>
/// Block sprinting by dropping run speed to walk speed.
///
/// The original does exactly this rather than tracking a separate "can
/// sprint" flag (sh_sprint.lua:18, `SetRunSpeed(GetWalkSpeed())`). It is
/// worth copying: with one number driving it there is no way for the flag
/// and the speed to disagree, which is the usual bug in stamina systems.
/// </summary>
void ApplySprintBlock()
{
// ⚠️ NOTHING TO DO FOR EMPLACEMENT HERE. WalkSpeed is already carrying the
// node's -50% — NZPlayer.TickAdsSpeed rewrites it from the config every frame
// through the same TechMoveMultiplier — so an exhausted player with the gun in
// hand inherits the penalty with no extra code.
if ( Exhausted )
{
_controller.RunSpeed = _controller.WalkSpeed;
_wrote = _controller.RunSpeed;
return;
}
// ⛔ THE WEAPON IN HAND CAN SLOW SPRINTING, AND IT IS READ HERE RATHER THAN
// WRITTEN FROM NZPlayer. RunSpeed is this component's value — TickAdsSpeed says
// so in its own comment — and it is stamped on the 0.05s tick below. A per-frame
// write from there would fight this tick with no guaranteed component order, and
// the player would read a stuttering sprint as a physics bug.
//
// ⛔ HOLSTERING RELEASES IT WITH NO RESTORE PATH. TechMoveMultiplier resolves the
// ENABLED weapon on the player and returns 1 when there is none — a holstered
// weapon is a disabled component — so putting the gun away makes `tech` 1 and the
// raise branch below hands full sprint back on the next tick. Same reason Drum
// Magazine's walk penalty is read-time: the penalty must not outlive the weapon.
//
// ⚠️ ONE resolve per tick, not per frame, and both branches below need it.
var tech = Owner.IsValid() ? Owner.TechMoveMultiplier( sprint: true ) : 1f;
var sprint = SprintSpeed * tech;
// Restore only if we were the ones who lowered it, so this does not
// stamp over a perk or a temporary speed effect.
if ( _controller.RunSpeed < sprint )
{
_controller.RunSpeed = sprint;
_wrote = sprint;
return;
}
// ⛔ THE BRANCH ABOVE ONLY EVER RAISES, WHICH IS WHY A PENALTY NEEDS ITS OWN
// WRITE. RunSpeed starts at the unmodified SprintSpeed (NZPlayer.ApplyConfig), so
// a factor below 1 fails that `<` test and lands nowhere: Emplacement would be
// arithmetically perfect and completely invisible.
//
// ⛔ AND IT USED TO ASK `tech < 1f`, WHICH MADE IT A FIX FOR ONE PENALTY RATHER THAN FOR
// PENALTIES. The Shrieker's sonic daze multiplies into `PerkEffects.SpeedMultiplier`, so it
// is inside `SprintSpeed` and not inside `tech` — it therefore failed the raise test AND
// this gate, and a dazed player kept full sprint while their walk was correctly cut to 35%.
// Measured: `walk 200 -> 70` while `run 340` never moved. The right question is not "which
// system asked for this" but "is the net below the unpenalised speed", which is what the
// second clause asks and which subsumes the first.
//
// ⚠️ `tech < 1f` IS KEPT ALONGSIDE IT rather than replaced, because Stamin-Up can push the
// net back above `_sprintSpeed` while a weapon node is still meant to be slowing you — and
// dropping the original clause would quietly hand that case full sprint.
//
// ⛔ AND A BOOST THAT ENDS HAD NO WAY DOWN AT ALL (review, 2026-10-04). A factor above 1 that
// goes away — Light Furniture, Runner's Grip or Carry Handle holstered, `tech` back to 1 —
// fails the raise test and both clauses above, so the boosted RunSpeed stayed on the pistol
// and every other gun until the pool emptied, and for ever with Marathon. `RunSpeed ==
// _wrote` asks "has anyone else touched it since our last write": if not, it is still ours
// to move either way, and a perk's or effect's own value is still never stamped over.
if ( tech < 1f || sprint < _sprintSpeed || _controller.RunSpeed == _wrote )
{
_controller.RunSpeed = sprint;
_wrote = sprint;
}
}
/// <summary>Set the pool directly. Test harness only — nz_stamina uses it to
/// reach exhaustion without anyone having to sprint for six seconds.</summary>
public void Debug_Set( float value )
{
Current = value.Clamp( 0f, Difficulty.StaminaMax );
ApplySprintBlock();
}
/// <summary>Refill instantly — for respawns, and for a perk later.</summary>
public void Refill()
{
Current = PoolMax;
_sprintSpeed = Difficulty.SprintSpeed;
}
}