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.
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;
}
}