KillfeedModel is a simple in-game killfeed data model. It stores a bounded newest-first list of kill entries with clipped names, team markers, local-player flags, a lifetime timer, and a spring-based slide-in progress.
using System;
using System.Collections.Generic;
using Goo.Animation;
namespace Goo.FpsUI;
// Team affiliation for a killfeed name, drives its color (friendly blue, enemy red, neutral ink).
public enum KillTeam { Neutral, Friendly, Enemy }
// Killfeed logic: a bounded, newest-first list of kill entries that slide in and expire.
// Each entry carries a stable Id (use as the Goo child Key) and a slide-in spring. Engine-free.
public sealed class KillfeedModel
{
public int MaxEntries = 5; // cap before oldest is evicted
public float Lifetime = 4.5f; // seconds an entry lives
public int MaxNameLength = 14; // names longer than this are truncated with an ellipsis
public sealed class Entry
{
public int Id; // stable identity for keying
public string Attacker = ""; // killer name (clipped)
public string Victim = ""; // victim name (clipped)
public bool Selfkill; // attacker is the world/unknown or equals the victim -> render victim only
public KillTeam AttackerTeam; // killer team -> name color
public KillTeam VictimTeam; // victim team -> name color
public bool AttackerLocal; // killer is the local player -> highlight marker
public bool VictimLocal; // victim is the local player -> highlight marker
public float Age; // seconds since added
public SpringFloat Slide; // 0 -> 1 slide-in progress
}
readonly List<Entry> _entries = new();
int _nextId;
public IReadOnlyList<Entry> Entries => _entries;
// Record a kill.
// The engine label renderer eats a mid-string ASCII colon (a name "x:3" renders "x3"), so we pass
// names through verbatim rather than rewrite them since live names rarely contain a colon.
// Teamless kill (both names neutral, neither local). Kept for simple callers.
public void Add( string attacker, string victim )
=> Add( attacker, KillTeam.Neutral, victim, KillTeam.Neutral, false, false );
// Full 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 )
{
// No attacker (world/fall/unknown) or attacker == victim is a self/world kill, view shows the victim alone.
bool self = string.IsNullOrWhiteSpace( attacker )
|| string.Equals( ( attacker ?? "" ).Trim(), ( victim ?? "" ).Trim(), StringComparison.OrdinalIgnoreCase );
string victimName = Clip( victim );
if ( victimName.Length == 0 ) victimName = "?"; // a kill with no victim still gets a visible row
_entries.Insert( 0, new Entry
{
Id = _nextId++,
Attacker = Clip( attacker ),
Victim = victimName,
Selfkill = self,
AttackerTeam = attackerTeam,
VictimTeam = victimTeam,
AttackerLocal = attackerLocal,
VictimLocal = victimLocal,
Slide = new SpringFloat( 0f, 16f, 0.8f ) { Target = 1f },
} );
while ( _entries.Count > MaxEntries ) _entries.RemoveAt( _entries.Count - 1 );
}
// Trim a name and cap its length so a long handle can't overflow the row (replaces the tail with an ellipsis).
public string Clip( string name )
{
name = ( name ?? "" ).Trim();
if ( MaxNameLength <= 1 || name.Length <= MaxNameLength ) return name;
int cut = MaxNameLength - 1;
// Don't slice through a UTF-16 surrogate pair (emoji/astral names): back off so the pair drops whole.
if ( cut > 0 && char.IsHighSurrogate( name[cut - 1] ) ) cut--;
return name.Substring( 0, cut ) + "…";
}
public bool Tick( float dt ) // age and slide entries, evict expired, true while anything changes
{
bool moving = false;
for ( int i = _entries.Count - 1; i >= 0; i-- )
{
var e = _entries[i];
e.Age += dt;
moving |= e.Slide.Tick( dt );
if ( e.Age >= Lifetime ) { _entries.RemoveAt( i ); moving = true; }
}
return moving;
}
}