UI/Scoreboard.razor

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.

NetworkingExternal Download
@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();
	}
}