UI/ReviveHud.razor

UI component that draws revive markers over downed teammates. It builds and positions an icon, a progress bar and a countdown for each downed NZPlayer (except the local player), updates them each frame, manages a preview marker for testing, and exposes console commands to test and report marker state.

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

@*
    THE REVIVE MARKER — an icon over every downed teammate, and the bar that fills
    while you pick them up.

    ⛔ WITHOUT THIS A DOWNED TEAMMATE IS INVISIBLE. A crawling player is at ankle
    height behind whatever they fell behind; on a map with any cover at all you
    cannot find them, and the `UsePrompt` line only appears once you are already
    within 90 units of a body you had no way to locate. The icon is what makes the
    revive a thing you can go and DO rather than stumble into.

    ⚠️ DRAWN THROUGH WALLS, DELIBERATELY. Every other world marker in this project
    is line-of-sight; this one must not be, for the reason above — an icon you can
    only see once you can already see the body tells you nothing you did not know.

    ⛔ BuildHash IS CONSTANT, exactly as DamageNumbersHud and PointsPopupHud are,
    and for the same reason: a PanelComponent destroys and rebuilds its whole tree
    whenever the hash changes, and these are repositioned every single frame. A
    hash over the marker set would rebuild every marker sixty times a second.
    Children are added and removed from code instead.

    ⚠️ NO ASSET. The badge is built from panels rather than an image because the
    only revive-ish art in the project is `ui/perks/revive.png`, the Quick Revive
    BOTTLE — which over a body reads as "a perk is here", not "your friend is
    down". A cross is unambiguous at any size and cannot be mistaken for a pickup.
*@

<root class="revive-markers"></root>

@code
{
    /// <summary>⛔ CONSTANT ON PURPOSE — see the header.</summary>
    protected override int BuildHash() => 0;

    /// <summary>
    /// How far up from the body's origin the badge floats, in world units.
    ///
    /// ⚠️ MEASURED FROM THE FEET, NOT THE HEAD. `WorldPosition` on a player is the
    /// floor under them, and a downed player's head is on the floor too — so this
    /// is the whole offset, not a nudge on top of a standing height.
    /// </summary>
    public static float Lift { get; set; } = 40f;

    /// <summary>Seconds of bleedout left at which the badge turns red.</summary>
    public static float UrgentAt { get; set; } = 10f;

    readonly Dictionary<NZPlayer, Marker> _live = new();
    readonly List<NZPlayer> _dead = new();

    /// <summary>
    /// A marker with no player behind it, for looking at the art.
    ///
    /// ⛔ THE REAL ONE IS NEVER DRAWN OVER MY OWN BODY, so solo there is nothing on screen to
    /// look at — and the badge, the bar, the urgent pulse and the countdown are four pieces of
    /// appearance that a second machine would only ever show me in a firefight. This is the same
    /// panels through the same `Build` and the same `Place` maths, so what it shows is what a real
    /// marker looks like; only the anchor is fake.
    ///
    /// ⚠️ NOT A DUMMY PLAYER. `ReviveAugments`' own header explains why a second `NZPlayer` in
    /// the scene is not worth building — 122 sites resolve "the player" as `FirstOrDefault`. A
    /// dummy MARKER touches nothing but this component.
    /// </summary>
    Marker _preview;

    static Vector3 _previewAt;
    static TimeUntil _previewUntil;
    static float _previewSecs;
    static float _previewFill;

    /// <summary>One downed player's marker: the badge, the bar, the count.</summary>
    class Marker
    {
        public Panel Root;
        public Panel Badge;
        public Panel Fill;
        public Label Secs;
    }

    protected override void OnUpdate()
    {
        if ( Panel is null ) return;

        // ⚠️ THE THEME'S CLASS FROM HERE, NOT FROM THE RAZOR: this tree is built once (`HudTheme.Wear`).
        HudTheme.Wear( Panel );

        Sync();
        Place();
        Preview();
    }

    /// <summary>Draw, move or retire the fake marker. Does nothing unless asked.</summary>
    void Preview()
    {
        var want = _previewUntil > 0f;

        if ( !want )
        {
            if ( _preview is null ) return;

            _preview.Root.Delete();
            _preview = null;
            return;
        }

        _preview ??= Build();

        var camera = Scene?.Camera;
        if ( !camera.IsValid() ) return;

        var screen = camera.PointToScreenPixels( _previewAt + Vector3.Up * Lift, out var behind );

        _preview.Root.Style.Opacity = behind ? 0f : 1f;
        if ( behind ) return;

        var scale = Panel.ScaleFromScreen;
        _preview.Root.Style.Left = Length.Pixels( screen.x * scale );
        _preview.Root.Style.Top = Length.Pixels( screen.y * scale );

        // ⚠️ THE BAR SWEEPS ON ITS OWN, because a still bar cannot show whether the fill
        // clips its rounded track — which is the one thing about it that can look wrong.
        var t = (_previewFill + Time.Now * 0.35f) % 1f;

        _preview.Fill.Style.Width = Length.Fraction( t );
        _preview.Fill.Parent.Style.Opacity = 1f;

        _preview.Secs.Text = $"{_previewSecs:0}";
        _preview.Badge.SetClass( "urgent", _previewSecs <= UrgentAt );
        _preview.Badge.SetClass( "helping", _previewSecs > UrgentAt );
    }

    /// <summary>Add a marker for anyone newly down, retire anyone who is not.</summary>
    void Sync()
    {
        var me = NZPlayer.Local;

        foreach ( var go in PlayerSpawner.AllBodies() )
        {
            var p = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
            if ( !p.IsValid() ) continue;

            // ⛔ NOT MYSELF. My own down is `DownedHud`, full screen, with the bleedout
            // that matters. A badge over my own head would sit in the middle of it.
            if ( p == me ) continue;

            // ⚠️ `IsOutOfRound` IS THE ONE THAT CROSSES MACHINES. `HasBledOut` is local — on my
            // copy of your body it is always false — so a marker keyed to it would hang over a
            // teammate who has already despawned, pointing at nothing, for the rest of the wave.
            var want = p.IsDown && !p.IsOutOfRound;

            if ( want && !_live.ContainsKey( p ) ) _live[p] = Build();
            else if ( !want && _live.TryGetValue( p, out var gone ) )
            {
                gone.Root.Delete();
                _live.Remove( p );
            }
        }

        // ⚠️ A BODY CAN LEAVE THE SCENE WHILE DOWN — a disconnect mid-bleedout — and the
        // loop above only ever visits bodies that still exist. Without this its marker
        // would hang in the air for the rest of the game.
        _dead.Clear();

        foreach ( var (p, _) in _live )
            if ( !p.IsValid() ) _dead.Add( p );

        foreach ( var p in _dead )
        {
            _live[p].Root.Delete();
            _live.Remove( p );
        }
    }

    Marker Build()
    {
        var root = new Panel();
        root.AddClass( "marker" );
        Panel.AddChild( root );

        var badge = new Panel();
        badge.AddClass( "badge" );
        root.AddChild( badge );

        // ⚠️ TWO PANELS, NOT A GLYPH. A "✚" in a Label depends on the UI font actually
        // carrying that codepoint, and a missing one draws a blank box rather than
        // failing loudly. Two rectangles cannot be missing.
        var v = new Panel(); v.AddClass( "cross-v" ); badge.AddChild( v );
        var h = new Panel(); h.AddClass( "cross-h" ); badge.AddChild( h );

        var track = new Panel();
        track.AddClass( "bar" );
        root.AddChild( track );

        var fill = new Panel();
        fill.AddClass( "fill" );
        track.AddChild( fill );

        var secs = new Label();
        secs.AddClass( "secs" );
        root.AddChild( secs );

        return new Marker { Root = root, Badge = badge, Fill = fill, Secs = secs };
    }

    /// <summary>Project every marker to the screen and set its numbers.</summary>
    void Place()
    {
        var camera = Scene?.Camera;
        if ( !camera.IsValid() ) return;

        var me = NZPlayer.Local;
        var scale = Panel.ScaleFromScreen;

        foreach ( var (p, m) in _live )
        {
            if ( !p.IsValid() ) continue;

            var screen = camera.PointToScreenPixels( p.WorldPosition + Vector3.Up * Lift, out var behind );

            // ⚠️ HIDDEN, NOT DELETED. Turning away from a downed teammate must not cost
            // the marker — it has to be there the instant you turn back.
            if ( behind )
            {
                m.Root.Style.Opacity = 0f;
                continue;
            }

            m.Root.Style.Opacity = 1f;
            m.Root.Style.Left = Length.Pixels( screen.x * scale );
            m.Root.Style.Top = Length.Pixels( screen.y * scale );

            // ── the bar ───────────────────────────────────────────────────────────────
            //
            // ⛔ MY progress, not theirs. `ReviveProgress` lives on the RESCUER so that two
            // players racing to revive one teammate do not each contribute half — which
            // means the number that fills this bar is on MY player, keyed to whether I am
            // the one currently working on THIS body.
            var mine = me.IsValid() && me.RevivingWho == p;
            var total = me.IsValid() ? ReviveAugments.SecondsFor( me ) : 1f;

            var frac = mine && total > 0f
                ? (me.ReviveProgress / total).Clamp( 0f, 1f )
                : 0f;

            m.Fill.Style.Width = Length.Fraction( frac );

            // ⚠️ THE BAR IS ONLY SHOWN WHILE IT MEANS SOMETHING. An empty track over every
            // downed player across the map is a row of identical grey lines saying nothing;
            // it appears when this particular revive is under way.
            m.Fill.Parent.Style.Opacity = mine ? 1f : 0f;

            // ── the countdown ─────────────────────────────────────────────────────────
            // ⛔ `BleedsOutIn` IS A LOCAL `TimeUntil` AND READS ZERO ON A PROXY. It was never
            // started on my copy of your body, so this marker showed **0** from the moment a
            // teammate fell to the moment they died — the one number it exists to carry.
            // `BleedoutShown` asks the owner's published int for anybody but me.
            var left = p.BleedoutShown;

            m.Secs.Text = $"{left}";
            m.Badge.SetClass( "urgent", left <= UrgentAt );
            m.Badge.SetClass( "helping", mine );
        }
    }

    // ── console ──────────────────────────────────────────────────────────────────────

    /// <summary>
    /// `nz_revive_hud_test [seconds] [bleedout]` — pin a fake marker in front of you and look at it.
    ///
    /// ⚠️ `bleedout` PICKS WHICH STATE IT SHOWS, because the two are different art: at or under
    /// `UrgentAt` it is the red pulsing badge, above it the green "somebody is on it" one. The
    /// default sits above, so `nz_revive_hud_test 20 5` is how the red one gets looked at.
    ///
    /// ⚠️ 0 SECONDS CLEARS IT, so a preview left running cannot outlive the session it was
    /// typed in and be mistaken for a real teammate.
    /// </summary>
    [ConCmd( "nz_revive_hud_test" )]
    public static void Test( float seconds = 20f, float bleedout = 30f )
    {
        _previewUntil = seconds;
        _previewSecs = bleedout;
        _previewFill = 0f;

        var camera = Game.ActiveScene?.Camera;

        // ⚠️ PLACED IN THE WORLD, NOT ON THE SCREEN, so turning away hides it exactly as a real
        // marker does — which is half of what there is to check.
        _previewAt = camera.IsValid()
            ? camera.WorldPosition + camera.WorldRotation.Forward * 150f - Vector3.Up * 40f
            : Vector3.Zero;

        Log.Info( seconds <= 0f
            ? "[nz-rev] preview cleared"
            : $"[nz-rev] preview marker at {_previewAt} for {seconds:0}s"
                + $" · showing the {(bleedout <= UrgentAt ? "URGENT (red, pulsing)" : "being-helped (green)")} state" );
    }

    /// <summary>
    /// `nz_revive_hud` — how many markers are up, and why there are not more.
    ///
    /// ⛔ A MARKER THAT NEVER APPEARS AND A MARKER BEHIND YOU LOOK THE SAME: nothing on
    /// screen. This separates "nobody is down as far as this machine knows" — which is the
    /// replication failing, and `nz_revive_state` names it — from "somebody is down and the
    /// marker was not built" from "it exists and is off-screen".
    /// </summary>
    [ConCmd( "nz_revive_hud" )]
    public static void Report()
    {
        var scene = Game.ActiveScene;
        if ( !scene.IsValid() ) { Log.Info( "[nz-rev] no scene" ); return; }

        var hud = scene.GetAllComponents<ReviveHud>().FirstOrDefault();

        if ( hud is null )
        {
            Log.Warning( "[nz-rev] ⛔ no ReviveHud in the scene — SurvivalHud attaches it in"
                + " OnUpdate, so this means the survival HUD itself is not running" );
            return;
        }

        var down = PlayerSpawner.AllBodies()
            .Select( go => go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) )
            .Where( p => p.IsValid() && p.IsDown )
            .ToList();

        Log.Info( $"[nz-rev] {hud._live.Count} marker(s) up · {down.Count} player(s) down"
            + $" (mine included; a marker is never drawn over my own body)" );

        foreach ( var p in down )
            Log.Info( $"[nz-rev]   '{p.GameObject.Name}' mine={PlayerPresence.Mine( p.GameObject )}"
                + $" bledOut={p.HasBledOut} marker={hud._live.ContainsKey( p )}"
                + $" beingRevived={p.BeingRevived} ({p.BeingRevivedFraction:0.00})" );
    }
}