DiamondPanel.Leaderboard.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using Sandbox;
namespace Diamonds;
public sealed partial class DiamondPanel
{
public bool LeaderboardOpen { get; private set; }
public bool LeaderboardLoading { get; private set; }
public IReadOnlyList<DiamondLeaderboard.Row> LeaderboardRows => leaderboardRows;
public float LeaderboardScroll => leaderboardScroll;
/// <summary>The board shown: high scores or longest times. It stays as chosen across visits.</summary>
public DiamondLeaderboard.Mode LeaderboardMode { get; private set; }
/// <summary>Generated players mixed into the fetched board; the host sets this from its debug property.</summary>
public int LeaderboardTestPlayers { get; set; }
bool leaderboardWasPaused;
DiamondBoard leaderboardBoard;
int leaderboardRequest;
float leaderboardColorPhase;
// 0 while HIGH SCORES is the heading, 1 for LONGEST TIME; between while the titles swap places.
float leaderboardTitleBlend;
List<DiamondLeaderboard.Row> leaderboardRows = new();
// Boards fetched since the overlay opened, so toggling back is instant.
Dictionary<DiamondLeaderboard.Mode, List<DiamondLeaderboard.Row>> leaderboardFetched;
// Per-row display caches, filled as rows first scroll into view.
string[] leaderboardNames, leaderboardScores;
Texture[] leaderboardAvatars;
float leaderboardScroll, leaderboardScrollTarget;
bool leaderboardDragging;
float leaderboardGrab;
// Same unit scale as the other overlays, so text sizes match; only the frame is taller.
const float LeaderboardWidth = 620, LeaderboardHeight = 672, LeaderboardRowHeight = 52;
// The heading, and beneath it the other board's smaller, faded title, which switches to it.
const float LeaderboardTitleY = 52, LeaderboardTitleSize = 52, LeaderboardOtherTitleY = 90, LeaderboardOtherTitleSize = 26;
const float LeaderboardSwapDuration = 0.3f;
// Palette indices cycled by rank for scores: yellow, blue, red, purple, green.
static readonly int[] LeaderboardRankColors = [3, 1, 0, 4, 2];
const float LeaderboardRankRight = 94, LeaderboardAvatarLeft = 108, LeaderboardAvatarSize = 36, LeaderboardNameLeft = 158;
static Rect LeaderboardList => new( 32, 120, 556, 520 );
static string LeaderboardTitle( DiamondLeaderboard.Mode mode ) => mode == DiamondLeaderboard.Mode.Time ? "LONGEST TIME" : "HIGH SCORES";
DiamondLeaderboard.Mode OtherLeaderboardMode =>
LeaderboardMode == DiamondLeaderboard.Mode.Score ? DiamondLeaderboard.Mode.Time : DiamondLeaderboard.Mode.Score;
float LeaderboardTitleTarget => LeaderboardMode == DiamondLeaderboard.Mode.Time ? 1 : 0;
/// <summary>The other board's title as a button, padded around its text.</summary>
Rect LeaderboardOtherTitle
{
get
{
var text = DiamondScoreEffects.MeasureNumber( LeaderboardTitle( OtherLeaderboardMode ) ) * (LeaderboardOtherTitleSize / DiamondScoreEffects.PickupFontSize);
return new Rect( LeaderboardWidth * 0.5f - text.x * 0.5f - 16, LeaderboardOtherTitleY - 20, text.x + 32, 40 );
}
}
// Rows span the list, leaving room on the right only while the scrollbar shows.
Rect LeaderboardRowsArea => new( LeaderboardList.Left, LeaderboardList.Top,
LeaderboardList.Width - (LeaderboardMaxScroll > 0 ? 20 : 0), LeaderboardList.Height );
static Rect LeaderboardTrack => new( LeaderboardList.Right - 14, LeaderboardList.Top + 8, 6, LeaderboardList.Height - 16 );
static Rect LeaderboardTrackHit => new( LeaderboardTrack.Left - 10, LeaderboardList.Top, LeaderboardTrack.Width + 18, LeaderboardList.Height );
static Rect LeaderboardFrame( Vector2 size )
{
float scale = MathF.Min( size.x * 0.5f / 620, size.y * 0.62f / 500 );
var extent = new Vector2( LeaderboardWidth, LeaderboardHeight ) * scale;
return new Rect( (size - extent) * 0.5f, extent );
}
static Vector2 LeaderboardLocal( Vector2 mouse, Rect frame ) => (mouse - frame.Position) / (frame.Width / LeaderboardWidth);
float LeaderboardMaxScroll => MathF.Max( 0, leaderboardRows.Count * LeaderboardRowHeight - LeaderboardList.Height );
float LeaderboardThumbLength
{
get
{
float content = leaderboardRows.Count * LeaderboardRowHeight;
return content <= 0 ? LeaderboardTrack.Height : MathF.Max( 36, LeaderboardTrack.Height * MathF.Min( 1, LeaderboardList.Height / content ) );
}
}
float LeaderboardThumbTop => LeaderboardTrack.Top + (LeaderboardMaxScroll <= 0 ? 0
: (LeaderboardTrack.Height - LeaderboardThumbLength) * leaderboardScroll / LeaderboardMaxScroll);
public void OpenLeaderboard()
{
if ( ModalOpen || Board is null ) return;
leaderboardBoard = Board;
leaderboardWasPaused = Board.Paused;
if ( !Board.Paused ) Board.TogglePause();
LeaderboardOpen = true;
StopTitleAudio();
(leaderboardFetched ??= new()).Clear();
leaderboardTitleBlend = LeaderboardTitleTarget;
ShowLeaderboardMode();
}
/// <summary>Switch between the high score and longest time boards, loading one not yet fetched this visit.</summary>
public void SetLeaderboardMode( DiamondLeaderboard.Mode mode )
{
if ( mode == LeaderboardMode ) return;
LeaderboardMode = mode;
if ( LeaderboardOpen ) ShowLeaderboardMode();
}
public void ToggleLeaderboardMode() => SetLeaderboardMode( OtherLeaderboardMode );
void ShowLeaderboardMode()
{
// A new request drops the other board's response still in flight.
leaderboardRequest++;
if ( leaderboardFetched is not null && leaderboardFetched.TryGetValue( LeaderboardMode, out var fetched ) )
{
SetLeaderboardRows( fetched );
return;
}
SetLeaderboardRows( new List<DiamondLeaderboard.Row>() );
LeaderboardLoading = true;
_ = LoadLeaderboard( leaderboardRequest, LeaderboardMode );
}
public void CloseLeaderboard()
{
if ( !LeaderboardOpen ) return;
if ( leaderboardBoard is not null && leaderboardBoard.Paused != leaderboardWasPaused ) leaderboardBoard.TogglePause();
LeaderboardOpen = false;
LeaderboardLoading = false;
leaderboardBoard = null;
// Drop a response still in flight.
leaderboardRequest++;
leaderboardFetched?.Clear();
SetLeaderboardRows( new List<DiamondLeaderboard.Row>() );
}
async System.Threading.Tasks.Task LoadLeaderboard( int request, DiamondLeaderboard.Mode mode )
{
List<DiamondLeaderboard.Row> rows;
try { rows = await DiamondLeaderboard.FetchTop( mode, LeaderboardTestPlayers ); }
catch ( Exception e )
{
Log.Warning( $"Diamonds: leaderboard load failed ({e.Message})." );
rows = new();
}
if ( !this.IsValid() || !LeaderboardOpen || request != leaderboardRequest ) return;
(leaderboardFetched ??= new())[mode] = rows;
SetLeaderboardRows( rows );
LeaderboardLoading = false;
}
/// <summary>Show <paramref name="rows"/> from the top; tools use it to stage a board without the backend.</summary>
public void SetLeaderboardRows( List<DiamondLeaderboard.Row> rows )
{
leaderboardRows = rows ?? new();
leaderboardNames = new string[leaderboardRows.Count];
leaderboardScores = new string[leaderboardRows.Count];
leaderboardAvatars = new Texture[leaderboardRows.Count];
leaderboardScroll = leaderboardScrollTarget = 0;
leaderboardDragging = false;
LeaderboardLoading = false;
}
public void UpdateLeaderboard( float delta, bool backspace )
{
if ( !LeaderboardOpen ) return;
if ( backspace ) { CloseLeaderboard(); return; }
delta = MathF.Max( 0, delta );
leaderboardColorPhase = (leaderboardColorPhase + delta * 2.4f) % Palette.Length;
// Linear progress, eased when drawn, so a click mid-swap reverses smoothly.
leaderboardTitleBlend = LeaderboardTitleTarget > leaderboardTitleBlend
? MathF.Min( LeaderboardTitleTarget, leaderboardTitleBlend + delta / LeaderboardSwapDuration )
: MathF.Max( LeaderboardTitleTarget, leaderboardTitleBlend - delta / LeaderboardSwapDuration );
if ( leaderboardDragging )
{
// Mouse-up normally ends the drag; losing the active state covers releases we never saw.
if ( !HasActive ) leaderboardDragging = false;
else DragLeaderboardThumb( LeaderboardLocal( MousePosition, LeaderboardFrame( Box.Rect.Size ) ).y );
}
leaderboardScrollTarget = Math.Clamp( leaderboardScrollTarget, 0, LeaderboardMaxScroll );
// The thumb tracks the pointer exactly; wheel steps glide.
leaderboardScroll = leaderboardDragging ? leaderboardScrollTarget
: leaderboardScrollTarget + (leaderboardScroll - leaderboardScrollTarget) * MathF.Exp( -delta / 0.05f );
if ( MathF.Abs( leaderboardScroll - leaderboardScrollTarget ) < 0.05f ) leaderboardScroll = leaderboardScrollTarget;
}
/// <summary>Scroll by <paramref name="rows"/> rows, as a mouse wheel step does.</summary>
public void ScrollLeaderboard( float rows )
{
if ( !LeaderboardOpen || leaderboardDragging ) return;
leaderboardScrollTarget = Math.Clamp( leaderboardScrollTarget + rows * LeaderboardRowHeight, 0, LeaderboardMaxScroll );
}
public override void OnMouseWheel( Vector2 value )
{
// The overlay owns the wheel; positive values scroll down.
if ( LeaderboardOpen ) { ScrollLeaderboard( value.y * 2 ); return; }
base.OnMouseWheel( value );
}
/// <summary>The X or an outside click closes; the other board's title switches to it; the scrollbar drags from its thumb or jumps to a track click.</summary>
public void HandleLeaderboardClick( Vector2 mouse, Vector2 size )
{
if ( !LeaderboardOpen ) return;
var frame = LeaderboardFrame( size );
if ( TutorialClose( frame ).IsInside( mouse ) ) { PlayClickSound(); CloseLeaderboard(); return; }
if ( !frame.IsInside( mouse ) ) { CloseLeaderboard(); return; }
var local = LeaderboardLocal( mouse, frame );
if ( LeaderboardOtherTitle.IsInside( local ) ) { PlayClickSound(); ToggleLeaderboardMode(); return; }
if ( LeaderboardMaxScroll <= 0 || !LeaderboardTrackHit.IsInside( local ) ) return;
float top = LeaderboardThumbTop, length = LeaderboardThumbLength;
// A track click centers the thumb on the pointer, then keeps dragging from there.
leaderboardGrab = local.y >= top && local.y <= top + length ? local.y - top : length * 0.5f;
leaderboardDragging = true;
DragLeaderboardThumb( local.y );
leaderboardScroll = leaderboardScrollTarget;
}
void DragLeaderboardThumb( float y )
{
float travel = LeaderboardTrack.Height - LeaderboardThumbLength;
if ( travel <= 0 ) return;
leaderboardScrollTarget = Math.Clamp( (y - leaderboardGrab - LeaderboardTrack.Top) / travel, 0, 1 ) * LeaderboardMaxScroll;
}
void DrawLeaderboard( Painter p )
{
if ( !LeaderboardOpen ) return;
using var scope = p.Scope();
p.Fill = Color.Black.WithAlpha( 0.32f );
p.Stroke = Stroke.None;
p.Rect( p.Bounds );
var frame = LeaderboardFrame( p.Bounds.Size );
p.Fill = Color.Parse( "#0d151e" ).Value;
p.Rect( frame );
p.Clip( frame );
float scale = frame.Width / LeaderboardWidth;
if ( scale <= 0 ) return;
DrawModalClose( p, frame );
var mouse = LeaderboardLocal( MousePosition, frame );
p.Translate( frame.Position );
p.Scale( scale );
bool time = LeaderboardMode == DiamondLeaderboard.Mode.Time;
DrawLeaderboardTitles( p, mouse );
var list = LeaderboardList;
p.Fill = Color.Parse( "#080e15" ).Value;
p.Stroke = Stroke.None;
p.Rect( list );
if ( LeaderboardLoading || leaderboardRows.Count == 0 )
{
DrawScoreNumber( p, LeaderboardLoading ? "LOADING..." : time ? "NO TIMES YET" : "NO SCORES YET", list.Center, 30, Muted * 0.8f );
return;
}
using ( p.Scope() )
{
p.Clip( list );
long me = (long)Game.SteamId;
int first = Math.Max( 0, (int)(leaderboardScroll / LeaderboardRowHeight) );
int last = Math.Min( leaderboardRows.Count - 1, (int)((leaderboardScroll + list.Height) / LeaderboardRowHeight) );
for ( int i = first; i <= last; i++ )
DrawLeaderboardRow( p, i, list.Top + i * LeaderboardRowHeight - leaderboardScroll, leaderboardRows[i].SteamId == me );
}
DrawLeaderboardScrollbar( p, mouse );
}
void DrawLeaderboardRow( Painter p, int index, float top, bool local )
{
var row = leaderboardRows[index];
var area = LeaderboardRowsArea;
var rect = new Rect( area.Left, top, area.Width, LeaderboardRowHeight );
float centerY = rect.Center.y;
p.Stroke = Stroke.None;
if ( local )
{
p.Fill = ScoreColor( leaderboardColorPhase ).WithAlpha( 0.1f );
p.Rect( rect );
}
else if ( index % 2 == 1 )
{
p.Fill = Ink.WithAlpha( 0.025f );
p.Rect( rect );
}
var ink = local ? Ink : Ink * 0.85f;
DrawScoreNumber( p, $"{index + 1}", new Vector2( LeaderboardRankRight, centerY ), 24, local ? Ink : Muted, align: 1 );
var avatar = new Rect( LeaderboardAvatarLeft, centerY - LeaderboardAvatarSize * 0.5f, LeaderboardAvatarSize, LeaderboardAvatarSize );
p.Fill = Color.Parse( "#16212b" ).Value;
p.Rect( avatar );
leaderboardAvatars[index] ??= Texture.LoadAvatar( row.SteamId, 64 );
if ( leaderboardAvatars[index] is { IsLoaded: true } texture ) p.Texture( texture, avatar );
string score = leaderboardScores[index] ??= DiamondLeaderboard.Format( LeaderboardMode, row.Value );
float scoreRight = area.Right - 16;
var scoreColor = Palette[LeaderboardRankColors[index % LeaderboardRankColors.Length]];
bool time = LeaderboardMode == DiamondLeaderboard.Mode.Time;
if ( time ) DrawTimeNumber( p, score, new Vector2( scoreRight, centerY ), 26, scoreColor, align: 1 );
else DrawScoreNumber( p, score, new Vector2( scoreRight, centerY ), 26, scoreColor, align: 1 );
float nameWidth = scoreRight - (time ? MeasureTimeNumber( score, 26 ) : MeasureScoreText( p, score, 26 ).x) - 20 - LeaderboardNameLeft;
p.TextStyle = new TextStyle( "Oxanium", 22, ink ) { FontWeight = 600, Alignment = TextFlag.LeftCenter };
string name = leaderboardNames[index] ??= Ellipsize( p, string.IsNullOrWhiteSpace( row.Name ) ? $"{row.SteamId}" : row.Name, nameWidth );
p.Text( name, new Rect( LeaderboardNameLeft, rect.Top, MathF.Max( 1, nameWidth + 8 ), rect.Height ) );
}
/// <summary>Both titles, each placed, sized and colored by how selected it is, so they trade places when the board switches.</summary>
void DrawLeaderboardTitles( Painter p, Vector2 mouse )
{
var button = LeaderboardOtherTitle;
bool hover = button.IsInside( mouse );
bool pressed = hover && HasActive;
if ( hover )
{
p.Fill = Ink.WithAlpha( pressed ? 0.16f : 0.09f );
p.Stroke = Stroke.None;
p.Rect( button );
}
float time = leaderboardTitleBlend * leaderboardTitleBlend * (3 - 2 * leaderboardTitleBlend);
var faded = pressed ? Ink.WithAlpha( 0.9f ) : hover ? Muted.WithAlpha( 0.9f ) : Muted.WithAlpha( 0.25f );
// The heading draws last, over the title leaving it.
if ( time >= 0.5f ) DrawLeaderboardTitle( p, DiamondLeaderboard.Mode.Score, 1 - time, faded );
DrawLeaderboardTitle( p, DiamondLeaderboard.Mode.Time, time, faded );
if ( time < 0.5f ) DrawLeaderboardTitle( p, DiamondLeaderboard.Mode.Score, 1 - time, faded );
}
void DrawLeaderboardTitle( Painter p, DiamondLeaderboard.Mode mode, float selected, Color faded )
{
float y = LeaderboardOtherTitleY + (LeaderboardTitleY - LeaderboardOtherTitleY) * selected;
float size = LeaderboardOtherTitleSize + (LeaderboardTitleSize - LeaderboardOtherTitleSize) * selected;
DrawScoreNumber( p, LeaderboardTitle( mode ), new Vector2( LeaderboardWidth * 0.5f, y ), size,
Color.Lerp( faded, ScoreColor( leaderboardColorPhase ), selected ) );
}
void DrawLeaderboardScrollbar( Painter p, Vector2 mouse )
{
if ( LeaderboardMaxScroll <= 0 ) return;
var track = LeaderboardTrack;
bool hover = leaderboardDragging || LeaderboardTrackHit.IsInside( mouse );
p.Stroke = Stroke.None;
p.Fill = Ink.WithAlpha( hover ? 0.08f : 0.05f );
p.Rect( track );
p.Fill = Ink.WithAlpha( leaderboardDragging ? 0.6f : hover ? 0.45f : 0.28f );
p.Rect( new Rect( track.Left, LeaderboardThumbTop, track.Width, LeaderboardThumbLength ) );
}
/// <summary>The longest prefix of <paramref name="text"/> that fits <paramref name="maxWidth"/> with an ellipsis, in the current text style.</summary>
static string Ellipsize( Painter p, string text, float maxWidth )
{
if ( p.MeasureText( text ).x <= maxWidth ) return text;
// Cut between whole text elements so surrogate pairs and combining marks stay intact.
var info = new StringInfo( text );
int low = 0, high = info.LengthInTextElements;
while ( low < high )
{
int mid = (low + high + 1) / 2;
if ( p.MeasureText( info.SubstringByTextElements( 0, mid ).TrimEnd() + "…" ).x <= maxWidth ) low = mid;
else high = mid - 1;
}
return low == 0 ? "…" : info.SubstringByTextElements( 0, low ).TrimEnd() + "…";
}
}