FpsUI/Widgets/ScoreboardWidget.cs

UI widget for a FPS scoreboard header. Defines a stateless ScoreboardView that constructs different header layouts (TDM team row, Domination with capture pips, FFA) from a ScoreboardModel and FpsTheme, and a ScoreboardWidget component that owns a model and theme, updates the clock each tick, and builds the UI using Parts helpers.

Reflection
using Goo;
using Sandbox;
using Sandbox.UI;

namespace Goo.FpsUI;

// Stateless presenter: a compact top-center gamemode header. Returns the inner content; the caller
// wraps it in a backing (Parts.Panel) and anchors it. Three variants keyed off ScoreboardMode.
static class ScoreboardView
{
    public static Container Build( ScoreboardModel m, FpsTheme t ) => m.Mode switch
    {
        ScoreboardMode.Domination => Domination( m, t ),
        ScoreboardMode.Ffa        => Ffa( m, t ),
        _                         => TeamRow( m, t ),
    };

    // TDM: friendly score | clock | enemy score.
    static Container TeamRow( ScoreboardModel m, FpsTheme t ) => new()
    {
        Key = "teamRow", FlexDirection = FlexDirection.Row, AlignItems = Align.Center, Gap = 18f,
        Children =
        {
            Score( "fs", m.FriendlyScore, t.TeamFriendly, t ),
            Clock( m, t ),
            Score( "es", m.EnemyScore, t.TeamEnemy, t ),
        },
    };

    // Domination: the team row plus a capture-point pip strip.
    static Container Domination( ScoreboardModel m, FpsTheme t ) => new()
    {
        Key = "dom", FlexDirection = FlexDirection.Column, AlignItems = Align.Center, Gap = 7f,
        Children = { TeamRow( m, t ), Pips( m, t ) },
    };

    // FFA: placement | your score | clock | leader score.
    static Container Ffa( ScoreboardModel m, FpsTheme t ) => new()
    {
        Key = "ffa", FlexDirection = FlexDirection.Row, AlignItems = Align.Center, Gap = 18f,
        Children =
        {
            Small( "rank", $"#{m.PlayerRank}/{m.PlayerCount}", t.Ink, t, 16f ),
            Big( "score", m.PlayerScore.ToString(), t.Xp, t ),
            Clock( m, t ),
            Small( "leader", $"LEADER {m.LeaderScore}", t.Dim, t, 14f ),
        },
    };

    static Container Pips( ScoreboardModel m, FpsTheme t )
    {
        var row = new Container { Key = "pips", FlexDirection = FlexDirection.Row, AlignItems = Align.Center, Gap = 8f };
        for ( int i = 0; i < m.Points.Length; i++ )
        {
            Color bg = m.Points[i] switch
            {
                CapOwner.Friendly => t.TeamFriendly,
                CapOwner.Enemy    => t.TeamEnemy,
                _                 => t.TrackBg,
            };
            string label = i < m.PointLabels.Length ? m.PointLabels[i] : ((char)('A' + i)).ToString();
            row.Children.Add( new Container
            {
                Key = i.ToString(),
                Width = 22f, Height = 22f, BackgroundColor = bg, BorderRadius = t.Radius,
                AlignItems = Align.Center, JustifyContent = Justify.Center,
                Children = { new Text( label )
                    { FontFamily = t.FontFamily, FontSize = 13f, FontWeight = t.WeightBold, FontColor = Color.White } },
            } );
        }
        return row;
    }

    // Fixed-width score so changing digits never shift the clock.
    static Container Score( string key, int value, Color color, FpsTheme t ) => new()
    {
        Key = key, Width = 56f, AlignItems = Align.Center, JustifyContent = Justify.Center,
        Children = { Big( "v", value.ToString(), color, t ) },
    };

    static Text Clock( ScoreboardModel m, FpsTheme t ) => new( m.Clock )
    {
        Key = "clk", FontFamily = t.FontFamily, FontSize = 20f, FontWeight = t.WeightBold,
        FontColor = t.Ink, WhiteSpace = WhiteSpace.NoWrap,
    };

    static Text Big( string key, string s, Color c, FpsTheme t ) => new( s )
    {
        Key = key, FontFamily = t.FontFamily, FontSize = 26f, FontWeight = t.WeightBold,
        FontColor = c, WhiteSpace = WhiteSpace.NoWrap,
    };

    static Text Small( string key, string s, Color c, FpsTheme t, float size ) => new( s )
    {
        Key = key, FontFamily = t.FontFamily, FontSize = size, FontColor = c, WhiteSpace = WhiteSpace.NoWrap,
    };
}

// Standalone gamemode scoreboard header. Set Mode, mutate Scoreboard fields from your game.
public sealed partial class ScoreboardWidget : GooPanel<Container>
{
    [Property] public ScoreboardMode Mode { get; set; } = ScoreboardMode.Tdm; // which header variant

    readonly ScoreboardModel _m = new();
    readonly FpsTheme _t = new();
    bool _booted;

    public ScoreboardModel Scoreboard => _m;  // mutate fields directly (scores, timer, captures)

    void Boot() { _m.Mode = Mode; _booted = true; }

    // Demo-only seam: implemented in FpsDemo.cs, compiles out when that file is deleted.
    partial void StepDemo( float dt, ref bool active );

    protected override bool Tick( float dt )
    {
        if ( !_booted ) Boot();
        bool demo = false;
        StepDemo( dt, ref demo );
        if ( !demo ) _m.Mode = Mode;
        _m.Tick( dt );   // count the clock down
        return true;     // the clock changes every second, the header is cheap (text + rects)
    }

    protected override Container Build()
    {
        if ( !_booted ) Boot();
        var root = Parts.Root( "fpsScoreboard" );
        root.Children.Add( Parts.Anchor( "a", Parts.Corner.TopCenter, _t.Margin, Parts.Panel( "sbBg", _t, ScoreboardView.Build( _m, _t ) ) ) );
        return root;
    }
}