UI/DamageNumbersHud.razor

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.

File Access
@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;
        }
    }
}