FpsUI/Models/KillfeedModel.cs

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.

Native Interop
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;
    }
}