UI/Player/Scoreboard.razor
@using Sandbox.UI
@inherits Panel
<style>
Scoreboard {
position: absolute;
width: 100%;
height: 100%;
align-items: flex-start;
justify-content: flex-start;
padding-top: 40px;
padding-left: 60px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
&.open {
opacity: 1;
}
> .panel {
flex-direction: column;
min-width: 620px;
background-color: rgba(30, 30, 30, 0.55);
> .title-bar {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px 26px;
> .title {
font-family: "Wallpoet";
font-size: 36px;
color: rgba(39, 181, 238, 1);
text-shadow: 0 0 14px rgba(39, 181, 238, 0.5);
}
> .subtitle {
font-family: "AzeretMono-Medium";
font-size: 20px;
color: rgba(39, 181, 238, 0.4);
}
}
> .col-headers {
flex-direction: row;
align-items: center;
padding: 6px 26px;
font-family: "AzeretMono-Medium";
font-size: 15px;
color: rgba(255, 255, 255, 0.3);
> .h-rank { width: 44px; }
> .h-name { flex-grow: 1; }
> .h-pts { width: 90px; text-align: right; }
> .h-k { width: 60px; text-align: right; }
> .h-d { width: 60px; text-align: right; }
> .h-ping { width: 76px; text-align: right; }
}
> .list {
flex-direction: column;
padding: 4px 0;
> .entry {
flex-direction: row;
align-items: center;
padding: 12px 26px;
font-family: "AzeretMono-Medium";
font-size: 24px;
color: rgba(255, 255, 255, 0.7);
&.local {
color: rgba(255, 255, 255, 0.95);
border-left: 3px solid rgba(39, 181, 238, 0.7);
padding-left: 23px;
}
&.bot {
color: rgba(255, 255, 255, 0.35);
}
> .rank {
width: 44px;
font-size: 20px;
color: rgba(255, 255, 255, 0.25);
&.r1 { color: rgba(255, 215, 0, 0.9); font-size: 24px; }
&.r2 { color: rgba(192, 192, 192, 0.85); }
&.r3 { color: rgba(180, 105, 55, 0.85); }
}
> .name-col {
flex-grow: 1;
flex-direction: row;
align-items: center;
> .avatar {
width: 28px;
height: 28px;
border-radius: 100px;
margin-right: 12px;
opacity: 0.75;
}
}
> .pts {
width: 90px;
text-align: right;
color: rgba(39, 181, 238, 0.9);
font-size: 28px;
font-weight: 700;
}
> .kills {
width: 60px;
text-align: right;
color: rgba(110, 220, 110, 0.75);
}
> .deaths {
width: 60px;
text-align: right;
color: rgba(220, 90, 90, 0.65);
}
> .ping {
width: 76px;
text-align: right;
font-size: 19px;
color: rgba(255, 255, 255, 0.25);
}
}
> .divider {
height: 1px;
background-color: rgba(255, 255, 255, 0.08);
margin: 6px 26px;
}
}
}
}
</style>
<root>
<div class="panel">
<div class="title-bar">
<label class="title">SCOREBOARD</label>
<label class="subtitle">@_playerCount PILOTS</label>
</div>
<div class="col-headers">
<label class="h-rank">#</label>
<label class="h-name">PILOT</label>
<label class="h-pts">PTS</label>
<label class="h-k">K</label>
<label class="h-d">D</label>
<label class="h-ping">PING</label>
</div>
<div class="list">
@{
var allEntries = _entries;
bool addedBotDivider = false;
int rank = 1;
}
@foreach ( var e in allEntries )
{
@if ( e.IsBot && !addedBotDivider && rank > 1 )
{
addedBotDivider = true;
<div class="divider"></div>
}
var rankClass = rank == 1 ? "r1" : rank == 2 ? "r2" : rank == 3 ? "r3" : "";
var isLocal = !e.IsBot && e.Conn == Connection.Local;
<div class="entry @(e.IsBot ? "bot" : "") @(isLocal ? "local" : "")">
<label class="rank @rankClass">@rank</label>
<div class="name-col">
@if ( !e.IsBot )
{
<img class="avatar" src=@($"avatar:{e.Conn.SteamId}") />
}
<label>@e.Name</label>
</div>
<label class="pts">@e.Score</label>
<label class="kills">@e.Kills</label>
<label class="deaths">@e.Deaths</label>
<label class="ping">@e.PingText</label>
</div>
rank++;
}
</div>
</div>
</root>
@code
{
private class ScoreEntry
{
public string Name;
public int Score, Kills, Deaths;
public string PingText;
public bool IsBot;
public Connection Conn;
}
private List<ScoreEntry> _entries = new();
private int _playerCount;
public override void Tick()
{
SetClass( "open", Input.Down( "Score" ) );
RebuildEntries();
}
protected override int BuildHash()
{
var hash = new HashCode();
hash.Add( Connection.All.Count() );
hash.Add( Input.Down( "Score" ) );
var pawns = Game.ActiveScene?.GetAllComponents<PlayerPawn>() ?? Enumerable.Empty<PlayerPawn>();
foreach ( var p in pawns.OrderBy( p => p.IsBot ).ThenBy( p => p.Network?.Owner?.DisplayName ?? p.BotName ) )
{
hash.Add( p.IsBot );
hash.Add( p.Network?.Owner?.DisplayName ?? p.BotName );
hash.Add( p.Score );
hash.Add( p.Kills );
hash.Add( p.Deaths );
hash.Add( p.IsAlive );
}
return hash.ToHashCode();
}
private void RebuildEntries()
{
var list = new List<ScoreEntry>();
var pawns = Game.ActiveScene?.GetAllComponents<PlayerPawn>() ?? Enumerable.Empty<PlayerPawn>();
// Build player rows from pawns first (authoritative source for score/kills/deaths),
// then fall back to bare connections that don't have a pawn yet.
foreach ( var pawn in pawns.Where( p => p != null && !p.IsBot && p.Network?.Owner != null ) )
{
var conn = pawn.Network.Owner;
list.Add( new ScoreEntry
{
Name = conn.DisplayName,
Score = pawn.Score,
Kills = pawn.Kills,
Deaths = pawn.Deaths,
PingText = $"{conn.Ping}ms",
IsBot = false,
Conn = conn
} );
}
foreach ( var conn in Connection.All )
{
if ( list.Any( e => e.Conn == conn ) ) continue;
list.Add( new ScoreEntry
{
Name = conn.DisplayName,
Score = 0,
Kills = 0,
Deaths = 0,
PingText = $"{conn.Ping}ms",
IsBot = false,
Conn = conn
} );
}
var bots = pawns.Where( p => p.IsBot );
foreach ( var bot in bots )
{
list.Add( new ScoreEntry
{
Name = bot.BotName?.Length > 0 ? bot.BotName : "Bot",
Score = bot.Score,
Kills = bot.Kills,
Deaths = bot.Deaths,
PingText = "BOT",
IsBot = true,
Conn = null
} );
}
// Players sorted by score first, then bots sorted by score
_entries = list
.OrderBy( e => e.IsBot ? 1 : 0 )
.ThenByDescending( e => e.Score )
.ToList();
_playerCount = list.Count;
}
}