UI/DamageNumbers.cs

UI helper that manages floating damage number entries shown over damaged entities. It batches pellets from the same shot by victim+frame stamp, formats compact numeric text, stores active numbers, prunes expired ones, and exposes console commands for toggling, testing and tuning.

File Access
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace NZombies;

/// <summary>
/// Floating damage numbers over whatever you just shot.
///
/// Ported from `ttt_combattext_nzpatch` — TTT Combat Text with the nZombies
/// patch already applied, which is what the GMod build actually plays with. Its
/// tuning is NOT the addon's defaults: the patch cut lifetime 1.5s -> 0.4s and
/// rise 32 -> 8 units, which is why hits there read as a quick tick rather than
/// a drifting cloud. Both are reproduced below.
///
/// ⛔ THE PELLET-BATCHING RULE IS THE WHOLE REASON THIS IS NOT TRIVIAL. A KS-23
/// fires SIXTEEN pellets and each one calls Health.Apply separately. Sixteen
/// stacked numbers is unreadable, and summing "hits on this victim in the last
/// N milliseconds" would also merge two consecutive shots into one.
///
/// The addon's answer, kept here: all pellets of ONE shot resolve within ONE
/// rendered frame, and the next shot is necessarily a later frame. So group by
/// FRAME — exact, and framerate-independent in a way a time window is not.
/// </summary>
public static class DamageNumbers
{
	/// <summary>Master switch. `nz_dmgnum 0` turns it off.</summary>
	public static bool Enabled { get; set; } = true;

	/// <summary>Print every hit as it is reported. `nz_dmgnum_log 1`.
	///
	/// ⚠️ THE ONE THING THAT SEPARATES THE TWO EXPLANATIONS. "The number is stuck" is
	/// either shots MERGING into one entry or an ELEMENT not being refreshed, and they
	/// look identical on screen. A run of MERGE lines is the batching; a run of NEW
	/// lines with a stuck display is the element.</summary>
	public static bool LogHits { get; set; }

	/// <summary>Seconds a number stays up. The nZombies patch uses 0.4.</summary>
	public static float Life { get; set; } = 0.4f;

	/// <summary>How far it drifts up over its life, in units. Patch uses 8.</summary>
	public static float Rise { get; set; } = 8f;

	/// <summary>
	/// Extra height added with distance, so a far-off number clears the body
	/// instead of sitting on it. The addon ramps 0 -> 16 units over 0 -> 256.
	/// </summary>
	public static float DistanceLift { get; set; } = 16f;
	public static float DistanceLiftRange { get; set; } = 256f;

	/// <summary>
	/// ⚠️ A CAP, not a pool. Past this the OLDEST are dropped — a horde round
	/// with a belt-fed LMG can generate numbers faster than they expire, and an
	/// uncapped list would grow until the projection cost showed up as frame
	/// time.
	/// </summary>
	public static int MaxVisible { get; set; } = 40;

	public sealed class Num
	{
		/// <summary>Where it was born, world space. The rise is added at draw time.</summary>
		public Vector3 World;
		public float Amount;
		public bool Headshot;
		public RealTimeSince Age;

		/// <summary>Batching key — the victim alone. One number per zombie at a time.</summary>
		public Health Victim;

		/// <summary>Batching key — the frame this number was born in, as `Time.Now`.
		///
		/// ⚠️ A float compared with `==` on purpose; see the note in `Report`.</summary>
		public float Stamp;

		public string Text => $"-{Compact( Amount )}";
	}

	/// <summary>
	/// A hit's number, compressed where it gets long — *"having a bunch of 125000 on screen is too much, but if we make it 125K
	/// it becomes readable"* (2026-09-27):
	/// - under 10,000, whole, as it always was: 9876;
	/// - from 10,000, thousands with one decimal: 10.3K;
	/// - from 100,000, thousands with none: 125K;
	/// - from a million, millions with two: 1.23M — and from a billion, billions with two: 1.23B.
	///
	/// ⚠️ ROUNDED FIRST, THEN PLACED, so a hit that rounds up across a step takes the next one: 99,960 is 100K, not 100.0K, and
	/// 999,600 is 1.00M, not 1000K. ⚠️ A POINT WHATEVER THE MACHINE'S CULTURE: "10.3K", never "10,3K". `nz_dmgnum_compact`
	/// prints the steps.
	/// </summary>
	public static string Compact( float amount )
	{
		var n = System.Math.Round( (double)amount, System.MidpointRounding.AwayFromZero );
		var inv = System.Globalization.CultureInfo.InvariantCulture;

		if ( n < 10_000d ) return n.ToString( "0", inv );
		if ( n < 99_950d ) return (n / 1_000d).ToString( "0.0", inv ) + "K";
		if ( n < 999_500d ) return (n / 1_000d).ToString( "0", inv ) + "K";
		if ( n < 999_995_000d ) return (n / 1_000_000d).ToString( "0.00", inv ) + "M";
		return (n / 1_000_000_000d).ToString( "0.00", inv ) + "B";
	}

	public static readonly List<Num> Active = new();

	/// <summary>
	/// Bumped once per rendered frame by the HUD.
	///
	/// ⚠️ Our own counter rather than an engine frame index, because the guarantee
	/// we need is "increments once per rendered frame" and this provides it
	/// directly. Damage resolves during the same frame's update regardless of
	/// whether the weapon or the HUD ticks first, so every pellet of a shot reads
	/// the same value either way.
	/// </summary>
	public static int Frame { get; private set; }

	/// <summary>
	/// ⚠️ NOTHING READS `Frame` ANY MORE. It existed so Report could tell "same shot" from "next
	/// shot"; the merge now keys on the victim alone, for as long as the number is alive. Kept
	/// because it costs one increment and removing it means editing the HUD's documented
	/// call-order comment for no behavioural gain — delete both together if you are in there.
	/// </summary>
	public static void Tick() => Frame++;

	/// <summary>
	/// Called from Health.Apply for every damage event in the scene.
	///
	/// ⚠️ Filters out the PLAYER's own health here rather than at the call site.
	/// Health.Apply is shared by zombies and the player, and "damage numbers"
	/// means damage you DEAL — a number over your own face on every zombie swipe
	/// is a different feature nobody asked for.
	/// </summary>
	public static void Report( Health victim, float amount, bool headshot )
	{
		// ⚠️ THE HEALTH BARS HEAR EVERY HIT OF MINE HERE TOO (2026-10-06, `ZombieHealthBars.NoteHit`): this is called for exactly
		// those, on both branches. Before the numbers' own switch, so `nz_dmgnum 0` does not take the bars with it.
		ZombieHealthBars.NoteHit( victim, headshot );

		if ( !Enabled || !victim.IsValid() || amount <= 0f ) return;
		if ( victim.Components.Get<NZPlayer>() is not null ) return;

		// ⛔ ONE NUMBER PER SHOT AGAIN, NOT ONE PER ZOMBIE. Keying on the victim ALONE made a
		// single number accumulate for as long as fire kept landing, and that is exactly what
		// "the hit numbers never refresh while I hold ADS" was: `Age = 0f` on every hit meant the
		// number never expired while you kept shooting, and `Headshot |= headshot` meant the FIRST
		// head hit coloured every later body shot for the rest of the burst.
		//
		// ⚠️ THE CHURN THAT MOTIVATED VICTIM-ONLY KEYING IS ALREADY HANDLED. `DamageNumbersHud`
		// POOLS its elements — it reuses a spare panel and rewrites `Text`/`SetClass` instead of
		// deleting and constructing — so a fresh number per shot costs a text write, not an
		// allocation. That pooling is why this can go back to per-shot without the cost the
		// previous comment was avoiding.
		//
		// ⚠️ THE KEY IS `Time.Now`, NOT A COUNTER THE HUD BUMPS. It is constant for the duration
		// of a frame and needs nobody to tick it, so the grouping cannot be broken by a UI
		// component failing to update. Compared with `==` on a float deliberately: two reads in
		// the same frame return the same bits and any later frame returns different ones — no
		// tolerance is wanted. Shooting is `OnUpdate`-driven and a weapon would need >3600 RPM to
		// fire twice in one 60Hz frame, so nothing legitimate merges by accident.
		//
		// ⚠️ PELLET BATCHING IS UNCHANGED, which was the original key's whole purpose: every
		// pellet of one shotgun blast resolves inside one frame, so they still sum into one number
		// and a single head pellet still colours it.
		var now = Time.Now;
		var existing = Active.FirstOrDefault( n => n.Victim == victim && n.Stamp == now );
		if ( existing is not null )
		{
			existing.Amount += amount;

			// ⛔ THE LIFE RESTARTS, WHICH IS WHAT MAKES THIS READ AS ONE RUNNING TOTAL. Without
			// it the number would keep fading on its original schedule and vanish mid-burst while
			// damage was still landing -- and the next hit would start a fresh number anyway,
			// putting the churn straight back.
			existing.Age = 0f;

			// ⚠️ RE-ANCHORED TO THE NEWEST HIT. Place() draws at `World`, a fixed point, not at the
			// zombie's live position. That was fine when a number lived one shot; now that continued
			// fire can keep it alive indefinitely, leaving World at the first hit would strand the
			// total in the air where the zombie used to be.
			existing.World = victim.HitPositionOr();
			// ⚠️ A headshot anywhere in the volley colours the whole number. A
			// shotgun that puts one pellet in the head genuinely did land a
			// headshot, and showing the summed total in white would hide it.
			existing.Headshot |= headshot;

			if ( LogHits )
				Log.Info( $"[dmgnum] MERGE  {victim.GameObject.Name}  +{amount:0.#}"
					+ $" -> {existing.Amount:0.#}  hs {headshot}->{existing.Headshot}"
					+ $"  stamp {now:0.0000}" );
			return;
		}

		Active.Add( new Num
		{
			World = victim.HitPositionOr(),
			Amount = amount,
			Headshot = headshot,
			Victim = victim,
			Stamp = now,
			Age = 0f,
		} );

		if ( LogHits )
			Log.Info( $"[dmgnum] NEW    {victim.GameObject.Name}  {amount:0.#}"
				+ $"  hs {headshot}  stamp {now:0.0000}  live {Active.Count}" );

		// Drop oldest first — the newest hit is the one being read.
		while ( Active.Count > MaxVisible )
			Active.RemoveAt( 0 );
	}

	/// <summary>Drop everything past its life. Called by the HUD each frame.</summary>
	public static void Prune() => Active.RemoveAll( n => n.Age > Life );

	public static void Clear() => Active.Clear();

	// ── console ─────────────────────────────────────────────────────────────

	/// <summary>`nz_dmgnum` toggles, `nz_dmgnum 1` / `0` sets.</summary>
	[ConCmd( "nz_dmgnum" )]
	public static void CmdEnable( int state = -1 )
	{
		Enabled = state < 0 ? !Enabled : state > 0;
		if ( !Enabled ) Clear();
		Log.Info( $"[dmgnum] {(Enabled ? "on" : "off")}" );
	}

	/// <summary>`nz_dmgnum_log` toggles the per-hit log.</summary>
	[ConCmd( "nz_dmgnum_log" )]
	public static void CmdLog( int state = -1 )
	{
		LogHits = state < 0 ? !LogHits : state > 0;
		Log.Info( $"[dmgnum] per-hit log {(LogHits ? "ON — NEW = a fresh number, MERGE = summed into the one already up" : "off")}" );
	}

	/// <summary>`nz_dmgnum_state` — what is live, and what it is keyed on.
	///
	/// ⚠️ PRINTS EACH STAMP NEXT TO `Time.Now`. Two entries sharing a stamp is one shot.
	/// A stamp that does not change across repeated runs WHILE FIRING is the frame key
	/// not advancing — the bug this replaced.</summary>
	[ConCmd( "nz_dmgnum_state" )]
	public static void CmdState()
	{
		Log.Info( $"[dmgnum] {(Enabled ? "on" : "OFF")} · {Active.Count}/{MaxVisible} live"
			+ $" · life {Life:0.00}s · now {Time.Now:0.0000}" );

		foreach ( var n in Active )
			Log.Info( $"[dmgnum]   -{n.Amount:0}{(n.Headshot ? " HS" : "")}"
				+ $"  age {(float)n.Age:0.00}s  stamp {n.Stamp:0.0000}"
				+ $"  on '{(n.Victim.IsValid() ? n.Victim.GameObject.Name : "<gone>")}'" );
	}

	/// <summary>`nz_dmgnum_life 0.4` — seconds on screen.</summary>
	[ConCmd( "nz_dmgnum_life" )]
	public static void CmdLife( float seconds )
	{
		Life = seconds.Clamp( 0.05f, 5f );
		Log.Info( $"[dmgnum] life {Life:0.00}s" );
	}

	/// <summary>`nz_dmgnum_rise 8` — units drifted over that life.</summary>
	[ConCmd( "nz_dmgnum_rise" )]
	public static void CmdRise( float units )
	{
		Rise = units.Clamp( 0f, 200f );
		Log.Info( $"[dmgnum] rise {Rise:0.#} units" );
	}

	/// <summary>
	/// `nz_dmgnum_test` — put a number on the nearest zombie without shooting.
	/// `nz_dmgnum_test 500 1` for a 500 headshot.
	/// </summary>
	[ConCmd( "nz_dmgnum_test" )]
	public static void CmdTest( float amount = 123f, int headshot = 0 )
	{
		var scene = Game.ActiveScene;
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Info( "[dmgnum] no player" ); return; }

		var victim = scene.GetAllComponents<Health>()
			.Where( h => h.IsValid() && h.Components.Get<NZPlayer>() is null )
			.OrderBy( h => h.WorldPosition.DistanceSquared( player.WorldPosition ) )
			.FirstOrDefault();

		if ( !victim.IsValid() ) { Log.Info( "[dmgnum] no target in scene" ); return; }

		// ⚠️ Straight into Active, NOT through Report — Report would batch it into whatever real
		// hit shares this victim, and a test that silently merges with live fire proves nothing.
		//
		// ⚠️ THE `Frame = -1` SENTINEL IS GONE WITH THE FIELD. It used to guarantee this could
		// never share a batch key with a live hit; now the key is the victim, so a REAL hit landing
		// on this zombie while the test number is up will still merge into it. Test on something
		// nobody is shooting.
		Active.Add( new Num
		{
			World = victim.WorldPosition + Vector3.Up * 48f,
			Amount = amount,
			Headshot = headshot > 0,
			Victim = victim,
			// ⚠️ -1 can never equal `Time.Now`, so a test number is unmergeable by
			// construction rather than by luck.
			Stamp = -1f,
			Age = 0f,
		} );
		Log.Info( $"[dmgnum] test -{Compact( amount )} on '{victim.GameObject.Name}' "
			+ $"({(headshot > 0 ? "headshot" : "body")})" );
	}

	/// <summary>
	/// `nz_dmgnum_compact [amount]` — how a hit of this much reads on screen (<see cref="Compact"/>). Bare, every step, from
	/// 9,876 to a billion and more. Needs no game.
	/// </summary>
	[ConCmd( "nz_dmgnum_compact" )]
	public static void CmdCompact( float amount = -1f )
	{
		var list = amount >= 0f
			? new[] { amount }
			: new[] { 9876f, 9999.6f, 10000f, 10349f, 99949f, 99960f, 125000f, 999499f, 999600f, 1234567f, 12345678f, 123456789f, 1234567890f };

		foreach ( var a in list )
			Log.Info( $"[dmgnum] {a.ToString( "#,0.#", System.Globalization.CultureInfo.InvariantCulture ),16}  ->  -{Compact( a )}" );
	}
}