FpsUI/Widgets/KillfeedWidget.cs

UI widget for a killfeed in an FPS HUD. It builds a newest-first column of rows showing "Attacker > Victim", applies slide/fade animations, team colors, local-player emphasis, and exposes Add methods to record kills.

Reflection
using System;
using Goo;
using Sandbox;
using Sandbox.UI;
using PanelTransform = Goo.PanelTransform;

namespace Goo.FpsUI;

// Stateless presenter: newest-first column of "Attacker  >  Victim" rows that slide in and fade out.
static class KillfeedView
{
    public static Container Build( KillfeedModel m, FpsTheme t )
    {
        var col = new Container { Key = "kf", FlexDirection = FlexDirection.Column, AlignItems = Align.FlexEnd, Gap = 4f };
        // All children keyed by stable entry Id (AddRange would auto-key, but we need per-entry transforms).
        foreach ( var e in m.Entries )
        {
            float slide = e.Slide.Current;                     // 0 -> 1
            float fade = Math.Clamp( (m.Lifetime - e.Age) / 0.5f, 0f, 1f ); // fade in last 0.5s
            var row = new Container
            {
                Key = e.Id.ToString(),
                FlexDirection = FlexDirection.Row, AlignItems = Align.Center, Gap = 8f,
                Padding = t.PanelPad, BackgroundColor = t.BackingBg, BorderRadius = t.Radius,
                MaxWidth = 360f,  // hard cap, names are already length-clipped in the model
                Opacity = Math.Min( slide, fade ),
                // Slide in from the right: translate by a fraction of the row's own width.
                Transform = PanelTransform.Translate( Length.Percent( (1f - slide) * 60f ) ?? default, Length.Percent( 0 ) ?? default ),
            };
            // Self/world kill: just the victim. Normal kill: "Attacker > Victim".
            if ( !e.Selfkill )
            {
                row.Children.Add( Name( e.Attacker, t, e.AttackerTeam, e.AttackerLocal ) );
                row.Children.Add( new Text( ">" ) { FontFamily = t.FontFamily, FontSize = 18f, FontColor = t.Dim } );
            }
            row.Children.Add( Name( e.Victim, t, e.VictimTeam, e.VictimLocal ) );
            col.Children.Add( row );
        }
        return col;
    }

    static Color TeamColor( KillTeam team, FpsTheme t ) => team switch
    {
        KillTeam.Friendly => t.TeamFriendly,
        KillTeam.Enemy    => t.TeamEnemy,
        _                 => t.Ink,
    };

    // A single name label that never wraps (the model already caps its length). The local player is
    // marked out with bold weight and a color brightened toward white so you spot your own kills fast.
    static Text Name( string s, FpsTheme t, KillTeam team, bool local )
    {
        Color teamColor = TeamColor( team, t );
        return new Text( s )
        {
            FontFamily = t.FontFamily, FontSize = local ? 19f : 18f,
            FontColor = local ? Color.Lerp( teamColor, Color.White, 0.35f ) : teamColor,
            FontWeight = local ? t.WeightBold : (int?)null,
            WhiteSpace = WhiteSpace.NoWrap,
        };
    }
}

// Standalone killfeed. Call Add(attacker, victim) when a kill happens.
public sealed partial class KillfeedWidget : GooPanel<Container>
{
    [Property, Range( 1, 10 )] public int MaxEntries { get; set; } = 5;       // rows shown before eviction
    [Property, Range( 1f, 12f )] public float Lifetime { get; set; } = 4.5f;  // seconds a row lives

    readonly KillfeedModel _m = new();
    readonly FpsTheme _t = new();

    public void Add( string attacker, string victim ) => _m.Add( attacker, victim ); // record a teamless kill
    // Record a kill with team colors and local-player markers.
    public void Add( string attacker, KillTeam attackerTeam, string victim, KillTeam victimTeam, bool attackerLocal = false, bool victimLocal = false )
        => _m.Add( attacker, attackerTeam, victim, victimTeam, attackerLocal, victimLocal );

    // 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 )
    {
        _m.MaxEntries = MaxEntries; _m.Lifetime = Lifetime;
        bool demo = false;
        StepDemo( dt, ref demo );
        bool moving = _m.Tick( dt );
        return demo || moving || _m.Entries.Count > 0;
    }

    protected override Container Build()
    {
        var root = Parts.Root( "fpsKillfeed" );
        root.Children.Add( Parts.Anchor( "a", Parts.Corner.TopRight, _t.Margin, KillfeedView.Build( _m, _t ) ) );
        return root;
    }
}