UI/KillFeed.razor
@using System;
@using System.Collections.Generic;
@using System.Globalization;
@using System.Linq;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox
<root class="@(IsWorldPanel ? "full" : "")">
<div class="feed">
@foreach ( var entry in VisibleEntries )
{
var opacity = EntryOpacity( entry ).ToString( "0.###", CultureInfo.InvariantCulture );
<div class="entry" style="opacity: @opacity;">
@if ( !entry.IsSuicide )
{
<label class="name @(IsLocal( entry.KillerId ) ? "local" : "")">@entry.KillerName</label>
@if ( !string.IsNullOrEmpty( entry.WeaponIconPath ) )
{
<img class="weapon" src="@entry.WeaponIconPath" />
}
else
{
<label class="separator">»</label>
}
}
else
{
<label class="separator suicide">☠</label>
}
<label class="name @(IsLocal( entry.VictimId ) ? "local" : "")">@entry.VictimName</label>
@if ( entry.Headshot )
{
<label class="headshot">HS</label>
}
</div>
}
</div>
</root>
@code
{
private const int MaxEntries = 5;
private const float EntryLifetime = 5f;
private const float FadeStart = 4f;
public static KillFeed Instance { get; private set; }
/// <summary>When true, layout fills a WorldPanel (VR); when false, uses ScreenPanel HUD offsets.</summary>
[Property] bool IsWorldPanel { get; set; }
private readonly List<KillFeedEntry> _entries = new();
private int _version;
private IEnumerable<KillFeedEntry> VisibleEntries =>
_entries.OrderByDescending( e => e.CreatedAt ).Take( MaxEntries );
public static void Add( Guid attackerId, Guid victimId, bool headshot, bool isSuicide, string weaponIconPath )
{
Instance?.AddEntry( attackerId, victimId, headshot, isSuicide, weaponIconPath );
}
public void AddEntry( Guid attackerId, Guid victimId, bool headshot, bool isSuicide, string weaponIconPath )
{
var attacker = NetworkManager.Instance?.GetPlayerSession( attackerId );
var victim = NetworkManager.Instance?.GetPlayerSession( victimId );
PushEntry(
attackerId,
victimId,
attacker?.DisplayName ?? "Unknown",
victim?.DisplayName ?? "Unknown",
headshot,
isSuicide,
weaponIconPath
);
}
/// <summary>Inserts a sample killfeed row (cycles kill / headshot / suicide).</summary>
[Button( "Add Test Entry" )]
public void AddTestEntry()
{
var localId = Connection.Local?.Id ?? Guid.Empty;
var variant = _version % 3;
switch ( variant )
{
case 0:
PushEntry( localId, Guid.NewGuid(), "TestKiller", "TestVictim", false, false, "/materials/m4a1.png" );
break;
case 1:
PushEntry( localId, Guid.NewGuid(), "TestKiller", "TestVictim", true, false, "/materials/usp.webp" );
break;
default:
PushEntry( localId, localId, "TestVictim", "TestVictim", false, true, "" );
break;
}
}
private void PushEntry( Guid killerId, Guid victimId, string killerName, string victimName, bool headshot, bool isSuicide, string weaponIconPath )
{
_entries.Insert( 0, new KillFeedEntry
{
KillerId = killerId,
VictimId = victimId,
KillerName = killerName,
VictimName = victimName,
Headshot = headshot,
IsSuicide = isSuicide,
WeaponIconPath = isSuicide ? "" : (weaponIconPath ?? ""),
CreatedAt = Time.Now
} );
while ( _entries.Count > MaxEntries )
_entries.RemoveAt( _entries.Count - 1 );
_version++;
StateHasChanged();
}
protected override void OnStart()
{
Instance = this;
}
protected override void OnDestroy()
{
if ( Instance == this )
Instance = null;
}
protected override void OnUpdate()
{
var now = Time.Now;
var removed = _entries.RemoveAll( e => now - e.CreatedAt >= EntryLifetime );
if ( removed > 0 )
_version++;
}
private static bool IsLocal( Guid connectionId ) =>
Connection.Local is not null && Connection.Local.Id == connectionId;
private static float EntryOpacity( KillFeedEntry entry )
{
var age = Time.Now - entry.CreatedAt;
if ( age < FadeStart )
return 1f;
return Math.Clamp( 1f - (age - FadeStart) / (EntryLifetime - FadeStart), 0f, 1f );
}
protected override int BuildHash()
{
var fadeTick = (int)(Time.Now * 10f);
return HashCode.Combine( _version, fadeTick, _entries.Count, IsWorldPanel );
}
private sealed class KillFeedEntry
{
public Guid KillerId { get; set; }
public Guid VictimId { get; set; }
public string KillerName { get; set; }
public string VictimName { get; set; }
public string WeaponIconPath { get; set; }
public bool Headshot { get; set; }
public bool IsSuicide { get; set; }
public float CreatedAt { get; set; }
}
}