A Razor UI PanelComponent that displays floating damage number labels over world positions. It syncs active damage entries to pooled Label elements, reuses or deletes labels, and positions/opacity updates each frame based on camera projection and age.
@using Sandbox;
@using Sandbox.UI;
@using System.Collections.Generic;
@using System.Linq;
@using NZombies;
@inherits PanelComponent
@*
DAMAGE NUMBERS — the floating "-240" over whatever you just shot.
⛔ BuildHash IS CONSTANT, for the same reason PointsPopupHud's is: a
PanelComponent destroys and recreates its entire tree whenever the hash
changes, and these elements are repositioned every single frame. A hash that
tracked them would rebuild the tree every frame — every number recreated
from scratch, sixty times a second. Children are added and removed from code
instead.
⚠️ POSITION IS SET FROM CODE, NOT CSS. Unlike the points popups these are
anchored to a WORLD point that moves on screen as you turn, so there is no
keyframe that could express the path. Style.Left/Top are written each frame;
writing Style does not rebuild the tree.
*@
<root class="dmg-nums"></root>
@code
{
/// <summary>⛔ CONSTANT ON PURPOSE — see the header.</summary>
protected override int BuildHash() => 0;
readonly Dictionary<DamageNumbers.Num, Panel> _live = new();
/// <summary>Scratch for expired entries — cleared and refilled, never reallocated.</summary>
readonly List<DamageNumbers.Num> _dead = new();
/// <summary>
/// Retired labels, waiting to be reused.
///
/// ⚠️ CAPPED AT MaxVisible, so a burst that briefly needed forty cannot leave forty hidden
/// elements parented forever. Anything over the cap is genuinely deleted.
/// </summary>
readonly List<Label> _pool = new();
static int PoolCap => DamageNumbers.MaxVisible;
protected override void OnUpdate()
{
// ⚠️ THE THEME'S CLASS FROM HERE, NOT FROM THE RAZOR: this tree is built once (`HudTheme.Wear`).
HudTheme.Wear( Panel );
// ⛔ THE UI WORK, WHICH cpu_dmg_numbers COULD NEVER SEE. That column wraps
// DamageNumbers.Report -- 1.8us to append a hit to a list. Everything that actually COSTS
// anything happens here, a frame later: a Label constructed per new number, AddChild
// triggering layout, an O(n^2) Contains scan over Active, and a .ToList() allocation every
// frame. Measuring Report and concluding "numbers are cheap" measured the wrong half.
using var _cpu = NZombies.CpuScope.Measure( "ui.dmgnum" );
// ⚠️ FIRST, and unconditionally. This is what makes "same frame = same
// shot" true; skipping it when the list is empty would let two shots
// separated by a quiet moment share a frame number and merge.
DamageNumbers.Tick();
DamageNumbers.Prune();
using ( NZombies.CpuScope.Measure( "ui.dmgnum.sync" ) )
Sync();
using ( NZombies.CpuScope.Measure( "ui.dmgnum.place" ) )
Place();
}
/// <summary>Add elements for new numbers, delete them for expired ones.</summary>
void Sync()
{
if ( Panel is null ) return;
foreach ( var n in DamageNumbers.Active )
{
if ( _live.ContainsKey( n ) ) continue;
// ⛔ TAKEN FROM A POOL, NOT CONSTRUCTED. A damage number cannot merge the way a
// points popup can -- each one is anchored over its own zombie, which is the entire
// point of it -- so the only way to stop the churn is to stop creating and destroying
// them. Eleven hits a frame used to mean eleven Labels built and eleven deleted, each
// AddChild triggering a layout pass, every frame. Now it is eleven show/hide.
//
// ⚠️ Constructed directly rather than via Panel.Add.Label — those helpers live on
// Sandbox.UI.Construct and are not in scope here. (Same trap PointsPopupHud documents.)
Label el;
if ( _pool.Count > 0 )
{
el = _pool[^1];
_pool.RemoveAt( _pool.Count - 1 );
el.Text = n.Text;
}
else
{
el = new Label { Text = n.Text };
el.AddClass( "num" );
Panel.AddChild( el );
}
// ⛔ SetClass, NOT AddClass. A pooled element keeps whatever it wore last time, so
// a recycled headshot label would stay gold on an ordinary hit -- and the reverse. This
// has to assert the state both ways, not just add it.
el.SetClass( "hs", n.Headshot );
el.Style.Display = DisplayMode.Flex;
_live[n] = el;
}
if ( _live.Count == 0 ) return;
// ⚠️ Materialise before mutating — removing while enumerating throws.
// ⛔ A REUSABLE LIST, NOT .ToList() EVERY FRAME. This ran on every frame of the game
// whether anything had died or not, allocating a List plus the LINQ closure behind it --
// and it is the UI phase, so none of it showed up in any damage-path scope.
_dead.Clear();
foreach ( var kv in _live )
if ( !DamageNumbers.Active.Contains( kv.Key ) ) _dead.Add( kv.Key );
foreach ( var dead in _dead )
{
// ⚠️ RETURNED, NOT DELETED. Hidden rather than destroyed so the next hit can have it
// back without a construction and a layout pass.
if ( _live[dead] is Label lbl )
{
lbl.Style.Display = DisplayMode.None;
if ( _pool.Count < PoolCap ) _pool.Add( lbl );
else lbl.Delete();
}
_live.Remove( dead );
}
}
/// <summary>Project every live number to the screen and move its element.</summary>
void Place()
{
var camera = Scene?.Camera;
if ( Panel is null || !camera.IsValid() ) return;
var eye = camera.WorldPosition;
foreach ( var (n, el) in _live )
{
var life = ((float)n.Age / DamageNumbers.Life).Clamp( 0f, 1f );
// Rise, plus the addon's distance lift so a far number clears the
// body instead of sitting inside it.
// ⚠️ The 6-arg Remap with clamp:true — the same form SpawnDirt uses.
var lift = MathX.Remap( n.World.Distance( eye ),
0f, DamageNumbers.DistanceLiftRange,
0f, DamageNumbers.DistanceLift, true );
var world = n.World + Vector3.Up * (lift + life * DamageNumbers.Rise);
var screen = camera.PointToScreenPixels( world, out var behind );
if ( behind )
{
// ⚠️ Hidden, not deleted. It is still inside its lifetime and
// could come back into view if the player turns; deleting would
// make a number vanish for good the moment it clipped an edge.
el.Style.Opacity = 0f;
continue;
}
// ⚠️ ScaleFromScreen converts real pixels to the panel's own units.
// Without it the numbers land in the right place only at 1080p and
// drift further off the target the further the resolution differs.
// ⚠️ Length.Pixels, not a bare float — the form SniperScope uses.
var s = Panel.ScaleFromScreen;
el.Style.Left = Length.Pixels( screen.x * s );
el.Style.Top = Length.Pixels( screen.y * s );
// Solid for the first half of its life, then linear to nothing —
// the addon's curve, so a number is readable before it starts going.
el.Style.Opacity = life > 0.5f ? 2f - 2f * life : 1f;
}
}
}