A Razor UI component for the in-game scoreboard and map vote. It renders player rows, match timer and a 3-option map voting panel, pulls package metadata (title and thumbnail) asynchronously, and caches results.
@using System;
@using System.Collections.Generic;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox
<root class="@(IsWorldPanel ? "full" : "")">
<div class="container @(IsWorldPanel ? "full" : "")">
<div class="header">
<div class="match-info">
<label class="state">@CountdownLabel</label>
<label class="timer">@TimeLeft</label>
</div>
<div class="columns">
<label class="name">Player</label>
<label class="score">Kills</label>
<label class="score">Deaths</label>
<label class="ping">Ping</label>
</div>
</div>
<div class="players">
@foreach (var player in SortedPlayers)
{
<ScoreboardRow Player="@player" />
}
</div>
@if (ShowMapVote)
{
<div class="map-vote">
<label class="map-vote-title">Vote for next map</label>
<div class="map-vote-options">
@for (var i = 0; i < 3; i++)
{
var index = i;
var ident = DeathmatchGame.Instance.GetVoteOption(index);
if (string.IsNullOrEmpty(ident))
continue;
var selected = LocalMapVote == index;
var info = GetMapInfo(ident);
<div class="map-card @(selected ? "selected" : "")" onclick=@(() => CastVote(index))>
@if (!string.IsNullOrEmpty(info.Thumb))
{
<img class="map-thumb" src="@info.Thumb" />
}
else
{
<div class="map-thumb placeholder"></div>
}
<label class="map-name">@info.Title</label>
<label class="map-votes">@DeathmatchGame.Instance.GetVoteCount(index)</label>
</div>
}
</div>
</div>
}
</div>
</root>
@code
{
private readonly Dictionary<string, MapPackageInfo> _mapInfo = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _fetching = new(StringComparer.OrdinalIgnoreCase);
private IEnumerable<PlayerSession> SortedPlayers => NetworkManager.Instance.PlayerSessions.OrderByDescending(p => p.Kills);
private string CountdownLabel => DeathmatchGame.Instance.State == MatchState.IN_PROGRESS ? "Time left" : "Next match starts in";
[Property] bool IsWorldPanel { get; set; }
private bool ShowMapVote =>
DeathmatchGame.Instance.State == MatchState.ENDED
&& !string.IsNullOrEmpty(DeathmatchGame.Instance.VoteOption0);
private int LocalMapVote => PlayerSession.CurrentSession.IsValid()
? PlayerSession.CurrentSession.MapVote
: -1;
private string TimeLeft => FormatTime(
DeathmatchGame.Instance.State == MatchState.IN_PROGRESS
? DeathmatchGame.Instance.TimeLeftToEnd
: DeathmatchGame.Instance.TimeLeftToStart
);
private string FormatTime(float seconds) => $"{(int)(seconds / 60):00}:{(int)(seconds % 60):00}";
private void CastVote(int index)
{
var session = PlayerSession.CurrentSession;
if (!session.IsValid() || session.IsProxy)
return;
if (string.IsNullOrEmpty(DeathmatchGame.Instance.GetVoteOption(index)))
return;
session.MapVote = index;
}
private MapPackageInfo GetMapInfo(string ident)
{
if (_mapInfo.TryGetValue(ident, out var info))
return info;
info = new MapPackageInfo { Title = ident };
_mapInfo[ident] = info;
FetchMapInfo(ident);
return info;
}
private async void FetchMapInfo(string ident)
{
if (!_fetching.Add(ident))
return;
try
{
var package = await Package.FetchAsync(ident, false);
if (package is null)
return;
_mapInfo[ident] = new MapPackageInfo
{
Title = string.IsNullOrWhiteSpace(package.Title) ? ident : package.Title,
Thumb = package.Thumb
};
StateHasChanged();
}
finally
{
_fetching.Remove(ident);
}
}
private struct MapPackageInfo
{
public string Title;
public string Thumb;
}
/// <summary>
/// the hash determines if the system should be rebuilt. If it changes, it will be rebuilt
/// </summary>
protected override int BuildHash()
{
var hash = new System.HashCode();
hash.Add(IsWorldPanel);
hash.Add(SortedPlayers.Count());
hash.Add(DeathmatchGame.Instance.State);
hash.Add(TimeLeft);
hash.Add(CountdownLabel);
hash.Add(DeathmatchGame.Instance.VoteOption0);
hash.Add(DeathmatchGame.Instance.VoteOption1);
hash.Add(DeathmatchGame.Instance.VoteOption2);
hash.Add(LocalMapVote);
hash.Add(DeathmatchGame.Instance.GetVoteCount(0));
hash.Add(DeathmatchGame.Instance.GetVoteCount(1));
hash.Add(DeathmatchGame.Instance.GetVoteCount(2));
foreach (var player in SortedPlayers)
{
hash.Add(player?.Network.OwnerId);
hash.Add(player?.MapVote);
}
foreach (var info in _mapInfo.Values)
{
hash.Add(info.Title);
hash.Add(info.Thumb);
}
return hash.ToHashCode();
}
}