UI/PlayerTagsHud.razor

A UI component (Razor) that draws teammate name tags on the HUD. It creates, positions and updates a tag per other NZPlayer, shows name and distance, handles off-screen edge placement and a console test command.

NetworkingFile Access
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Collections.Generic;
@using System.Linq;
@using NZombies;
@inherits PanelComponent
@*
    THE PLAYER TAGS — every teammate's name over their head, through walls, and pinned to the edge of the screen when they
    are not on it: *"I need a way to always know where all the other players are ... a player tag on top of the character
    that can be seen trough walls"* (2026-09-29).

    ⚠️ `ReviveHud`'S MACHINERY, KEPT WHOLE: a constant BuildHash, the tags built and retired from code, and every one
    projected to the screen each frame (`PointToScreenPixels`). Drawn on the HUD, so no wall can cover it — which is the
    whole of "through walls", with no depth trick to go wrong.

    ⛔ AND ALWAYS SOMEWHERE ON SCREEN. `ReviveHud` hides a marker behind you; a tag that vanished when you turned away
    would say nothing about where your teammate went. So a teammate off screen is held at its edge, on their side, with
    a pointer toward them — and one BEHIND you drops to the bottom edge, left or right as they are.
    ⚠️ THE SIDE COMES FROM THE CAMERA'S OWN AXES, NOT THE PROJECTION: a point behind the lens projects mirrored, so its
    screen position points the wrong way.

    ⚠️ ONE PER OTHER BODY: never my own, never anyone out of the round (`IsOutOfRound`, the flag that crosses machines).
    A downed teammate keeps a tag, red, lifted clear of `ReviveHud`'s badge.
*@
<root class="player-tags"></root>

@code
{
    protected override int BuildHash() => 0;

    // ⛔ NULLABLE-BACKED (INSTRUCTIONS §1): a static's initialiser does not run again on a hotload.

    /// <summary>`nz_tags 0` hides them, `nz_tags 1` brings them back (this session).</summary>
    public static bool On { get => _on ?? true; set => _on = value; }
    static bool? _on;

    /// <summary>How far above a standing player's feet the tag floats, in units: a player stands about 72.</summary>
    public static float Lift { get => _lift ?? 84f; set => _lift = value; }
    static float? _lift;

    /// <summary>
    /// A downed teammate's tag, in screen pixels over `ReviveHud`'s badge (which sits at the body + `ReviveHud.Lift`).
    /// ⚠️ IN PIXELS, NOT UNITS, so the two never overlap at range: 40 units apart is 180 px at 200 units and 18 px at 2,000.
    /// </summary>
    const float DownOverBadge = 76f;

    /// <summary>How far in from the screen's edge an off-screen tag is held, in the HUD's pixels.</summary>
    const float EdgeMargin = 56f;

    static readonly string[] Edges = { "left", "right", "top", "bottom" };

    readonly Dictionary<NZPlayer, Tag> _live = new();
    readonly List<NZPlayer> _gone = new();

    /// <summary>One teammate's tag: the name, the distance, and the pointer shown at an edge.</summary>
    class Tag
    {
        public Panel Root;
        public Label Name;
        public Label Dist;
        public Panel Pointer;
        public int Metres = -1;
    }

    // ── the preview (`nz_tags test`): one tag ahead of you and one behind, so the edge can be looked at solo ─────────────
    static TimeUntil _previewUntil;
    static Vector3 _aheadAt, _behindAt;
    Tag _ahead, _behind;

    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 );
        Panel.Style.Opacity = On ? 1f : 0f;

        Sync();

        var cam = Scene?.Camera;
        if ( !cam.IsValid() || !On ) return;

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

            var down = p.IsDown;
            var at = p.WorldPosition + Vector3.Up * (down ? ReviveHud.Lift : Lift);
            Place( t, cam, at, down ? DownOverBadge : 0f );
            SetName( t, NZPlayers.NameOf( p ) );
            SetDistance( t, cam, p.WorldPosition );
            t.Root.SetClass( "down", down );
        }

        Preview( cam );
    }

    /// <summary>A tag for every other body in the round; retire the rest.</summary>
    void Sync()
    {
        var me = NZPlayer.Local;

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

            // ⚠️ NOR THE ONE I AM LOOKING OUT OF AFTER BLEEDING OUT (2026-10-05, `SpectateOthers`): their tag sits over the camera.
            var want = On && !p.IsOutOfRound && !SpectateOthers.HidesTagOf( p );
            if ( want && !_live.ContainsKey( p ) ) _live[p] = Build();
            else if ( !want && _live.TryGetValue( p, out var off ) )
            {
                off.Root.Delete();
                _live.Remove( p );
            }
        }

        // ⚠️ A BODY CAN LEAVE THE SCENE — a disconnect — and the loop above only visits bodies that still exist
        _gone.Clear();
        foreach ( var (p, _) in _live )
            if ( !p.IsValid() ) _gone.Add( p );
        foreach ( var p in _gone )
        {
            _live[p].Root.Delete();
            _live.Remove( p );
        }
    }

    Tag Build()
    {
        var root = new Panel();
        root.AddClass( "tag" );
        Panel.AddChild( root );

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

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

        // ⚠️ A PANEL, NOT A GLYPH (`ReviveHud`'s reason): an arrow character depends on the font carrying it
        var pointer = new Panel();
        pointer.AddClass( "pointer" );
        root.AddChild( pointer );

        return new Tag { Root = root, Name = name, Dist = dist, Pointer = pointer };
    }

    static void SetName( Tag t, string name )
    {
        name = string.IsNullOrWhiteSpace( name ) ? "Player" : name;
        if ( t.Name.Text != name ) t.Name.Text = name;
    }

    /// <summary>Metres from the camera — a unit is an inch — written only when the whole number changes.</summary>
    static void SetDistance( Tag t, CameraComponent cam, Vector3 feet )
    {
        var m = (int)MathF.Round( feet.Distance( cam.WorldPosition ) * 0.0254f );
        if ( m == t.Metres ) return;

        t.Metres = m;
        t.Dist.Text = $"{m} m";
    }

    /// <summary>
    /// On screen, the tag sits on the point, `lift` pixels higher. Off it — outside the margin, or behind the lens — it is
    /// held at the edge on the teammate's side, centred on its spot, with the pointer on the side facing them.
    /// </summary>
    void Place( Tag t, CameraComponent cam, Vector3 world, float lift )
    {
        var scale = Panel.ScaleFromScreen;
        var w = Screen.Width;
        var h = Screen.Height;
        var margin = EdgeMargin / MathF.Max( 0.01f, scale );

        // camera space: x ahead, y to the left, z up
        var local = cam.WorldRotation.Inverse * (world - cam.WorldPosition);
        var screen = cam.PointToScreenPixels( world, out var behind );

        var onScreen = !behind && local.x > 1f
            && screen.x >= margin && screen.x <= w - margin && screen.y >= margin && screen.y <= h - margin;

        string edge = null;
        if ( !onScreen )
        {
            // ⚠️ ON SCREEN, RIGHT IS -y AND DOWN IS -z. And a teammate behind you goes DOWN, so they are found at the bottom
            // edge on their own side, not flipped to the top.
            var dx = -local.y;
            var dy = -local.z;
            if ( local.x < 0f ) dy = MathF.Abs( dy ) + MathF.Abs( local.x );
            if ( MathF.Abs( dx ) < 0.001f && MathF.Abs( dy ) < 0.001f ) dy = 1f;

            var cx = w * 0.5f;
            var cy = h * 0.5f;
            var hx = MathF.Max( 1f, cx - margin );
            var hy = MathF.Max( 1f, cy - margin );
            var kx = MathF.Abs( dx ) > 0.0001f ? hx / MathF.Abs( dx ) : float.MaxValue;
            var ky = MathF.Abs( dy ) > 0.0001f ? hy / MathF.Abs( dy ) : float.MaxValue;
            var k = MathF.Min( kx, ky );

            screen = new Vector2( cx + dx * k, cy + dy * k );
            edge = kx <= ky ? (dx > 0f ? "right" : "left") : (dy > 0f ? "bottom" : "top");
            lift = 0f;
        }

        t.Root.Style.Left = Length.Pixels( screen.x * scale );
        t.Root.Style.Top = Length.Pixels( screen.y * scale - lift );
        t.Root.SetClass( "edge", edge is not null );
        foreach ( var e in Edges )
            t.Root.SetClass( "edge-" + e, e == edge );
    }

    /// <summary>Draw, move or retire the two fake tags. Nothing unless `nz_tags test` asked.</summary>
    void Preview( CameraComponent cam )
    {
        if ( _previewUntil <= 0f )
        {
            _ahead?.Root.Delete();
            _behind?.Root.Delete();
            _ahead = _behind = null;
            return;
        }

        _ahead ??= Build();
        _behind ??= Build();

        Place( _ahead, cam, _aheadAt, 0f );
        SetName( _ahead, "Teammate" );
        SetDistance( _ahead, cam, _aheadAt - Vector3.Up * Lift );

        Place( _behind, cam, _behindAt, 0f );
        SetName( _behind, "Behind you" );
        SetDistance( _behind, cam, _behindAt - Vector3.Up * Lift );
    }

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

    /// <summary>
    /// `nz_tags [0|1|test] [seconds]` — the teammates' tags: `0` hides them and `1` shows them (this session); `test` pins a
    /// fake one five metres ahead of you and another behind you and to the left, for `seconds` (20; 0 clears), so the tag and
    /// the edge pointer can be looked at alone. Bare: how many are up, and for whom.
    /// </summary>
    [ConCmd( "nz_tags" )]
    public static void Cmd( string what = "", float seconds = 20f )
    {
        switch ( what.ToLowerInvariant() )
        {
            case "0" or "off": On = false; break;
            case "1" or "on": On = true; break;
            case "test":
            {
                var cam = Game.ActiveScene?.Camera;
                if ( !cam.IsValid() ) { Log.Warning( "[nz-tags] no camera" ); return; }

                // ⚠️ PLACED IN THE WORLD, NOT ON THE SCREEN, so turning round moves them exactly as real tags move
                var flat = cam.WorldRotation.Forward.WithZ( 0f ).Normal;
                var left = cam.WorldRotation.Left.WithZ( 0f ).Normal;
                var feet = cam.WorldPosition - Vector3.Up * 64f;
                _aheadAt = feet + flat * 200f + Vector3.Up * Lift;
                _behindAt = feet - flat * 300f + left * 150f + Vector3.Up * Lift;
                _previewUntil = MathF.Max( 0f, seconds );
                break;
            }
        }

        var scene = Game.ActiveScene;
        var hud = scene.IsValid() ? scene.GetAllComponents<PlayerTagsHud>().FirstOrDefault() : null;
        var others = scene.IsValid()
            ? PlayerSpawner.AllBodies( scene ).Select( g => g.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) )
                .Where( p => p.IsValid() && p != NZPlayer.Local ).ToList()
            : new List<NZPlayer>();

        if ( hud is null )
        {
            Log.Warning( "[nz-tags] no PlayerTagsHud in the scene — SurvivalHud attaches it, so the survival HUD is not running" );
            return;
        }

        Log.Info( $"[nz-tags] {(On ? "ON" : "OFF")} · {hud._live.Count} tag(s) for {others.Count} other player(s)"
            + (what == "test" ? $" · test tags for {seconds:0} s, one ahead and one behind you" : "") );
        foreach ( var p in others )
            Log.Info( $"[nz-tags]   {NZPlayers.NameOf( p ) ?? p.GameObject.Name}: {(p.IsOutOfRound ? "out of the round, no tag" : p.IsDown ? "down" : "up")}"
                + $" · tag {(hud._live.ContainsKey( p ) ? "yes" : "no")}" );
    }
}