UI/HighscoreScreen.razor
@using Sandbox
@using Sandbox.UI
@using System.Linq
@using System.Collections.Generic
@using System.Threading.Tasks
@inherits VirtualListPanel
@namespace BlockParty
@attribute [StyleSheet( "HighscoreScreen.razor.scss" )]
<root style="opacity: @(Stage?.FadeAlpha ?? 0f)">
@{ if ( Stage is null ) return; }
<PixelTooltip @ref="Tooltip"></PixelTooltip>
@* Title. A workshop board is pinned to one level, so it names that level (and credits the author)
instead. Every other board can change its level from the picker below, so it keeps the generic
title and the name label over the preview. *@
@if ( WorkshopMode )
{
<div class="level-header">
<label class="level-title">@(CurrentLevel?.Name ?? "")</label>
@if ( !string.IsNullOrEmpty( AuthorName ) )
{
@* Author credit line: their Steam avatar then the name, both linking to their workshop levels. *@
<div class="level-author-row">
@if ( AuthorSteamId != 0 )
{
<SteamAvatar SteamId=@AuthorSteamId Size=@(28) Url=@AuthorWorkshopUrl OnHover=@Tip( "Author's Workshop levels" )></SteamAvatar>
}
@if ( AuthorWorkshopUrl is string authorUrl )
{
@* The name is a web link to the author's s&box workshop items. Only a rich label's
<a href> opens a browser (see WebLinkLabel), so the name IS the anchor text; the
wrapper carries the class/tooltip and stays visually bare (see WorkshopScreen).
Rich text drops text-shadow (spans are styled from snapshots that never carry the
effect), so the outline is a plain twin label offset underneath — same text, same
metrics, drawn in the shadow colour. *@
<div class="level-author link" onmouseover=@Tip( "Author's Workshop levels" )>
<LiteralLabel class="shadow" Text=@AuthorName />
<WebLinkLabel IsRich=@true Text=@AuthorLinkHtml( authorUrl )></WebLinkLabel>
</div>
}
else
{
<LiteralLabel class="level-author" Text=@AuthorName />
}
</div>
}
@if ( !string.IsNullOrEmpty( Description ) )
{
<label class="level-description">@Description</label>
}
</div>
}
else
{
<label class="header">HIGH SCORES</label>
}
@* Board selectors. The level and character choose the stat; scope is persisted in GameSettings.
Workshop boards are identical except the level is PINNED (the level picker is map-only, so the
preview renders static there); character filter and scope work the same on both. *@
<div class="controls">
<div class="control level-control">
@if ( WorkshopMode )
{
<div class="level-picker static">
@* Name lives in the screen title here, but the label stays in the layout (invisible)
so the preview keeps its height alignment with the character filter. *@
<div class="level-name hidden"><label>@(CurrentLevel?.Name ?? "")</label></div>
@* The level's Steam Workshop page, beside the preview (host-owned wrapper: the
component's root sets its own classes, so a class on its tag wouldn't stick). *@
@if ( WorkshopItemUrl is string workshopUrl )
{
<div class="workshop-link">
<WorkshopLinkButton Url=@workshopUrl OnHover=@Tip( "Steam Workshop page" )></WorkshopLinkButton>
</div>
}
<div class="level-face">
<LevelNodePreview Level=@CurrentLevel></LevelNodePreview>
@if ( Characters.TryGet( CurrentLevel?.ForcedCharacterId, out var wsForced ) )
{
<div class="tile-forced-character @(wsForced.Partner is null ? "" : "paired")" style="@ForcedCharacterStyle( wsForced )">
<div class="icon-glow"></div>
@if ( wsForced.Partner is CharacterDef wsPartner )
{
<div class="sprite rear" style="background-image:url( @wsPartner.PreviewImage );"></div>
}
<div class="sprite front" style="background-image:url( @wsForced.PreviewImage );"></div>
</div>
}
</div>
</div>
}
else
{
<button class="level-picker" onclick=@OpenLevelBrowser>
<div class="level-name"><label>@(CurrentLevel?.Name ?? "")</label></div>
<div class="level-face">
<LevelNodePreview Level=@CurrentLevel></LevelNodePreview>
@if ( Characters.TryGet( CurrentLevel?.ForcedCharacterId, out var forced ) )
{
<div class="tile-forced-character @(forced.Partner is null ? "" : "paired")" style="@ForcedCharacterStyle( forced )">
<div class="icon-glow"></div>
@if ( forced.Partner is CharacterDef forcedPartner )
{
<div class="sprite rear" style="background-image:url( @forcedPartner.PreviewImage );"></div>
}
<div class="sprite front" style="background-image:url( @forced.PreviewImage );"></div>
</div>
}
</div>
</button>
}
</div>
<div class="control character-control @(_characterPickerOpen ? "open" : "")">
<CharacterPicker LeaderboardMode=@true CenterOverlayOnScreen=@true
Title="WHO DO YOU WANT TO SEE?" GetSelectedId=@GetLeaderboardCharacterId
OnPick=@OnCharacterSelected OnClear=@ClearCharacterFilter
OnOpenChanged=@OnCharacterPickerOpenChanged TriggerTip=@Tip( CharacterFilterTip )
ClearTip=@Tip( "Show all characters" ) @ref="CharacterFilterPicker"></CharacterPicker>
</div>
<div class="control">
<PixelDropdown Options=@_scopeOptions Selected=@((int)Settings.Current.LeaderboardScope) OnSelected=@OnScopeSelected></PixelDropdown>
</div>
</div>
<div class="list">
@{
// The visible window (FirstVisible..LastVisible) and per-row placement come from
// VirtualListPanel; see there for the fixed-row-height virtualization.
int first = FirstVisible;
int last = LastVisible;
// Reveal milliseconds on adjacent rows with matching mm:ss, so those runs can
// be told apart. Computed over the whole list (see ComputeMsRows).
var msRows = ComputeMsRows();
}
@if ( _loading )
{
<label class="info">LOADING...</label>
}
else if ( _rows.Count == 0 )
{
<label class="info">NO ENTRIES YET</label>
}
else
{
@for ( int i = first; i < last; i++ )
{
var row = _rows[i];
var rank = i + 1;
var idx = i; // capture for the per-row hover/click handlers
var top = RowTop( i );
bool showMs = msRows.Contains( i );
// The row content (rank / avatar / name / time / character / block tally / score /
// replay) is the shared LeaderboardRow component; this outer div only positions the
// row and carries the zebra/selected background + hover-to-select. Debug rows pass
// their time inline (real rows leave it NaN and resolve it from the data payload).
<div class="row @(i % 2 == 1 ? "even" : "") @(i == Selected ? "selected" : "")" style="top: @(top)px;"
onmouseover=@(() => SelectRow( idx )) onclick=@(() => SelectRow( idx ))>
<LeaderboardRow Rank=@rank ShowRankMedal=@ShowMapMedals [email protected] [email protected] [email protected]
[email protected] [email protected] [email protected]
[email protected] ShowMs=@showMs DimNonVictoryScore=@true
LeaderboardBackToLevelSelect=@(Stage?.BackToLevelSelect ?? false)
ReturnWorkshopLevelId=@(Stage?.WorkshopLevelId)
ReturnScore=@(Stage?.ReturnScore)
AwardTip=@StaleTipFactory WatchTip=@Tip( "Watch replay" )
StaleTip=@StaleTipFactory
ProfileTip=@Tip( "Steam profile" )></LeaderboardRow>
</div>
}
@if ( ShowScrollbar )
{
<div class="scrollbar" style="top: @(ThumbTop)px; height: @(ThumbHeight)px;"></div>
}
// Smiling-cube selector. Slides between rows and sits in the left lane; Space replays
// the selected run, W/S move the selection. Hidden while the BACK button is focused
// (its own cube appears in the footer instead).
@if ( !BackSelected )
{
<div class="selector" style="top: @(SelectorTop)px; background-position: @(-SelectorFrame * 100)% 0%;"></div>
}
}
</div>
@* <label class="hint" style="color:white">SPACE: WATCH REPLAY · W/S: SELECT · ESC: BACK</label> *@
<div class="footer">
@* The BACK button is the BACK pseudo-item in the W/S cycle; when focused it takes the
selector cube (same sheet as the list cube) and a highlight. The cube keeps a fixed lane
(hidden, not removed) so the button doesn't shift as focus comes and goes. *@
<div class="selector @(BackSelected ? "" : "hidden")" style="background-position: @(-SelectorFrame * 100)% 0%;"></div>
<button class="back @(BackSelected ? "selected" : "")" onmouseover=@SelectBack onclick=@OnBack>BACK</button>
</div>
@if ( LevelBrowserOpen )
{
<LevelBrowserOverlay Mode=@(LevelBrowserOverlay.PickerMode.Leaderboard) OnPick=@OnLevelSelected
OnClose=@OnLevelBrowserClosed @ref="LevelBrowser"></LevelBrowserOverlay>
}
</root>
@code
{
public HighscoreStage Stage { get; set; }
// Toggle: when true the screen renders synthetic debug rows instead of the live leaderboard,
// so the UI can be iterated on without a published board. Set false to use the real board.
// (static readonly, not const, so flipping it doesn't make the other branch unreachable — a const
// bool would trigger CS0162 "unreachable code" on whichever path is currently compiled out.)
private static readonly bool DEBUG_ROWS = false;
// DEBUG: duplicate the live board this many times (1 = no change). Lets a single real entry
// stand in for a long list to exercise scrolling/selection. Ignored when DEBUG_ROWS is true.
// static readonly (not const) so the "> 1" test stays a runtime check, not compile-time-dead.
private static readonly int DEBUG_DUPLICATE = 1;
// ── hover tooltip for the rows' WATCH buttons ────────────────────────────────────────────────────
// The layer belongs to this SCREEN, not to LeaderboardRow: a per-row layer would be clipped by the
// list's scrolling container. Rows get the handler through their WatchTip parameter. Mechanism (and
// why the poll validates the stored panel rather than using mouseout) documented in LevelEditorHud.
// Also feeds the character-filter tips (CharacterPicker's TriggerTip/ClearTip).
private PixelTooltip Tooltip { get; set; }
private Panel _tipPanel;
private System.Func<string> _tipText;
/// <summary>Fixed tooltip text.</summary>
private System.Action<PanelEvent> Tip( string text )
=> e => { _tipPanel = e.This; _tipText = () => text; };
// Explicitly typed: the engine's razor generator can't infer a lambda's delegate type inside an attribute.
private System.Func<string, System.Action<PanelEvent>> StaleTipFactory => reason => Tip( reason );
/// <summary>Tooltip text resolved per frame, for tips that change while the control stays hovered
/// (picking a filter rebuilds the markup without re-firing mouseover, so a snapshot would go stale).</summary>
private System.Action<PanelEvent> Tip( System.Func<string> text )
=> e => { _tipPanel = e.This; _tipText = text; };
// Character-filter trigger: names the active filter, since the trigger sprite alone doesn't say
// whether the board is filtered or showing everyone.
private string CharacterFilterTip()
=> Characters.TryGet( LeaderboardCharacterId, out var character )
? $"Only showing {SentenceCase( character.Name )} runs"
: "Filter by character";
// Character names are authored SHOUTING; the tooltip reads as a sentence, so soften them to Witch.
private static string SentenceCase( string name )
=> string.IsNullOrEmpty( name )
? name
: string.Join( " ", name.Split( ' ', System.StringSplitOptions.RemoveEmptyEntries )
.Select( word => char.ToUpperInvariant( word[0] ) + word[1..].ToLowerInvariant() ) );
protected override void OnUpdate()
{
base.OnUpdate();
string tip = _tipPanel.IsValid() && _tipPanel.HasHovered ? _tipText?.Invoke() : null;
if ( !string.IsNullOrEmpty( tip ) ) Tooltip?.Show( tip );
else Tooltip?.Hide();
PruneInconsistentRows();
}
// --- board payload pruning (anti-tamper, display side) ------------------------------------------
// Board writes are client-authoritative, so a patched client can put any value on a level board —
// and unlike the dailies (whose boards are day-filtered server-side) an all-time board keeps it
// forever. Rows are hidden when their (lazily fetched) payload contradicts the entry: wrong level /
// character / a daily run, or a value that doesn't match the payload's own score + tiebreaker.
// Checks the visible window only — exactly the rows whose payloads the shared LeaderboardRow already
// fetches for its time column — so it costs no extra requests; an off-screen bad row is pruned when
// scrolled into view. A null payload (still loading, or fetch failed) is kept: it's indistinguishable
// from a network failure, and honest rows must not vanish offline. Payload-LESS entries (no DataUrl
// at all) are dropped in Refresh instead. Same mechanism as DailyChallengeScreen's prune.
private void PruneInconsistentRows()
{
if ( DEBUG_ROWS || _loading || _rows.Count == 0 )
return;
string levelId = BoardLevelId;
string characterId = LeaderboardCharacterId;
int first = FirstVisible;
bool removed = false;
for ( int i = System.Math.Min( LastVisible, _rows.Count ) - 1; i >= first; i-- )
{
var row = _rows[i];
var data = RunDataStore.Get( row.DataUrl, OnPayloadLoaded );
if ( data is null || Leaderboard.IsLevelEntryConsistent( data, levelId, characterId, row.Value ) )
continue;
if ( Game.IsEditor )
Log.Info( $"BlockParty: board '{CurrentStatName}' hid row {i + 1} ({row.Name}, {row.SteamId}) — entry doesn't match its run payload." );
_rows.RemoveAt( i );
removed = true;
}
if ( removed )
{
ClampSelectionAfterRemoval();
StateHasChanged();
}
}
private List<LeaderboardRowData> _rows = new();
private bool _loading = true;
private long _localSteamId;
private int _refreshGeneration;
// Bumped whenever a row's run-data payload finishes loading, so BuildHash changes and the
// freshly-arrived time gets painted into the row.
private int _dataVersion;
// Row count for the virtual-list base (scroll/selection/selector all live in VirtualListPanel).
protected override int ItemCount => _rows.Count;
// The footer BACK button joins the W/S selection cycle as a pseudo-item (index -1): the
// board opens focused on it, and it wraps around above the first row / below the last. See
// VirtualListPanel.HasBackItem.
protected override bool HasBackItem => true;
// Dropdown labels follow LeaderboardScope enum order so the selected index can cast directly.
private readonly List<string> _scopeOptions = new() { "GLOBAL", "FRIENDS" };
private bool _levelBrowserOpen;
private bool _characterPickerOpen;
private LevelBrowserOverlay LevelBrowser { get; set; }
private CharacterPicker CharacterFilterPicker { get; set; }
public bool LevelBrowserOpen => _levelBrowserOpen;
public bool ModalOpen => _levelBrowserOpen || _characterPickerOpen;
// A workshop board is pinned to the stage's level (never persisted); everything below reads the
// level through BoardLevelId so both modes share the stat/prune/preview/filter plumbing.
private bool WorkshopMode => Stage?.WorkshopLevelId is not null;
private bool ShowMapMedals => Settings.Current.LeaderboardScope == LeaderboardScope.Global
&& !WorkshopMode && LevelMap.Nodes.Any( node => node.LevelId == BoardLevelId );
// Workshop levels credit their author under the title (the display name is remembered from the
// browse listing — see WorkshopLevels.AuthorFor).
private string AuthorName => WorkshopMode ? WorkshopLevels.AuthorFor( BoardLevelId ) : null;
private string AuthorWorkshopUrl => WorkshopMode ? WorkshopLevels.AuthorWorkshopUrlFor( BoardLevelId ) : null;
private long AuthorSteamId => WorkshopMode ? WorkshopLevels.AuthorIdFor( BoardLevelId ) : 0L;
// Rich-text anchor for the author credit. Steam names are arbitrary text, so escape them: the
// engine's rich parser decodes entities, and a literal '<' or '&' would otherwise eat the markup.
private string AuthorLinkHtml( string url )
{
string name = AuthorName.Replace( "&", "&" ).Replace( "<", "<" ).Replace( ">", ">" );
return $"<a href=\"{url}\">{name}</a>";
}
private string Description => WorkshopMode ? WorkshopLevels.DescriptionFor( BoardLevelId ) : null;
private string WorkshopItemUrl => WorkshopMode ? WorkshopLevels.ItemPageUrlFor( BoardLevelId ) : null;
private string BoardLevelId => Stage?.WorkshopLevelId ?? Settings.Current.LeaderboardLevelId;
private LevelDef CurrentLevel => Levels.Get( BoardLevelId ) ?? Levels.All.FirstOrDefault();
private string LeaderboardCharacterId
=> Characters.TryGet( Settings.Current.LeaderboardCharacterId, out var character ) ? character.Id : null;
// Forced-character badge on the level preview, sized like the browser tiles' marker (40px on a
// 150px tile) scaled to this face's 120px.
private static string ForcedCharacterStyle( CharacterDef character )
{
float size = 40f * (120f / 150f) * character.PreviewScale;
return FormattableString.Invariant( $"width:{size:0.###}px;height:{size:0.###}px;" );
}
private string CurrentStatName => string.IsNullOrEmpty( LeaderboardCharacterId )
? Leaderboard.StatNameForLevel( BoardLevelId )
: Leaderboard.StatNameForLevelAndCharacter( BoardLevelId, LeaderboardCharacterId );
protected override void HandleNavigation( float dt )
{
if ( ModalOpen )
return;
base.HandleNavigation( dt );
}
protected override void OnMouseWheel( Vector2 value )
{
if ( ModalOpen )
return;
base.OnMouseWheel( value );
}
protected override void OnStart()
{
base.OnStart();
_localSteamId = (long)Game.SteamId;
// Play the board level's song, wherever this screen was opened from (already playing = no restart).
Audio.PlayLevelMusic( CurrentLevel );
_ = Refresh();
}
private void OpenLevelBrowser()
{
Audio.PlaySfx( SfxType.MenuStart );
_levelBrowserOpen = true;
StateHasChanged();
}
public void CloseLevelBrowser()
{
if ( !_levelBrowserOpen )
return;
if ( LevelBrowser is not null )
{
LevelBrowser.RequestClose();
return;
}
OnLevelBrowserClosed();
}
private void OnLevelBrowserClosed()
{
_levelBrowserOpen = false;
LevelBrowser = null;
StateHasChanged();
}
public void CloseModal()
{
if ( _levelBrowserOpen )
{
CloseLevelBrowser();
return;
}
if ( _characterPickerOpen )
{
if ( CharacterFilterPicker is not null )
CharacterFilterPicker.RequestClose();
else
OnCharacterPickerOpenChanged( false );
}
}
private string GetLeaderboardCharacterId() => LeaderboardCharacterId;
private void OnCharacterSelected( string characterId )
{
if ( !Characters.TryGet( characterId, out var character ) )
return;
Settings.Current.LeaderboardCharacterId = character.Id;
Settings.Save();
_ = Refresh();
}
private void ClearCharacterFilter()
{
if ( string.IsNullOrEmpty( Settings.Current.LeaderboardCharacterId ) )
return;
Settings.Current.LeaderboardCharacterId = null;
Settings.Save();
_ = Refresh();
}
private void OnCharacterPickerOpenChanged( bool open )
{
_characterPickerOpen = open;
if ( open )
StopScrolling();
StateHasChanged();
}
// Map selection changed: persist it, swap to that level's song, and re-query its board.
private void OnLevelSelected( string levelId )
{
if ( LevelMap.Find( levelId ) is null )
return;
Settings.Current.LeaderboardLevelId = levelId;
Settings.Save();
Audio.PlayLevelMusic( CurrentLevel );
_ = Refresh();
}
// Global/Friends dropdown changed: persist it and re-query the board with the new filter.
private void OnScopeSelected( int index )
{
Settings.Current.LeaderboardScope = (LeaderboardScope)index;
Settings.Save();
_ = Refresh();
}
private async Task Refresh()
{
int generation = ++_refreshGeneration;
_loading = true;
StateHasChanged();
if ( !this.IsValid() )
return; // panel was torn down (left the stage) while we waited
// DEBUG: skip the live board and show synthetic rows.
if ( DEBUG_ROWS )
{
_rows = BuildDebugRows( _localSteamId );
_loading = false;
SelectDefault();
StateHasChanged();
return;
}
string statName = CurrentStatName;
double? localScore = null;
try
{
var scope = Settings.Current.LeaderboardScope;
var board = Leaderboard.GetTopBoard( statName, Leaderboard.MaxEntries, scope );
await board.Refresh();
if ( !this.IsValid() || generation != _refreshGeneration )
return;
var entries = board.Entries?.ToList() ?? new();
entries = await LeaderboardOrder.CompletePodiumTiesAsync( entries, board.TotalEntries,
() => Leaderboard.GetTopBoard( statName, Leaderboard.MaxEntries, scope ),
isCurrent: () => this.IsValid() && generation == _refreshGeneration );
if ( !this.IsValid() || generation != _refreshGeneration ) return;
localScore = entries
.Where( entry => entry.SteamId == _localSteamId )
.Select( entry => (double?)entry.Value )
.FirstOrDefault();
Leaderboard.RecordPersonalBest( statName, localScore );
entries = entries.Take( Leaderboard.MaxEntries ).ToList();
_rows = entries.Select( e => new LeaderboardRowData
{
SteamId = e.SteamId,
Name = e.DisplayName,
Score = (int)e.Value,
Value = e.Value,
TimeSeconds = float.NaN, // resolved lazily from the data payload on render
DataUrl = e.DataUrl,
CountryCode = e.CountryCode,
Timestamp = e.Timestamp,
} ).ToList();
if ( Game.IsEditor )
Log.Info( $"BlockParty: leaderboard → {_rows.Count} entries (total {board.TotalEntries}) [querying ident '{Sandbox.Game.Ident}' stat '{statName}']." );
// Every legitimate submission attaches its run payload (Leaderboard.Submit always passes
// data:), so an entry with no DataUrl is a bare forged Stats.SetValue — hide it. Entries
// whose payload exists but disagrees are pruned once it loads, in PruneInconsistentRows.
int noData = _rows.RemoveAll( r => string.IsNullOrEmpty( r.DataUrl ) );
if ( Game.IsEditor && noData > 0 )
Log.Info( $"BlockParty: board '{statName}' hid {noData} payload-less entries." );
// DEBUG: fan the real rows out N times so a tiny board can stress scrolling/selection.
if ( DEBUG_DUPLICATE > 1 && _rows.Count > 0 )
_rows = Enumerable.Range( 0, DEBUG_DUPLICATE ).SelectMany( _ => _rows ).ToList();
}
catch ( System.Exception e )
{
if ( !this.IsValid() || generation != _refreshGeneration )
return;
// The backend OMITS the Entries field entirely when a stat has zero entries for
// this ident, so the engine's Board2.From() throws ArgumentNullException ("source")
// on result.Entries.Select(...). That means we're querying an ident with no data —
// logging Game.Ident reveals whether we're on the published 'facepunch.blockparty'
// (has scores) or the editor's 'local.blockparty' (empty).
// Treat it as an empty board: without the clear, switching to an empty scope/level/
// character filter kept showing the PREVIOUS board's rows under the new filter.
_rows = new();
if ( Game.IsEditor )
Log.Info( $"BlockParty: leaderboard empty/not-ready ({e.Message}) [querying ident '{Sandbox.Game.Ident}' stat '{statName}']." );
}
if ( !this.IsValid() || generation != _refreshGeneration )
return;
// Outside the try so it also covers the empty/failed board: a run we submitted this session
// is worth showing even when the query came back with nothing.
LeaderboardRows.SpliceLocalBest( _rows, statName, Leaderboard.MaxEntries, localScore );
_loading = false;
SelectDefault();
StateHasChanged();
}
// A row's run-data payload landed — nudge BuildHash so the time renders.
private void OnPayloadLoaded()
{
if ( !this.IsValid() )
return;
_dataVersion++;
StateHasChanged();
}
// Launch a deterministic replay of the selected run (re-simulates from the recorded seed + input).
// The leaderboard row's submitter metadata (avatar/name/country/date) rides along for the HUD.
private void StartReplay( LeaderboardRowData row, RunData data )
{
GameManager.Instance?.StartReplay( data, new ReplaySubmitter( row.SteamId, row.Name, row.CountryCode, row.Timestamp ),
leaderboardBackToLevelSelect: Stage?.BackToLevelSelect ?? false, returnSelectionIndex: Selected,
returnWorkshopLevelId: Stage?.WorkshopLevelId, returnScore: Stage?.ReturnScore );
}
// BACK button: same route as Esc.
private void OnBack()
{
Audio.PlaySfx( SfxType.MenuStart );
Stage?.GoBack();
}
// Open focused on the BACK button (index -1) so Space returns rather than
// firing the top replay; a down-press drops into the board (see VirtualListPanel.HasBackItem).
// When returning from a watched replay, reopen on that run's row instead so the selection persists.
private void SelectDefault()
{
int initial = Stage?.ReturnSelection ?? -1;
if ( initial >= 0 && initial < _rows.Count )
SelectInitial( initial );
else
SelectBackInitial();
}
// Space/Confirm: activate the current selection. On the BACK pseudo-item that's the back
// action; on a row it launches that run's replay (no-op if the row has no replayable data).
public void LaunchSelected()
{
if ( BackSelected )
{
OnBack();
return;
}
if ( Selected < 0 || Selected >= _rows.Count )
return;
var row = _rows[Selected];
var payload = string.IsNullOrEmpty( row.DataUrl ) ? null : RunDataStore.Get( row.DataUrl, OnPayloadLoaded );
if ( payload?.CanReplay == true )
StartReplay( row, payload );
else
WobbleSelector();
}
// Whole seconds as mm:ss and the sub-second remainder are formatted by the shared LeaderboardRow
// (used both for display and for this screen's ms-dedup key), so the two can't drift apart.
// A row's run time: debug rows carry it inline; real rows resolve it from the cached
// data payload (kicking a one-time fetch on first miss, repainting when it lands).
private float? ResolveTime( LeaderboardRowData row ) =>
!float.IsNaN( row.TimeSeconds )
? row.TimeSeconds
: RunDataStore.Get( row.DataUrl, OnPayloadLoaded )?.TimeSeconds;
// Rows whose mm:ss matches an immediate neighbor reveal their milliseconds (LeaderboardRows does the
// tie-finding; the level-select footer uses the same helper). Every row resolves its time here,
// fetching the payload if needed — this screen shows one board at a time, so pulling them all is
// fine (the footer, which reloads per highlighted level, is stingier).
private HashSet<int> ComputeMsRows() =>
LeaderboardRows.ComputeMsRows( _rows, i => ResolveTime( _rows[i] ) );
// DEBUG: 150 synthetic rows. Scores repeat in groups of 3; within a group two rows share the
// same whole-second time but differ in milliseconds (and one is a different whole second), to
// exercise sub-second tie-breaking. Sorted like the live board: score desc, then time asc
// (faster first), so the ms-only difference decides adjacent equal-score/equal-second rows.
private static List<LeaderboardRowData> BuildDebugRows( long meId )
{
var rows = new List<LeaderboardRowData>();
for ( int i = 0; i < 150; i++ )
{
int tier = i / 3; // 50 score tiers of 3 rows each
int pos = i % 3;
int score = 1000 - tier * 5; // 1000 down to 755
int wholeBase = 75 + tier; // base whole-seconds for this tier
float seconds = pos switch
{
0 => wholeBase + 0.142f, // pos 0 & 1: same whole second, different ms
1 => wholeBase + 0.787f,
_ => wholeBase + 1.503f, // pos 2: a different whole second
};
rows.Add( new LeaderboardRowData
{
SteamId = 76561197960265728L + i,
Name = DebugName( i ),
Score = score,
Value = score,
TimeSeconds = seconds,
DataUrl = null,
} );
}
// Live-board ordering: highest score first, faster time breaks ties.
rows = rows.OrderByDescending( r => r.Score ).ThenBy( r => r.TimeSeconds ).ToList();
// Mark one row as the local player to preview the "me" highlight.
if ( rows.Count > 4 )
{
var me = rows[4];
me.SteamId = meId;
me.Name = "YOU (DEBUG)";
rows[4] = me;
}
return rows;
}
// Stress-test display names for the debug board: long strings, RTL scripts, CJK, combining
// "Zalgo" marks (tall), zero-width joiners/emoji, and control-ish characters. Lets us eyeball
// how the row clips/ellipsizes and decide what to sanitize/trim on real names.
private static readonly string[] DebugNames =
{
"PLAYER", // plain baseline
"xX_SuperLongDragonSlayer_ProGamer_99999_Xx", // very long ASCII (ellipsis test)
"ZA̸̧͍͝L̴̢̟̾G̷̮̈O̶͔͐_T̵̰͝H̶̳̑E̵͜_C̴͉̈O̸̗R̷̩͝R̴̡U̶̢P̵̱T̶̰O̸R", // combining marks -> tall, overflows row height
"日本語のなまえテスト", // CJK (wide glyphs)
"مرحبا بكم في اللعبة", // Arabic (right-to-left)
"Игрок Кириллица", // Cyrillic
"🎮🔥💀 GamerXX 🐉👑✨", // emoji (incl. ZWJ sequences)
"a\u200Bb\u200Bc invisible\u200Bspaces", // zero-width spaces
"नमस्ते दुनिया", // Devanagari (combining)
"l\u0307\u0308\u0301o\u0300\u0301n\u0302\u0303g_diacritics", // stacked diacritics
" leading spaces", // whitespace padding
"THE_NAME_THAT_NEVER_ENDS_AND_KEEPS_GOING_ON_FOREVER_AND_EVER", // extreme length
};
private static string DebugName( int i )
{
// Sprinkle the stress names across the list (and use them for the first several rows so
// they're visible without scrolling); fall back to a numbered name for the rest.
if ( i < DebugNames.Length )
return DebugNames[i];
if ( i % 7 == 0 )
return DebugNames[i % DebugNames.Length];
return $"PLAYER {i + 1:000}";
}
protected override int BuildHash() => System.HashCode.Combine(
_loading,
_rows.Count,
_dataVersion,
(int)ScrollPos,
Selected,
System.HashCode.Combine( (int)(SelectorPos * 8f), SelectorFrame,
(int)Settings.Current.LeaderboardScope,
BoardLevelId, LevelBrowserOpen,
LeaderboardCharacterId, _characterPickerOpen ),
(int)((Stage?.FadeAlpha ?? 0f) * 20f) );
}