UI/PointsPopupHud.razor

Razor UI component that hosts floating point/pop-up labels (like "+50") as persistent child panels. It prevents Razor rebuilds by keeping BuildHash constant, syncs live popups with a PointsPopups list, creates Label elements, updates their text in-place, and prunes expired ones.

File Access
@using Sandbox;
@using Sandbox.UI;
@using System.Collections.Generic;
@using System.Linq;
@using NZombies;
@inherits PanelComponent

@*
    THE FLOATING "+50", ON ITS OWN PANEL.

    ⛔ SEPARATE FROM SurvivalHud BECAUSE A CSS ANIMATION CANNOT SURVIVE IN IT.
    A PanelComponent rebuilds its whole tree whenever BuildHash changes, and the
    HUD's hash contains Points, Clip, Reserve, HealthPercent and Round — in a
    firefight that is several rebuilds a second. Every rebuild destroys and
    recreates every element, restarting any animation on it.

    That is the "they stutter whenever I get points while a previous one is
    there" report, and it survived two attempts to paper over it: a negative
    animation-delay to resume mid-flight (which helps, but still costs a frame
    per rebuild) and pinning the counter's width (a real bug, but a different
    one).

    ⚠️ THE FIX IS STRUCTURAL: BuildHash here NEVER CHANGES, so this tree is built
    once and never rebuilt. Popups are added and removed as CHILD PANELS from
    code, so each element lives from creation to expiry and its animation plays
    exactly once, uninterrupted, no matter what the rest of the HUD is doing.
*@

<root class="point-pops"></root>

@code
{
    /// <summary>
    /// ⛔ CONSTANT ON PURPOSE — THIS IS THE ENTIRE MECHANISM. A changing hash
    /// would rebuild the tree and destroy every popup mid-flight, which is the
    /// bug this component exists to escape. Nothing here may ever be driven by
    /// razor state; children are managed in Sync() instead.
    /// </summary>
    protected override int BuildHash() => 0;

    /// <summary>Which popup each live element belongs to, so expiry can find and
    /// delete the right one.</summary>
    readonly Dictionary<PointsPopups.Pop, Panel> _live = new();

    /// <summary>Scratch for expired entries — cleared and refilled, never reallocated.</summary>
    readonly List<PointsPopups.Pop> _dead = new();

    float _anchorRight = float.NaN;
    float _anchorBottom = float.NaN;

    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 other deferred-UI suspect, measured the same way as ui.dmgnum.
        using var _cpu = NZombies.CpuScope.Measure( "ui.pointspop" );

        // Expiry lives here now rather than in SurvivalHud — the popups are this
        // component's business, and leaving it there meant the HUD bumped
        // Version (and rebuilt itself) for something it no longer draws.
        PointsPopups.Prune();

        // ⚠️ APPLIED FROM CODE, NOT LEFT TO THE STYLESHEET. The anchor has to
        // reproduce where SurvivalHud draws the counter, and that cannot be
        // verified without looking at the screen — so it lives in a static that
        // `nz_points_pos` can move while the game is running. Setting Style does
        // NOT rebuild the tree, so nudging it costs a live popup nothing.
        if ( Panel is not null )
        {
            // ⚠️ ONLY WHEN IT MOVED. Assigning a Style property marks the panel dirty and can
            // force a layout pass, so writing the same two values every frame paid for a reflow
            // that changed nothing. The anchors only move when a console command moves them.
            if ( _anchorRight != PointsPopups.AnchorRight || _anchorBottom != PointsPopups.AnchorBottom )
            {
                _anchorRight = PointsPopups.AnchorRight;
                _anchorBottom = PointsPopups.AnchorBottom;
                Panel.Style.Right = _anchorRight;
                Panel.Style.Bottom = _anchorBottom;
            }
        }

        Sync();
    }

    /// <summary>
    /// Bring the panel's children in line with the popup list.
    ///
    /// ⚠️ ADD AND REMOVE ONLY — never touch an element that is already up. The
    /// whole point is that a live popup is left completely alone from creation
    /// to deletion, so its animation is never restarted or re-timed.
    /// </summary>
    void Sync()
    {
        if ( Panel is null ) return;

        foreach ( var p in PointsPopups.Active )
        {
            if ( _live.TryGetValue( p, out var have ) )
            {
                // ⚠️ TEXT IS REFRESHED, because a popup's Amount now CHANGES: same-frame awards
                // merge into the newest one, so a label created as "+10" becomes "+110" while the
                // element already exists. Setting Text only at creation would show the first award
                // and silently swallow the other ten.
                if ( have is Label lbl && lbl.Text != p.Text ) lbl.Text = p.Text;
                continue;
            }

            // ⚠️ CONSTRUCTED DIRECTLY, NOT VIA `Panel.Add.Label(...)`. Those
            // helpers are extension methods on `Sandbox.UI.Construct` and are not
            // in scope without that namespace — the compiler said so plainly
            // ("PanelCreator does not contain a definition for Label"). Building
            // the Label and parenting it needs no extra using at all.
            //
            // ⚠️ AddClass ONE AT A TIME. `AddClasses` is documented on Panel but
            // is not reachable from a Label here — the compiler was explicit. The
            // classes carry everything: tone picks the colour, drift picks which
            // keyframe lane it flies down.
            var el = new Label { Text = p.Text };
            el.AddClass( "pop" );
            el.AddClass( p.Tone );
            el.AddClass( p.DriftClass );
            Panel.AddChild( el );

            _live[p] = el;
        }

        if ( _live.Count == 0 ) return;

        // ⚠️ Materialise before mutating — removing from the dictionary while
        // enumerating it 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 ( !PointsPopups.Active.Contains( kv.Key ) ) _dead.Add( kv.Key );

        foreach ( var dead in _dead )
        {
            _live[dead]?.Delete();
            _live.Remove( dead );
        }
    }
}