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.
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 <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++;
}
}