UI/Scoreboard.razor

Razor UI component for the in-game scoreboard. Renders player rows (kills, headshots, downs, revives, points), an optional Easter egg progress card, and an optional map panel; it builds its data from networked connection/stat tables and local PlayerStats and computes a hash for UI updates.

NetworkingFile Access
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using NZombies;
@inherits PanelComponent

@*
    SCOREBOARD — held on TAB.

    ⛔ HELD, NOT TOGGLED. `Input.Down`, not `Pressed`: this is a glance at the table mid-round,
    which is how every shooter behaves, and a toggle leaves the panel over your face while a horde
    closes in. The lobby used to own TAB and has moved to L — see LobbyMenu's toggle.

    ⚠️ IT READS `PlayerStats`, WHICH RECORDS ITSELF. Nothing here counts anything; the columns are
    whatever the component on each player says. That keeps the panel free to be wrong on screen
    without being wrong in the data, and `nz_score` prints the same table to console.

    ⚠️ NOT SHOWN IN THE LOBBY. There is nothing to score before a game starts, and the lobby is a
    full-screen panel this would land on top of.
*@

<root class="scoreboard @(Showing ? "" : "hidden") @HudTheme.Class">

    @* ⛔ TWO COLUMNS (the user's layout, 2026-09-29: *"score on the left, map on the right, zombies left on the top, and current
       Easter egg step bellow the score"*): the score, and under it the Easter egg's own panel, on the left; the map on the right.
       On a map without a TAB map the right column is not drawn and the left one centres. *@
    <div class="side">

        <div class="board">

            <div class="head">
                <div class="title">SCOREBOARD</div>
                <div class="round">@RoundLabel</div>
            </div>

            <div class="row hdr">
                <div class="c name">Player</div>
                <div class="c num">Kills</div>
                <div class="c num">Headshots</div>
                <div class="c num">HS%</div>
                <div class="c num">Downs</div>
                <div class="c num">Revives</div>
                <div class="c num wide">Points earned</div>
            </div>

            @foreach ( var row in Rows )
            {
                <div class="row @(row.IsLocal ? "me" : "")">
                    <div class="c name">@row.Name</div>
                    <div class="c num">@row.Kills</div>
                    <div class="c num">@row.Headshots</div>
                    <div class="c num">@row.HeadshotPercent.ToString( "0.#" )%</div>
                    <div class="c num">@row.Downs</div>
                    <div class="c num">@row.Revives</div>
                    <div class="c num wide">@row.PointsEarned.ToString( "N0" )</div>
                </div>
            }

            @if ( Rows.Length == 0 )
            {
                <div class="empty">No players</div>
            }

        </div>

        @* ⛔ THE EASTER EGG, ITS OWN PANEL UNDER THE SCORE (2026-09-29): *"I want the steps to be divided in main step, and the
           little steps to complete it"*. The main step on now (`HexPlatforms.MainNowShown`), its little steps as this machine is
           shown them (`HexPlatforms.LittleStepsShown`), and the nine as hexes. Basalt's egg only, the same on every machine. *@
        @if ( EggOn )
        {
            <div class="board egg-card">

                <div class="head">
                    <div class="title">EASTER EGG</div>
                    <div class="round">@EggProgress.Counter( EggMain ).ToUpperInvariant()</div>
                </div>

                <div class="egg-name">@(EggMain > EggProgress.Mains ? "The Core is home" : EggProgress.MainName( EggMain ))</div>

                @foreach ( var l in EggRows )
                {
                    <div class="little @l.Class">
                        <div class="mark"><div class="glyph"></div></div>
                        <div class="text">@l.Step.Text</div>
                        @if ( l.Step.Need > 0 && !l.Step.Done )
                        {
                            <div class="count">@l.Step.Have / @l.Step.Need</div>
                        }
                    </div>
                }

                <div class="pips">
                    @for ( var m = 1; m <= EggProgress.Mains; m++ )
                    {
                        <div class="pip @PipClass( m )"><div class="h"></div><div class="h b"></div><div class="h c"></div></div>
                    }
                </div>

            </div>
        }

    </div>

    @* ⛔ THE MAP OF THE WHOLE PLACE, ON THE RIGHT (2026-09-29): *"I also want to see a map of the whole place"*. `TabMap` builds,
       sizes and moves everything in it from code, so the table's rebuilds leave it alone. *@
    @if ( MapOn )
    {
        <div class="board map-card">
            <div class="head">
                <div class="title">MAP</div>
                <div class="round">@TabMap.Title.ToUpperInvariant()</div>
            </div>
            <TabMap></TabMap>
        </div>
    }

</root>

@code {

    /// <summary>One line of the table, flattened so the markup does no work.</summary>
    record Row( string Name, int Kills, int Headshots, float HeadshotPercent,
        int Downs, int Revives, int PointsEarned, bool IsLocal );

    static RoundManager Rounds => Game.ActiveScene?.GetAllComponents<RoundManager>()
        .FirstOrDefault();

    /// <summary>
    /// Is the board on screen.
    ///
    /// ⛔ GATED ON THE MODE, NOT ONLY THE KEY. In the lobby the panel would sit on top of a
    /// full-screen menu and show a table of zeroes for a game that has not started.
    /// </summary>
    static bool Showing => (Input.Down( "Score" ) || Pinned) && NZGame.Mode != GameMode.Lobby;

    /// <summary>
    /// Hold the board open without the key, for looking at it.
    ///
    /// ⚠️ A DEV AFFORDANCE, NOT A FEATURE. `Showing` is a HELD state, so the layout cannot be
    /// judged from a screenshot — and the same problem is why `nz_stats_fake` exists. Off by
    /// default; nothing in the game sets it.
    /// </summary>
    public static bool Pinned { get; set; }

    /// <summary>`nz_score_pin [0/1]` — hold the board open, or let TAB own it again.</summary>
    [ConCmd( "nz_score_pin" )]
    public static void PinCmd( int on = -1 )
    {
        Pinned = on < 0 ? !Pinned : on != 0;
        Log.Info( $"[nz-score] pinned {(Pinned ? "ON" : "off")}"
            + (NZGame.Mode == GameMode.Lobby ? " — but the lobby hides it, leave the lobby" : "") );
    }

    /// <summary>Is the Easter egg's panel drawn: on basalt, whose egg it is.</summary>
    static bool EggOn => HexPlatforms.OnBasalt;

    /// <summary>The main step on now, 1-9, or 10 once all nine are done (`HexPlatforms.MainNowShown`).</summary>
    static int EggMain => HexPlatforms.MainNowShown;

    /// <summary>A little step and how it is drawn: `done`, `now` (the first not done), or `todo`.</summary>
    record EggRow( HexPlatforms.EggLittle Step, string Class );

    /// <summary>
    /// The main step on now's little steps, marked.
    /// ⚠️ "NOW" IS THE FIRST NOT DONE, not the one after the last done: the Shield Lock can open before the Torch Carry, so a later
    /// little step can be ticked while an earlier one is still on.
    /// </summary>
    static EggRow[] EggRows
    {
        get
        {
            var main = EggMain;
            if ( main > EggProgress.Mains ) return System.Array.Empty<EggRow>();

            var steps = HexPlatforms.LittleStepsShown( main );
            var rows = new EggRow[steps.Length];
            var on = false;
            for ( var i = 0; i < steps.Length; i++ )
            {
                rows[i] = new EggRow( steps[i], steps[i].Done ? "done" : on ? "todo" : "now" );
                if ( !steps[i].Done ) on = true;
            }

            return rows;
        }
    }

    /// <summary>A main step's hex: done, the one on now, or neither.</summary>
    static string PipClass( int main ) => HexPlatforms.MainDoneShown( main ) ? "done" : main == EggMain ? "now" : "";

    /// <summary>Is the map panel drawn: this map has a TAB map (`TabMap.AvailableFor`), and `nz_tabmap 0` has not hidden it.</summary>
    static bool MapOn => TabMap.On && TabMap.AvailableFor( NZMap.Current );

    static string RoundLabel
    {
        get
        {
            var r = Rounds;
            return r.IsValid() && r.Round > 0 ? $"ROUND {r.Round}" : "";
        }
    }

    /// <summary>
    /// The table, best score first.
    ///
    /// ⚠️ SORTED BY POINTS EARNED, not kills. Points are the game's own measure of contribution —
    /// they already weight a headshot over a body shot and pay for revives and repairs, so sorting
    /// by kills would rank a player who did nothing but shoot above one who kept the team alive.
    /// </summary>
    static Row[] Rows
    {
        get
        {
            // ⛔ FROM THE NETWORK TABLE, NOT FROM THE `PlayerStats` COMPONENTS IN THE SCENE.
            //
            // A `PlayerStats` is filled in on the machine where the kill happened, so THIS
            // machine's copy of somebody else's body has one that has never recorded anything
            // and never will. Every player saw a scoreboard with themselves on it and everybody
            // else on zero. User: *"each player can only see their own."*
            //
            // ⚠️ MY OWN ROW COMES FROM MY OWN COMPONENT, because it is right here and cannot lag
            // a frame behind my own kill on my own screen.
            //
            // ⚠️ DRIVEN BY `Connection.All`, NOT BY THE BODIES. A player is on the scoreboard
            // because they are in the game, not because their body happens to exist in my scene
            // yet — which is exactly the window a joining player spends on nobody's list.
            var rows = new List<Row>();

            if ( !Networking.IsActive )
            {
                var solo = PlayerStats.For( NZPlayer.Local );

                if ( solo is not null )
                    rows.Add( new Row( NameFor( NZPlayer.Local ), solo.Kills, solo.Headshots,
                        solo.HeadshotPercent, solo.Downs, solo.Revives, solo.PointsEarned, true ) );

                return rows.ToArray();
            }

            var meId = Connection.Local?.Id ?? default;
            var mineStats = PlayerStats.For( NZPlayer.Local );

            foreach ( var c in Connection.All )
            {
                if ( c is null ) continue;

                var mine = c.Id == meId;

                var kills = mine && mineStats is not null ? mineStats.Kills : NZNet.StatsOf( c.Id ).Kills;
                var heads = mine && mineStats is not null ? mineStats.Headshots : NZNet.StatsOf( c.Id ).Headshots;
                var downs = mine && mineStats is not null ? mineStats.Downs : NZNet.StatsOf( c.Id ).Downs;
                var revs  = mine && mineStats is not null ? mineStats.Revives : NZNet.StatsOf( c.Id ).Revives;
                var pts   = mine && mineStats is not null ? mineStats.PointsEarned : NZNet.StatsOf( c.Id ).Points;

                rows.Add( new Row( c.DisplayName ?? "?", kills, heads,
                    kills > 0 ? 100f * heads / kills : 0f, downs, revs, pts, mine ) );
            }

            return rows
                .OrderByDescending( r => r.PointsEarned )
                .ThenByDescending( r => r.Kills )
                .ToArray();
        }
    }

    /// <summary>
    /// What to call a player: their Steam profile name.
    ///
    /// ⛔ NOT THE CHARACTER. Two players can both pick Dempsey, and the character is a costume
    /// rather than an identity — a scoreboard has to say WHO, which is the Steam name the lobby
    /// already lists (`Connection.All` -> `DisplayName`, LobbyMenu:171).
    ///
    /// ⚠️ THE OWNING CONNECTION FIRST, THEN THE LOCAL ONE. `Network.Owner` is the right answer once
    /// players are networked; it is null while the game is effectively single-player, so the local
    /// connection covers the only player there is. Falling straight back to the object name would
    /// print "Player Controller" for the one case that works today.
    /// </summary>
    static string NameFor( NZPlayer p )
    {
        if ( !p.IsValid() ) return "—";

        // ⛔ THROUGH `NZPlayers.NameOf` NOW, AND THIS COPY ASKED THE WRONG THING. It read
        // `p.Network?.Owner`, which is null for the host's own body — an unowned object is nobody's
        // — so the host's row fell through to the object name. `NZPlayer.OwningConnection` is the
        // project's settled answer to "whose body is this" and carries a screen of comment about
        // the two derived answers that failed before it.
        var name = NZPlayers.NameOf( p );
        if ( !string.IsNullOrWhiteSpace( name ) ) return name;

        // ⚠️ THE OBJECT NAME IS THIS CALLER'S FALLBACK, not the helper's — a cell wants
        // something in it, whereas the revive prompt wants "your teammate" in a sentence.
        return p.GameObject.Name;
    }

    // ⚠️ THE WHOLE TABLE IS IN THE HASH, so a kill landing while the board is held updates it.
    // Hashing only `Showing` would freeze the numbers at the moment TAB went down.
    protected override int BuildHash()
    {
        var h = new HashCode();
        h.Add( Showing );
        h.Add( HudTheme.Class );
        h.Add( RoundLabel );
        h.Add( MapOn );
        h.Add( TabMap.Title );
        h.Add( EggOn );

        // ⚠️ THE EGG'S WHOLE STATE IN THE HASH, as the table's is: a little step ticking while the board is held must show
        if ( EggOn )
        {
            h.Add( EggMain );
            foreach ( var r in EggRows )
            {
                h.Add( r.Class );
                h.Add( r.Step.Have );
            }

            for ( var m = 1; m <= EggProgress.Mains; m++ )
                h.Add( HexPlatforms.MainDoneShown( m ) );
        }

        foreach ( var r in Rows )
        {
            h.Add( r.Name );
            h.Add( r.Kills );
            h.Add( r.Headshots );
            h.Add( r.Downs );
            h.Add( r.Revives );
            h.Add( r.PointsEarned );
        }

        return h.ToHashCode();
    }
}