UI/LeaderboardRow.razor
@using Sandbox
@using Sandbox.UI
@using System
@namespace BlockParty
@inherits Panel
@attribute [StyleSheet( "LeaderboardRow.razor.scss" )]
@* One leaderboard line, shared by the High Scores page and the level-select footer so their rows
stay identical (rank / avatar / name / time / character / block tally / score / replay). The run's
time, character and block graphic come from the async data payload (fetched + cached by
RunDataStore); the row repaints itself when that lands. The host owns any outer positioning,
selection highlight and hover — this component is just the content, and is non-interactive except
for its own replay button and the avatar (Steam profile link), so clicks/hover fall through to the
host row. *@
<root>
<div class="rank-column">
@if ( ShowRankTrophy && Rank is >= 1 and <= 3 )
{
<TrophyIcon Place=@Rank Size=@(40) OnHover=@(AwardTip?.Invoke( AwardPoints.Tip( Rank ) ))></TrophyIcon>
}
else if ( ShowRankMedal && Rank is >= 1 and <= 3 )
{
<MedalIcon Place=@Rank Size=@(40) OnHover=@(AwardTip?.Invoke( AwardPoints.Tip( Rank ) ))></MedalIcon>
}
else
{
<label class="rank">@Rank</label>
}
</div>
<SteamAvatar SteamId=@SteamId Size=@(50) OnHover=@ProfileTip></SteamAvatar>
<LiteralLabel class=@(IsOwnScore ? "name own" : "name") Text=@Name />
<div class="time-col">
<label class="time">@(TimeValue is null ? "" : FormatTime( TimeValue.Value ))</label>
@if ( TimeValue is not null && ShowMs )
{
<label class="ms">@FormatMs( TimeValue.Value )</label>
}
</div>
<div class="char-col">
@if ( Data is not null )
{
<div class="icon-glow"></div>
@if ( Data.Character.Partner is CharacterDef partner )
{
<div class="character rear" style="@CharacterPreviewStyle( partner )"></div>
}
<div class="character @(Data.Character.Partner is null ? "" : "front")" style="@CharacterPreviewStyle( Data.Character )"></div>
}
</div>
<div class="blocks-col">
<BlockTally Blocks=@(Data?.DecodeBlocks()) />
</div>
<label class="score @(IsNonVictory ? "non-victory" : "")">@Score</label>
@if ( CanReplay )
{
<button class="replay" onclick=@(() => StartReplay()) onmouseover=@WatchTip>▶</button>
}
else
{
<button class="replay stale" onmouseover=@(StaleTip?.Invoke( ReplayUnavailableTip ))>▶</button>
}
</root>
@code
{
[Parameter] public int Rank { get; set; }
/// <summary>Opted into by global map-level boards only.</summary>
[Parameter] public bool ShowRankMedal { get; set; }
/// <summary>Daily boards show trophies for the top three, including today's provisional places.</summary>
[Parameter] public bool ShowRankTrophy { get; set; }
[Parameter] public long SteamId { get; set; }
[Parameter] public string Name { get; set; }
[Parameter] public int Score { get; set; }
/// <summary>Data-payload URL (from the leaderboard entry); null for debug/inline rows.</summary>
[Parameter] public string DataUrl { get; set; }
[Parameter] public string CountryCode { get; set; }
[Parameter] public DateTimeOffset Timestamp { get; set; }
/// <summary>Reveal the sub-second milliseconds beside the time (the host decides when — e.g. only
/// for rows whose mm:ss matches an immediate neighbor, so ties can be told apart).</summary>
[Parameter] public bool ShowMs { get; set; }
/// <summary>Screen-owned tooltip factory for medal and trophy point values.</summary>
[Parameter] public Func<string, Action<PanelEvent>> AwardTip { get; set; }
/// <summary>Hover handler for the replay button, so the WATCH tooltip can be shown. The host supplies
/// its own <c>Tip( "..." )</c> because the tooltip layer belongs to the SCREEN: mounting one per row
/// would clip the bubble inside the row's scrolling container. Null = no tooltip.</summary>
[Parameter] public Action<PanelEvent> WatchTip { get; set; }
/// <summary>Tip factory for any unavailable replay. Intact but outdated runs keep their specific
/// <see cref="RunData.ReplayOutdatedReason"/>; missing or unusable data gets a generic explanation.
/// The host wraps the reason in its screen-owned tooltip mechanism.</summary>
[Parameter] public Func<string, Action<PanelEvent>> StaleTip { get; set; }
/// <summary>Hover handler for the avatar (opens the player's Steam profile). Same screen-owned mechanism.</summary>
[Parameter] public Action<PanelEvent> ProfileTip { get; set; }
/// <summary>Debug/inline rows carry their time here directly (NaN = resolve from the payload).</summary>
[Parameter] public float InlineTime { get; set; } = float.NaN;
/// <summary>Dim the score when the loaded run is not a victory.</summary>
[Parameter] public bool DimNonVictoryScore { get; set; }
/// <summary>If set, a launched replay returns to that day's Daily Challenge screen (daily rows).</summary>
[Parameter] public string ReturnDailyId { get; set; }
/// <summary>If true, a launched replay returns to the level-select map (level-select footer rows).</summary>
[Parameter] public bool ReturnToLevelSelect { get; set; }
/// <summary>Preserves a High Scores page whose BACK action returns to level select.</summary>
[Parameter] public bool LeaderboardBackToLevelSelect { get; set; }
/// <summary>Set when this row's board was opened from a score tally: exiting the replay returns to
/// the board, whose BACK then rebuilds that tally (see <see cref="ScoreReturn"/>).</summary>
[Parameter] public ScoreReturn ReturnScore { get; set; }
/// <summary>If set, a launched replay returns to this WORKSHOP level's board (whose own BACK goes
/// to the workshop browser).</summary>
[Parameter] public string ReturnWorkshopLevelId { get; set; }
// Bumped when the async payload lands so BuildHash changes and the row repaints with its time /
// character / block graphic.
private int _dataVersion;
private RunData Data => string.IsNullOrEmpty( DataUrl ) ? null : RunDataStore.Get( DataUrl, OnPayloadLoaded );
private float? TimeValue => !float.IsNaN( InlineTime ) ? InlineTime : Data?.TimeSeconds;
private bool CanReplay => Data?.CanReplay ?? false;
private string ReplayUnavailableTip => Data is { ReplayOutdated: true } data
? data.ReplayOutdatedReason : "Replay unavailable";
private bool IsNonVictory => DimNonVictoryScore && Data is not null && !Data.Victory;
private bool IsOwnScore => SteamId == (long)Game.SteamId;
private static string CharacterPreviewStyle( CharacterDef character )
{
float size = 40f * character.PreviewScale;
float offset = (40f - size) * 0.5f;
return FormattableString.Invariant(
$"left:{offset:0.###}px;top:{offset:0.###}px;width:{size:0.###}px;height:{size:0.###}px;background-image:url( {character.PreviewImage} );" );
}
private void OnPayloadLoaded()
{
if ( !this.IsValid() )
return;
_dataVersion++;
StateHasChanged();
}
private void StartReplay()
{
var d = Data;
if ( d?.CanReplay == true )
{
Audio.PlaySfx( SfxType.MenuStart );
GameManager.Instance?.StartReplay( d, new ReplaySubmitter( SteamId, Name, CountryCode, Timestamp ),
returnDailyId: ReturnDailyId, returnToLevelSelect: ReturnToLevelSelect,
leaderboardBackToLevelSelect: LeaderboardBackToLevelSelect, returnSelectionIndex: Rank - 1,
returnWorkshopLevelId: ReturnWorkshopLevelId, returnScore: ReturnScore );
}
}
/// <summary>Whole seconds as mm:ss. Shared with the High Scores page's ms-dedup key so both agree.</summary>
public static string FormatTime( float seconds )
{
if ( seconds < 0f )
seconds = 0f;
int total = (int)MathF.Floor( seconds );
return $"{total / 60:00}:{total % 60:00}";
}
/// <summary>The sub-second remainder as ".mmm" (shown faded beside the mm:ss on tied rows).</summary>
public static string FormatMs( float seconds )
{
if ( seconds < 0f )
seconds = 0f;
int ms = (int)MathF.Floor( (seconds - MathF.Floor( seconds )) * 1000f );
return $".{ms:000}";
}
protected override int BuildHash() => System.HashCode.Combine(
Rank, SteamId, Score, Name, ShowMs, DimNonVictoryScore, (int)(InlineTime * 100f), System.HashCode.Combine( _dataVersion, ShowRankMedal, ShowRankTrophy ) );
}