ui/LeaderboardPanel.razor

A Razor UI panel for the game's leaderboard. It renders dropdowns and a scrollable list of leaderboard entries, formats entry visuals (rank colors, avatars, values), handles input scrolling, refreshes leaderboard data from Sandbox.Services.Leaderboards, and opens run details for a selected entry.

NetworkingFile Access
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Text.Json;
@inherits Panel
@attribute [StyleSheet("LeaderboardPanel.razor.scss")]

<root>
	<div class="header_container">
		@* <div class="@(GetTitleClass(Manager.Instance.Difficulty))"></div> *@

		<div class="dropdown_container">
			<DropDown class="leaderboard_mode_dropdown" Value:bind=@LeaderboardModeValue Options=@LeaderboardModeOptions></DropDown>
			<DropDown class="date_range_dropdown" Value:bind=@DateRangeValue Options=@DateRangeOptions></DropDown>
			<DropDown class="friends_only_dropdown" Value:bind=@ShowFriendsValue Options=@ShowFriendsOptions></DropDown>
		</div>

		<div class="button_container">
			@if(LeaderboardModeValue == LeaderboardMode.FastestWin || LeaderboardModeValue == LeaderboardMode.LongestSurvival)
			{
				<button class="toggle_button players_button @(GetNumPlayersButtonClass())" onclick=@(() => ClickNumPlayersButton())></button>
			}
		</div>
	</div>
	
	<div class="entries_container" style="height: @((IsOnMainMenu ? 900 : 780))px;">
		<div class="entries">
			@{
				if(Leaderboard is null || IsLoading)
				{
					<div class="loading">Loading...</div>
					return;
				}

				if(_cachedEntries.Count <= 0)
				{
					<div class="loading">No entries.</div>
					return;
				}

				var entries = _cachedEntries;
				var entryHeight = 42;
				var index = 0;
			}

			@for(int i = StartIndex; i < Math.Min(StartIndex + MaxVisible, entries.Count); i++)
			{
				var entry = entries[i];

				@{
					var isVictory = entry.Value > Manager.VICTORY_OFFSET / 2f;
					var isBossDefeat = LeaderboardModeValue == LeaderboardMode.FastestWin && !isVictory && entry.Value >= Manager.BOSS_DEFEAT_OFFSET;

					var rankT = Utils.Map(i + 1, 0, 40, 0f, 1f, EasingType.QuadOut);
					var rank = i + 1;
					Color nameColor;
					if ( rank == 1 )      nameColor = new Color(1f, 0.84f, 0f);
					else if ( rank == 2 ) nameColor = new Color(0.75f, 0.75f, 0.85f);
					else if ( rank == 3 ) nameColor = new Color(0.8f, 0.5f, 0.2f);
					else                  nameColor = Color.Lerp(new Color(0.95f, 0.95f, 0.95f), new Color(0.7f, 0.7f, 0.7f), rankT);
					var valueColor = isVictory
						? Color.White.Rgba
						: (isBossDefeat ? new Color(0.8f, 0.47f, 0.27f).Rgba : new Color(0.55f, 0.25f, 0.25f).Rgba);

					var bgColorStr = index % 2 == 0 ? "background-color:#00000077;" : "";
				}

				@* <div class="entry" style="height: @(entryHeight)px; color:@(color.Rgba); @(bgColorStr)" onclick=@(() => ShowRunData(entry))> *@
				<div class="entry" style="height: @(entryHeight)px; color:@(nameColor.Rgba); @(bgColorStr)">
					<label class="rank">##@(rank)</label>

					@if(entry.SteamId != 0)
					{
						LeaderboardPlayerIcon.LeaderboardIconType iconType = LeaderboardPlayerIcon.LeaderboardIconType.None;
						if( LeaderboardModeValue == LeaderboardMode.FastestWin )
							iconType = isVictory? LeaderboardPlayerIcon.LeaderboardIconType.Victory : LeaderboardPlayerIcon.LeaderboardIconType.Defeat;

						var playerLevel = entry.Data != null ? Utils.GetIntFromDictionary(entry.Data, "player_level", -1) : -1;
						int numPlayers = entry.Data != null ? Utils.GetIntFromDictionary(entry.Data, "num_players", 1) : 1;

						<div class="avatar_container">
							<LeaderboardPlayerIcon PlayerInfo=@(new PlayerInfo(entry.DisplayName, entry.SteamId, playerLevel)) ShowTooltip=@(numPlayers > 1) Icon=@iconType />

							@if(numPlayers > 1 && LeaderboardModeValue == LeaderboardMode.FastestWin)
							{
								long otherPlayerId0 = Utils.GetLongFromDictionary(entry.Data, "other_player_id_0", 0);
								long otherPlayerId1 = Utils.GetLongFromDictionary(entry.Data, "other_player_id_1", 0);
								
								@if(otherPlayerId0 > 0)
								{
									string otherPlayerName0 = Utils.GetStringFromDictionary(entry.Data, "other_player_name_0", "");
									int otherPlayerLevel0 = Utils.GetIntFromDictionary(entry.Data, "other_player_level_0", -1);

									<LeaderboardPlayerIcon PlayerInfo=@(new PlayerInfo( otherPlayerName0, otherPlayerId0, otherPlayerLevel0 )) IsSmall=@(true) ShowTooltip=@true />
								}

								@if(otherPlayerId1 > 0)
								{
									string otherPlayerName1 = Utils.GetStringFromDictionary(entry.Data, "other_player_name_1", "");
									int otherPlayerLevel1 = Utils.GetIntFromDictionary(entry.Data, "other_player_level_1", -1);

									<LeaderboardPlayerIcon PlayerInfo=@(new PlayerInfo( otherPlayerName1, otherPlayerId1, otherPlayerLevel1 )) IsSmall=@(true) ShowTooltip=@true />
								}
							}
						</div>
					}

					<label class="name">@entry.DisplayName</label>

					@if(false && entry.Data != null) // disabled for now
					{
						@if(!isVictory && entry.Data.ContainsKey("boss_life_removed_pct"))
						{
							var bossLifePct = Utils.GetFloatFromDictionary(entry.Data, "boss_life_removed_pct");
							<label class="value">Boss: @(bossLifePct.ToString("0"))%</label>
						}
						else
						{
							<label class="value">@Utils.FormatTime(Utils.GetFloatFromDictionary(entry.Data, "run_time"))</label>
						}

						@* LogData(entry.Data, (float)entry.Value, entry.DisplayName); *@

						if(entry.Data.TryGetValue("important_perks", out var importantPerksObj))
						{
							Dictionary<int, int> importantPerks = Utils.JsonObjectToIntDictionary(importantPerksObj);
							@* Log.Info($"importantPerks: {importantPerks.Count}"); *@

							<div class="perks" style="width: @(entryHeight * 3)px;">
								@foreach(var pair in importantPerks)
								{
									var perkType = PerkManager.IdentityToType(pair.Key);
									var perkLevel = pair.Value;

									<PerkIconStatic style="height: 100%;" PerkType=@perkType Level=@perkLevel />
								}
							</div>

							@* <div class="view_button" onclick=@(() => ShowRunData(entry))>visibility</div> *@
						}
					}
					else
					{
						if(LeaderboardModeValue == LeaderboardMode.FastestWin)
						{
							if ( isBossDefeat )
							{
								var bossLifePct = ((float)entry.Value - Manager.BOSS_DEFEAT_OFFSET) / 100f;
								<label class="value" style="color:@valueColor;">Boss: @(bossLifePct.ToString("0"))%</label>
							}
							else
							{
								var runTime = isVictory
									? Manager.VICTORY_OFFSET - entry.Value
									: entry.Value;

								bool showMs = (i > 0 && Utils.HasSameMinutesAndSeconds(entry.Value, entries[i - 1].Value)) || (i < entries.Count() - 1 && Utils.HasSameMinutesAndSeconds(entry.Value, entries[i + 1].Value));

								<div class="value_container">
									<label class="value" style="color:@valueColor;">
										@Utils.FormatTime(runTime)
									</label>

									@if(showMs)
									{
										<span class="milliseconds">@Utils.GetMillisecondsString(runTime)</span>
									}
								</div>
							}
						}
						else if(LeaderboardModeValue == LeaderboardMode.LongestSurvival)
						{
							<label class="value">@Utils.FormatTime((float)entry.Value)</label>
						}
						else
						{
							<label class="value">@entry.Value.ToString("N0")</label>
						}
					}
				</div>

				@{
					index++;
				}
			}
			
		</div>

		@{
			if(_cachedEntries.Count > MaxVisible)
			{
				var scrollT = StartIndex / (float)(_cachedEntries.Count - MaxVisible);
				var containerH = IsOnMainMenu ? 900f : 780f;
				var barH = (MaxVisible / (float)_cachedEntries.Count) * containerH;
				var barTop = scrollT * (containerH - barH);
				<div class="scrollbar" style="top:@(barTop)px; height:@(barH)px;"></div>
			}
		}
	</div>
</root>

@code
{
	public int Difficulty { get; set; }
	private int _lastDifficulty;

	public bool IsSurvival { get; set; }

	Sandbox.Services.Leaderboards.Board2 Leaderboard;

	public bool IsLoading { get; set; }

	public string LoadingText { get; set; } = "";

	public bool IsOnMainMenu { get; set; }

	List<Sandbox.Services.Leaderboards.Board2.Entry> _cachedEntries = new();
	static int StartIndex = 0;
	static float _scrollPos = 0f;
	static float _scrollVelocity = 0f;
	int MaxVisible => IsOnMainMenu ? 21 : 18;

	public override void OnMouseWheel( Vector2 value )
	{
		base.OnMouseWheel( value.y );
		_scrollVelocity = Math.Clamp( _scrollVelocity + value.y * 1.5f, -15f, 15f );
	}

	public override void Tick()
	{
		base.Tick();

		if ( MathF.Abs( _scrollVelocity ) < 0.05f )
		{
			_scrollVelocity = 0;
			return;
		}

		var maxPos = Math.Max( 0, _cachedEntries.Count - MaxVisible );
		_scrollPos = Math.Clamp( _scrollPos + _scrollVelocity, 0, maxPos );
		_scrollVelocity *= 0.85f;

		var newIndex = (int)_scrollPos;
		if ( newIndex != StartIndex )
		{
			StartIndex = newIndex;
			StateHasChanged();
		}
	}

	protected override void OnAfterTreeRender(bool firstTime)
	{
		base.OnAfterTreeRender(firstTime);

		if(firstTime || Difficulty != _lastDifficulty)
		{
			Refresh();

			_lastDifficulty = Difficulty;
			// _lastLeaderboardName = LeaderboardName;
		}
	}

	async void Refresh()
	{
		var statType = GetStatTypeForLeaderboardMode(LeaderboardModeValue);
		var statName = Manager.GetStatString(statType, numPlayers: Manager.Instance.NumPlayersLeaderboardToShow, difficulty: Difficulty);

		Leaderboard = Sandbox.Services.Leaderboards.GetFromStat(statName);

		if(LeaderboardModeValue == LeaderboardMode.FastestWin)
		{
			Leaderboard.SetAggregationMax();
			Leaderboard.SetSortDescending();
		}
		else if(LeaderboardModeValue == LeaderboardMode.LongestSurvival)
		{
			Leaderboard.SetAggregationMax();
			Leaderboard.SetSortDescending();
		}
		else if(LeaderboardModeValue == LeaderboardMode.MostEnemyKills || LeaderboardModeValue == LeaderboardMode.MostMinibossKills)
		{
			Leaderboard.SetAggregationSum();
			Leaderboard.SetSortDescending();
		}

		// Leaderboard.CenterOnMe();
		// Leaderboard.Offset = 0;

		switch(Manager.Instance.DateRangeToShow)
		{
			case DateRangeMode.All: Leaderboard.FilterByNone(); break;
			case DateRangeMode.Day: Leaderboard.FilterByDay(); break;
			case DateRangeMode.Week: Leaderboard.FilterByWeek(); break;
			case DateRangeMode.Month: Leaderboard.FilterByMonth(); break;
		}

		IsLoading = true;
		StartIndex = 0;
		_scrollPos = 0;
		_scrollVelocity = 0;

		Leaderboard.SetFriendsOnly(Manager.Instance.LeaderboardShowFriends);

		Leaderboard.MaxEntries = 1000;
		await Leaderboard.Refresh();

		_cachedEntries = Leaderboard.Entries
			.Where( e => !Manager.HiddenLeaderboardSteamIds.Contains( e.SteamId ) )
			.Where( e => !Manager.HiddenLeaderboardEntries.Any( h =>
				h.SteamId == e.SteamId && h.Difficulty == Difficulty &&
				e.Value >= h.ValueMin && e.Value <= h.ValueMax ) )
			.ToList();
		StateHasChanged();

		IsLoading = false;

		// Log.Info($"Refreshed leaderboard with {Leaderboard.Entries.Count()} entries.");
	}

	protected override int BuildHash()
	{
		return System.HashCode.Combine(
			Manager.Instance.NumPlayersLeaderboardToShow,
			Manager.Instance.DateRangeToShow,
			Manager.Instance.LeaderboardModeToShow,
			Manager.Instance.LeaderboardShowFriends,
			StartIndex
		);
	}

	public void ShowRunData( Sandbox.Services.Leaderboards.Board2.Entry entry )
	{
		if( entry.Data == null)
		{
			Manager.Instance.PlaySfxUI("error2", pitch: 0.5f, volume: 0.95f);
			return;
		}

		var localTime = entry.Timestamp.ToLocalTime();
		string dateString = $"{localTime.Year}/{localTime.Month}/{localTime.Day}";

		Manager.Instance.RunEntryToShow = new RunInfo(entry.DisplayName, entry.SteamId, (float)entry.Value, dateString, entry.Data);
	}

	private void LogData(Dictionary<string, object> data, float value, string name)
	{
		int version = -1;
		if( data.TryGetValue("data_version", out var dataVersionObj) )
			version = ((JsonElement)dataVersionObj).GetInt32();

		var sb = new System.Text.StringBuilder();
		sb.AppendLine($"---- entry ---- time: {value} name: {name} version: {version} data({data.Count}):");

		if(data.TryGetValue("important_perks", out var importantPerksObj))
		{
			Dictionary<int, int> importantPerks = Utils.JsonObjectToIntDictionary(importantPerksObj);
			sb.AppendLine($"important_perks ({importantPerks.Count}):");

			foreach(var pair in importantPerks)
			{
				var perkType = PerkManager.IdentityToType(pair.Key);
				var perkLevel = pair.Value;
				sb.AppendLine($"  {perkType.Name} (id:{pair.Key}): level {perkLevel}");
			}
		}

		if(data.TryGetValue("perks", out var perksObj))
		{
			Dictionary<int, int> perks = Utils.JsonObjectToIntDictionary(perksObj);
			sb.AppendLine($"perks ({perks.Count}):");

			foreach(var pair in perks)
			{
				var perkType = PerkManager.IdentityToType(pair.Key);
				var perkLevel = pair.Value;
				sb.AppendLine($"  {perkType.Name} (id:{pair.Key}): level {perkLevel}");
			}
		}

		Log.Info(sb.ToString());
	}

	string GetTitleClass( int difficulty )
	{
		switch(difficulty)
		{
			case 0: default: return "title_normal";
			case 1: return "title_expert";
			case 2: return "title_cursed";
		}
	}

	void ClickNumPlayersButton()
	{
		Manager.Instance.NumPlayersLeaderboardToShow++;

		if(Manager.Instance.NumPlayersLeaderboardToShow > Manager.MAX_PLAYERS)
		Manager.Instance.NumPlayersLeaderboardToShow = 1;

		Refresh();

		Manager.Instance.SkipShowingLeaderboardFrames = 1;
	}

	string GetNumPlayersButtonClass()
	{
		switch(Manager.Instance.NumPlayersLeaderboardToShow)
		{
			case 1: default: return "num_players_one";
			case 2: return "num_players_two";
			case 3: return "num_players_three";
		}
	}

	List<Option> LeaderboardModeOptions = new List<Option>()
	{
		new Option("Fastest Victory", LeaderboardMode.FastestWin),
		new Option("Longest Survival", LeaderboardMode.LongestSurvival),
		new Option("Total Victories", LeaderboardMode.MostWins),
		new Option("Total Attempts", LeaderboardMode.MostRuns),
		new Option("Total Enemy Kills", LeaderboardMode.MostEnemyKills),
		new Option("Total Miniboss Kills", LeaderboardMode.MostMinibossKills),
	};

	StatType GetStatTypeForLeaderboardMode( LeaderboardMode mode )
	{
		switch(mode)
		{
			case LeaderboardMode.FastestWin: default: return StatType.LeaderboardRun;
			case LeaderboardMode.MostWins: return StatType.NumVictory;
			case LeaderboardMode.MostRuns: return StatType.NumRuns;
			case LeaderboardMode.MostEnemyKills: return StatType.NumKills;
			case LeaderboardMode.MostMinibossKills: return StatType.NumMinibossKills;
			case LeaderboardMode.LongestSurvival: return StatType.SurvivalTime;
		}
	}

	LeaderboardMode LeaderboardModeValue
	{
		get => Manager.Instance.LeaderboardModeToShow;
		set
		{
			if (Manager.Instance.LeaderboardModeToShow != value)
			{
				Manager.Instance.LeaderboardModeToShow = value;
				Refresh();
				Manager.Instance.SkipShowingLeaderboardFrames = 1;
			}
		}
	}

	List<Option> DateRangeOptions = new List<Option>()
	{
		new Option("All Time", DateRangeMode.All),
		new Option("Today", DateRangeMode.Day),
		new Option("This Week", DateRangeMode.Week),
		new Option("This Month", DateRangeMode.Month),
	};

	DateRangeMode DateRangeValue
	{
		get => Manager.Instance.DateRangeToShow;
		set
		{
			if (Manager.Instance.DateRangeToShow != value)
			{
				Manager.Instance.DateRangeToShow = value;
				Refresh();
				Manager.Instance.SkipShowingLeaderboardFrames = 1;
			}
		}
	}

	List<Option> ShowFriendsOptions = new List<Option>()
	{
		new Option("Global", false),
		new Option("Friends", true),
	};

	bool ShowFriendsValue
	{
		get => Manager.Instance.LeaderboardShowFriends;
		set
		{
			if (Manager.Instance.LeaderboardShowFriends != value)
			{
				Manager.Instance.LeaderboardShowFriends = value;
				GameSettingsSystem.Save();
				Refresh();
				Manager.Instance.SkipShowingLeaderboardFrames = 1;
			}
		}
	}
}