UI/PointsPopups.cs

A static helper managing the floating points popups shown by the HUD. It stores active pop entries with amount, age, drift class and style delay, supports merging recent awards, enforces a max visible count, prunes expired entries, and exposes console commands to toggle and configure behavior.

Reflection
using Sandbox;
using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// The floating "+50" beside the points counter.
///
/// ⛔ BEHAVIOUR TAKEN FROM THE ORIGINAL, NOT INVENTED. nzombies/gamemode/display/
/// cl_hud.lua builds one entry per award — `{ply, amount, diry = random(-25,25),
/// time = CurTime()}` — and draws it for exactly one second, fading linearly to
/// transparent while drifting +35px right and `diry` vertically.
///
/// ⚠️ ONE POPUP PER AWARD, NOT A RUNNING TOTAL. That is why the original
/// randomises the vertical drift: three hits in the same second produce three
/// numbers, and without the scatter they would draw on top of each other. Summing
/// them into one figure would be a different game feel and is not what it does —
/// the only place the original totals them is a clientside-diffing convar mode
/// that its own help text describes as a limitation.
///
/// ⚠️ COLOUR CARRIES THE MEANING: gold under 100, green at 100+, red for a spend.
/// A headshot (100) reading differently from a body shot (50) is information, not
/// decoration.
/// </summary>
public static class PointsPopups
{
	/// <summary>
	/// How long one popup lives.
	///
	/// ⛔ MUST MATCH `animation-duration` ON `.pop` IN SurvivalHud.razor.scss.
	/// C# removes the element when its age passes this; the stylesheet decides
	/// how long the motion takes. Shorter here cuts the number off mid-flight,
	/// longer leaves an invisible element holding a MaxVisible slot. They cannot
	/// read each other, so the only thing keeping them in step is this note.
	///
	/// ⚠️ The original is 1s (`Clamp(CurTime()-time, 0, 1)`). Shortened to 0.45s
	/// at the user's call — with a longer throw (-90px) so it reads as a quicker
	/// flick rather than the same move rushed.
	/// </summary>
	public static float Life { get; set; } = 0.45f;

	/// <summary>
	/// Where the popup panel sits, in pixels from the screen's right and bottom.
	///
	/// ⛔ NOT DERIVABLE FROM THE HUD ANY MORE. The popups are their own panel now
	/// (so a HUD rebuild cannot destroy them), which means they can no longer be
	/// positioned relative to `.points` — these have to reproduce where the HUD
	/// puts the counter, and they are the one thing about this feature that
	/// cannot be checked without looking at the screen.
	///
	/// Derived from SurvivalHud's own layout constants:
	///
	///     bottom 48 (.right) + ~81 (.lower: 22px name + 46px ammo)
	///              + 62 (gap) + ~27 (half of .points) ≈ 218 to the icon's centre
	///     right  40 (.right) + 160 (.value min-width) + 10 (gap) = 210 to its edge
	///
	/// ⚠️ THE VERTICAL IS AN ESTIMATE — `.lower`'s height is content-driven, so
	/// line heights for a 22px and a 46px font are guessed rather than measured.
	/// `nz_points_pos` moves it live so this can be settled by eye in seconds
	/// instead of by recompiling.
	/// </summary>
	public static float AnchorRight { get; set; } = 200f;

	/// <inheritdoc cref="AnchorRight"/>
	public static float AnchorBottom { get; set; } = 190f;

	/// <summary>Cap, so a chaotic round cannot fill the screen with numbers. The
	/// original has none — it is drawing text in a loop, where we are creating
	/// panels — so this is ours and it discards the OLDEST.</summary>
	public static int MaxVisible { get; set; } = 12;

	public sealed class Pop
	{
		public int Amount;
		public RealTimeSince Age;

		/// <summary>Which vertical drift to use, 0-4.
		///
		/// ⚠️ AN INDEX, NOT A PIXEL VALUE. The drift is done by CSS keyframes, and
		/// picking between five authored classes avoids passing a number from C#
		/// into a stylesheet — which would mean either a custom property or
		/// per-frame inline styles, and per-frame styles change BuildHash on every
		/// frame and rebuild the whole tree.
		/// </summary>
		public int Drift;

		/// <summary>Text as the original prints it: "+50", or "-500" already
		/// signed, so no minus is added.</summary>
		public string Text => Amount >= 0 ? $"+{Amount}" : Amount.ToString();

		/// <summary>gold &lt;100 · green 100+ · red for a spend.</summary>
		public string Tone => Amount < 0 ? "spend" : Amount >= 100 ? "big" : "gold";

		public string DriftClass => $"d{Drift}";

		/// <summary>
		/// A NEGATIVE animation-delay equal to this popup's age, so a recreated
		/// element resumes where it was instead of starting over.
		///
		/// ⛔ THIS IS THE "NUMBERS APPEAR AGAIN" FIX. `Points` is in the HUD's
		/// BuildHash and every award changes it, so each award rebuilds the whole
		/// tree — destroying and recreating every live .pop element, which
		/// restarts its CSS animation from 0%. Kill several zombies quickly and
		/// every number on screen replays its flight at once.
		///
		/// A negative delay is the standard CSS answer: start the animation as if
		/// it had already been running for that long. Rebuilt at 0.2s old, the
		/// element resumes 44% through a 0.45s flight rather than from the top.
		///
		/// ⚠️ VERIFIED AS FAR AS I COULD: `animation-delay` is used in 8 engine
		/// stylesheets, but NO negative value appears anywhere in the engine and
		/// the property is undocumented. If s&box clamps negatives to zero this
		/// changes nothing and the popups still restart — no worse than now, but
		/// the fallback is to move the popups into their own PanelComponent so
		/// they never share SurvivalHud's rebuilds.
		/// </summary>
		public string DelayStyle => $"animation-delay: -{(float)Age:0.000}s;";
	}

	/// <summary>
	/// Seconds of point awards that merge into a single popup. 0.2.
	///
	/// ⛔ AWARDS ARRIVE FAR FASTER THAN ANYONE CAN READ THEM. Every zombie hit pays, so a shot
	/// through eleven bodies pays eleven times, and a 1200rpm weapon fires twenty times a second on
	/// top of that. Un-merged, that is hundreds of "+10" labels a second against a MaxVisible of
	/// twelve -- the list replaced several times over per frame, with the HUD creating and deleting
	/// a Label for every change. At 0.2s each popup absorbs about four shots' worth and reads as one
	/// number that counts up.
	///
	/// ⚠️ THE WINDOW IS FIXED FROM CREATION, not slid forward on each merge -- see _windowOpened.
	///
	/// ⚠️ 0 RESTORES PER-AWARD POPUPS, which is the old behaviour and a useful A/B.
	/// </summary>
	public static float MergeWindow
	{
		get => _mergeWindow ?? 0.2f;
		set => _mergeWindow = value;
	}

	static float? _mergeWindow;

	/// <summary>`nz_pointspop_merge [seconds]` — how long one popup absorbs awards. 0 = never merge.</summary>
	[ConCmd( "nz_pointspop_merge" )]
	public static void MergeCmd( float seconds = -1f )
	{
		if ( seconds >= 0f ) MergeWindow = MathX.Clamp( seconds, 0f, 5f );

		Log.Info( $"[nz-points] merge window {MergeWindow:0.###}s"
			+ (MergeWindow <= 0f ? "   (off — one popup per award)" : "")
			+ $" · {Active.Count}/{MaxVisible} live" );
	}

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

	/// <summary>
	/// When the newest popup was CREATED — the start of its merge window.
	///
	/// ⚠️ RENAMED FROM `_frameAt`, WHICH MEANT SOMETHING ELSE. It held a frame marker for
	/// same-frame merging; it now holds a window start. s&box hotload copies statics forward by
	/// name, so reusing the name would have carried a frame timestamp into a field that is now
	/// compared against a duration.
	///
	/// ⛔ SET ON CREATION ONLY, NEVER ON A MERGE. If merging pushed it forward the window
	/// would slide, and under sustained fire one popup would absorb every award forever and never
	/// visibly expire. Fixed from creation means each popup swallows exactly MergeWindow seconds
	/// and then a fresh one starts.
	/// </summary>
	static float _windowOpened = -999f;

	/// <summary>
	/// Bumped on every add and every expiry.
	///
	/// ⚠️ THIS IS WHAT THE HUD PUTS IN BuildHash. A PanelComponent only rebuilds
	/// when its hash changes, so without a value that moves when the list moves,
	/// a new popup would never appear and an expired one would never leave.
	/// </summary>
	public static int Version { get; private set; }

	/// <summary>
	/// Draw points popups at all.
	///
	/// ⛔ EVERY ZOMBIE HIT AWARDS POINTS, so every hit queues one of these. At eleven bodies a
	/// frame that is eleven Add() calls against a MaxVisible of twelve -- the list is completely
	/// replaced every frame, and PointsPopupHud creates and deletes a Label for each change.
	/// Damage numbers have the same shape and their own toggle; this had none, so turning numbers
	/// off left the second popup system running and looked like "popups are not the problem".
	/// </summary>
	public static bool Enabled
	{
		get => _enabled ?? true;
		set => _enabled = value;
	}

	static bool? _enabled;

	/// <summary>`nz_pointspop [0/1]` — points popups on or off.</summary>
	[ConCmd( "nz_pointspop" )]
	public static void EnableCmd( int on = -1 )
	{
		Enabled = on < 0 ? !Enabled : on > 0;
		if ( !Enabled ) { Active.Clear(); Version++; }

		Log.Info( $"[nz-points] popups {(Enabled ? "on" : "off")}"
			+ $" · {Active.Count} live, max {MaxVisible}" );
	}

	public static void Add( int amount )
	{
		if ( amount == 0 || !Enabled ) return;

		// ⛔ SAME-FRAME AWARDS MERGE INTO ONE POPUP, and this is a gameplay improvement before
		// it is an optimisation. A shot through eleven zombies awards eleven times, which used to
		// stack eleven separate "+10" labels in the corner -- against a MaxVisible of twelve, so the
		// list was completely replaced every frame and the HUD created and deleted eleven Labels
		// with it. One "+110" is both what a player wants to read and one Label instead of eleven.
		//
		// ⚠️ Time.Now IS CONSTANT WITHIN A FRAME, which is what makes "same frame" testable at all
		// -- the same trick SoundGate and CpuScope use, and for the same reason: there is no tick
		// counter to compare against.
		//
		// ⚠️ SIGN MUST MATCH. Merging a spend into a gain would silently net them off and show a
		// player who earned 500 and spent 500 nothing at all.
		if ( Time.Now - _windowOpened < MergeWindow && Active.Count > 0
			&& (Active[^1].Amount >= 0) == (amount >= 0) )
		{
			Active[^1].Amount += amount;
			Version++;
			return;
		}

		_windowOpened = Time.Now;

		Active.Add( new Pop
		{
			Amount = amount,
			Age = 0f,
			Drift = Game.Random.Int( 0, 4 ),
		} );

		while ( Active.Count > MaxVisible )
			Active.RemoveAt( 0 );

		Version++;
	}

	/// <summary>Drop expired popups. Call from the HUD's update.</summary>
	public static void Prune()
	{
		for ( int i = Active.Count - 1; i >= 0; i-- )
		{
			if ( Active[i].Age < Life ) continue;

			Active.RemoveAt( i );
			Version++;
		}
	}

	/// <summary>Wipe — used when the round resets so old numbers do not survive
	/// into a new game.</summary>
	public static void Clear()
	{
		if ( Active.Count == 0 ) return;

		Active.Clear();
		Version++;
	}
}