UI/Leaderboard.razor
@using Sandbox;
@using Sandbox.UI;
@using SquishyTerry.Scoring;
@namespace SquishyTerry
@inherits PanelComponent
@*
Top 10 leaderboard card on the left of the screen, live from the s&box stats service. The
arrows (or the keyboard arrows) switch boards with the deck animation. Scores are recorded by
the ScoreKeeper; the footer shows the player's own best from their saved profile.
*@
<root>
@{ var rows = Rows( CurrentBoard ); }
<div class="deck">
<div class="card" style="@CardStyle()">
@* Purple tab poking out above the top-left corner. *@
<div class="tab"></div>
<div class="header">
@* Terry peeking over the banner, with a little "!" burst. *@
<div class="peek">
<div class="face"></div>
<div class="burst b1"></div>
<div class="burst b2"></div>
</div>
<div class="title-banner">
<div class="crown"></div>
<div class="title">Leaderboard</div>
</div>
@* Terry's hands on the edge of the banner. *@
<div class="hand left"></div>
<div class="hand right"></div>
<div class="switcher">
<div class="arrow" onclick="@(() => Cycle( -1 ))">◀</div>
<div class="subtitle">@CurrentBoard.Title</div>
<div class="arrow" onclick="@(() => Cycle( 1 ))">▶</div>
</div>
</div>
<div class="rows">
@if ( rows.Count == 0 )
{
<div class="empty">@(_loading.Contains( CurrentBoard.Stat ) ? "Loading..." : "No scores yet. Be the first!")</div>
}
@for ( int i = 0; i < rows.Count; i++ )
{
var entry = rows[i];
@if ( i == 0 )
{
@* First place: a rainbow border that cycles around the row. *@
<div class="rainbow" style="background-image: @RainbowGradient()">
<div class="row first @(entry.IsMe ? "me" : "")">
<div class="dots"></div>
<div class="rank">
<label>1</label>
<div class="crown small"></div>
</div>
<div class="avatar-ring gold" style="background-image: @RainbowGradient()">@Avatar( entry )</div>
<div class="name cycle">
@for ( int c = 0; c < entry.Name.Length; c++ )
{
<label style="color: @CycleColor( c, entry.Name.Length )">@entry.Name[c]</label>
}
</div>
<div class="score">@entry.Score</div>
</div>
</div>
}
else
{
<div class="row @(entry.IsMe ? "me" : "")">
<div class="rank box">@(i + 1)</div>
<div class="avatar-ring">@Avatar( entry )</div>
<div class="name">@entry.Name</div>
<div class="score">@entry.Score</div>
</div>
}
}
</div>
@if ( MeRow( CurrentBoard, rows ) is { } me )
{
@* You, when you're not in the top 10: your account's rank, avatar, name and score. *@
<div class="you-divider">•••</div>
<div class="row you">
<div class="rank box">@me.Rank</div>
<div class="avatar-ring">@Avatar( me.Entry )</div>
<div class="name">@me.Entry.Name</div>
<div class="score">@me.Entry.Score</div>
</div>
}
<div class="footer">
<div class="tagline">@Tagline( CurrentBoard )</div>
</div>
@* Corner detail: dot texture bottom left. *@
<div class="corner-dots"></div>
</div>
</div>
</root>
@code
{
/// <param name="SteamId">Steam id for the avatar; 0 shows the name's initial instead.</param>
public record Entry( string Name, string Score, bool IsMe = false, long SteamId = 0 );
/// <summary>Where scores come from (the player's saved profile is read from it too).</summary>
[Property] public ScoreKeeper Keeper { get; set; }
public enum Aggregation { Max, Sum, Avg }
/// <param name="Title">What the board ranks, shown between the arrows.</param>
/// <param name="Stat">The stats-service stat it ranks (recorded by the ScoreKeeper).</param>
/// <param name="Personal">The player's own best on this board, from their saved profile.</param>
/// <param name="Placeholder">Rows shown in the editor while there's no live data yet.</param>
public record Board( string Title, string Stat, Aggregation Aggregate, System.Func<double, string> Format,
System.Func<PlayerProfile, string> Personal, List<Entry> Placeholder );
/// <summary>The boards the arrows cycle through (placeholders for now).</summary>
public List<Board> Boards { get; set; } = new()
{
new( "Biggest stretch", ScoreKeeper.StretchStat, Aggregation.Max, v => $"{v:0.00}", p => $"{p.BestStretch:0.00}", new()
{
new( "Garry", "3.42", SteamId: 76561197960279927 ),
new( "Terry", "3.10" ),
new( "Layla", "2.87" ),
new( "Kristen", "2.61" ),
new( "Casey", "2.40" ),
new( "Matt", "2.12" ),
new( "Sam", "1.98" ),
new( "Alex", "1.73" ),
new( "Jordan", "1.51" ),
new( "Robin", "1.20" ),
} ),
new( "Best combo", ScoreKeeper.ComboStat, Aggregation.Max, v => $"{v:0}", p => $"{p.BestCombo}", new()
{
new( "Layla", "212" ),
new( "Garry", "180", SteamId: 76561197960279927 ),
new( "Sam", "154" ),
new( "You", "121", true, Sandbox.Utility.Steam.SteamId ),
new( "Kristen", "98" ),
new( "Terry", "77" ),
new( "Robin", "64" ),
new( "Matt", "51" ),
new( "Jordan", "40" ),
new( "Alex", "33" ),
} ),
new( "Total squishes", ScoreKeeper.SquishStat, Aggregation.Sum, v => $"{v:N0}", p => $"{p.TotalSquishes:N0}", new()
{
new( "Terry", "48,210" ),
new( "You", "31,877", true, Sandbox.Utility.Steam.SteamId ),
new( "Matt", "27,004" ),
new( "Garry", "19,550", SteamId: 76561197960279927 ),
new( "Alex", "15,121" ),
new( "Jordan", "12,980" ),
new( "Layla", "9,440" ),
new( "Robin", "7,302" ),
new( "Sam", "5,118" ),
new( "Kristen", "2,760" ),
} ),
new( "Total stretches", ScoreKeeper.StretchCountStat, Aggregation.Sum, v => $"{v:N0}", p => $"{p.TotalStretches:N0}", new()
{
new( "Garry", "39,882", SteamId: 76561197960279927 ),
new( "Matt", "30,415" ),
new( "Terry", "26,003" ),
new( "Layla", "18,774" ),
new( "Jordan", "14,090" ),
new( "Sam", "11,236" ),
new( "Robin", "8,905" ),
new( "Alex", "6,321" ),
new( "Kristen", "4,450" ),
new( "Casey", "1,982" ),
} ),
new( "Most wrinkles", ScoreKeeper.WrinkleStat, Aggregation.Max, v => $"{v:N0}", p => $"{p.MostWrinkles:N0}", new()
{
new( "Kristen", "612" ),
new( "Terry", "575" ),
new( "Garry", "540", SteamId: 76561197960279927 ),
new( "Alex", "498" ),
new( "Layla", "466" ),
new( "Matt", "431" ),
new( "Jordan", "402" ),
new( "Robin", "377" ),
new( "Sam", "351" ),
new( "Casey", "329" ),
} ),
new( "Fatalities", ScoreKeeper.FatalityStat, Aggregation.Sum, v => $"{v:N0}", p => $"{p.TotalFatalities:N0}", new()
{
new( "Terry", "88" ),
new( "Garry", "71", SteamId: 76561197960279927 ),
new( "Matt", "64" ),
new( "Layla", "52" ),
new( "Jordan", "41" ),
new( "Alex", "33" ),
new( "Sam", "25" ),
new( "Robin", "18" ),
new( "Kristen", "11" ),
new( "Casey", "4" ),
} ),
new( "Most blood", ScoreKeeper.BloodStat, Aggregation.Max, v => $"{v:N0} ml", p => $"{p.MostBlood:N0} ml", new()
{
new( "Terry", "9,840 ml" ),
new( "Garry", "8,215 ml", SteamId: 76561197960279927 ),
new( "Layla", "7,090 ml" ),
new( "Matt", "6,402 ml" ),
new( "Jordan", "5,377 ml" ),
new( "Alex", "4,120 ml" ),
new( "Sam", "3,561 ml" ),
new( "Robin", "2,904 ml" ),
new( "Kristen", "1,733 ml" ),
new( "Casey", "846 ml" ),
} ),
new( "Squish efficiency", ScoreKeeper.EfficiencyStat, Aggregation.Avg, v => $"{v:0.0}%", p => $"{p.AverageEfficiency():0.0}%", new()
{
new( "Sam", "98.7%" ),
new( "Garry", "97.2%", SteamId: 76561197960279927 ),
new( "Kristen", "95.9%" ),
new( "Terry", "93.4%" ),
new( "Robin", "91.0%" ),
new( "Casey", "88.6%" ),
new( "Jordan", "84.1%" ),
new( "Layla", "80.3%" ),
new( "Alex", "76.8%" ),
new( "Matt", "71.5%" ),
} ),
};
// --- Live data ---
readonly Dictionary<string, List<Entry>> _live = new();
readonly HashSet<string> _loading = new();
readonly HashSet<string> _reportedUnavailable = new();
// The player's own entry per board (rank + value), for the YOU row.
readonly Dictionary<string, (long Rank, double Value)> _mine = new();
float _refreshTimer;
const float RefreshInterval = 30f;
ScoreKeeper _subscribed;
/// <summary>The rows to draw: live scores, or the placeholders in the editor before any exist.</summary>
List<Entry> Rows( Board board )
{
if ( _live.TryGetValue( board.Stat, out var live ) && live.Count > 0 ) return live;
return Game.IsEditor ? board.Placeholder : new List<Entry>();
}
async System.Threading.Tasks.Task Refresh( Board board )
{
if ( !_loading.Add( board.Stat ) ) return;
try
{
var lb = Sandbox.Services.Leaderboards.GetFromStat( board.Stat );
switch ( board.Aggregate )
{
case Aggregation.Sum: lb.SetAggregationSum(); break;
case Aggregation.Avg: lb.SetAggregationAvg(); break;
default: lb.SetAggregationMax(); break;
}
lb.SetSortDescending();
lb.MaxEntries = 10;
await lb.Refresh( default );
long me = Sandbox.Utility.Steam.SteamId;
var rows = new List<Entry>();
foreach ( var e in lb.Entries ?? System.Array.Empty<Sandbox.Services.Leaderboards.Board2.Entry>() )
rows.Add( new Entry( e.SteamId == me ? "You" : e.DisplayName ?? "?", board.Format( e.Value ), e.SteamId == me, e.SteamId ) );
_live[board.Stat] = rows;
// The player's own rank: the same board, centred on them, one entry.
var mine = Sandbox.Services.Leaderboards.GetFromStat( board.Stat );
switch ( board.Aggregate )
{
case Aggregation.Sum: mine.SetAggregationSum(); break;
case Aggregation.Avg: mine.SetAggregationAvg(); break;
default: mine.SetAggregationMax(); break;
}
mine.SetSortDescending();
mine.CenterOnMe();
mine.MaxEntries = 1;
await mine.Refresh( default );
foreach ( var e in mine.Entries ?? System.Array.Empty<Sandbox.Services.Leaderboards.Board2.Entry>() )
if ( e.SteamId == me ) _mine[board.Stat] = (e.Rank, e.Value);
}
catch ( System.Exception e )
{
// Offline, or the game isn't published yet. Say so once per board, not every refresh.
if ( _reportedUnavailable.Add( board.Stat ) )
Log.Info( $"Leaderboard '{board.Stat}' unavailable: {e.Message}" );
}
finally
{
_loading.Remove( board.Stat );
}
}
public record MeEntry( string Rank, Entry Entry );
/// <summary>
/// The YOU row: shown when the player isn't in the rows above. Uses their live rank when the
/// service has one, otherwise their saved personal best (rank unknown).
/// </summary>
MeEntry MeRow( Board board, List<Entry> rows )
{
foreach ( var r in rows ) if ( r.IsMe ) return null;
long me = Sandbox.Utility.Steam.SteamId;
const string name = "You";
if ( _mine.TryGetValue( board.Stat, out var mine ) )
return new MeEntry( $"{mine.Rank:N0}", new Entry( name, board.Format( mine.Value ), true, me ) );
if ( Keeper?.Profile is { } profile )
return new MeEntry( "—", new Entry( name, board.Personal( profile ), true, me ) );
return null;
}
/// <summary>Refreshes the board on screen (the others refresh when you switch to them).</summary>
void RefreshCurrent()
{
_ = Refresh( CurrentBoard );
_refreshTimer = 0f;
}
protected override void OnStart() => RefreshCurrent();
int _boardIndex;
Board CurrentBoard => Boards[System.Math.Clamp( _boardIndex, 0, Boards.Count - 1 )];
// --- Switching boards: the card lifts off the deck, sinks back in, and the next board rises
// out of the deck and settles with a small overshoot. The content swaps while it's tucked in. ---
const float LiftTime = 0.14f, SinkTime = 0.16f, RiseTime = 0.3f;
const float SwitchTime = LiftTime + SinkTime + RiseTime;
float _switchT = -1f;
int _switchDir;
int _nextIndex;
// Stats are batched by the service, so give a submission a few seconds before re-reading.
void OnSubmitted() => _refreshTimer = System.MathF.Max( _refreshTimer, RefreshInterval - 5f );
protected override void OnDestroy()
{
if ( _subscribed is not null ) _subscribed.Submitted -= OnSubmitted;
}
/// <summary>Switches to the next (1) or previous (-1) board with the deck animation.</summary>
public void Cycle( int dir )
{
if ( Boards.Count < 2 ) return;
// A new press mid-switch just retargets the board that comes up.
_nextIndex = ((_switchT >= 0 ? _nextIndex : _boardIndex) + dir + Boards.Count) % Boards.Count;
if ( _switchT < 0 || _switchT > LiftTime + SinkTime ) _switchT = 0f;
_switchDir = dir;
}
static float EaseOut( float x ) => 1f - (1f - x) * (1f - x) * (1f - x);
static float EaseIn( float x ) => x * x * x;
static float EaseOutBack( float x )
{
const float c1 = 1.70158f, c3 = c1 + 1f;
float y = x - 1f;
return 1f + c3 * y * y * y + c1 * y * y;
}
string CardStyle()
{
// Resting pose: the slight tilt that matches the combo counter.
float y = 0f, x = 0f, scale = 1f, rotate = 0f, opacity = 1f;
if ( _switchT >= 0f )
{
float t = _switchT;
if ( t < LiftTime )
{
// Lift: up off the deck, a touch bigger, tipping towards the arrow pressed.
float p = EaseOut( t / LiftTime );
y = -34f * p;
x = 10f * _switchDir * p;
scale = 1f + 0.05f * p;
rotate = 5f * _switchDir * p;
}
else if ( t < LiftTime + SinkTime )
{
// Sink: back down into the deck, shrinking and fading as it tucks in.
float p = EaseIn( (t - LiftTime) / SinkTime );
y = -34f + 60f * p;
x = 10f * _switchDir * (1f - p);
scale = 1.05f - 0.13f * p;
rotate = 5f * _switchDir * (1f - p);
opacity = 1f - p;
}
else
{
// Rise: the next board comes up out of the deck and settles with an overshoot.
float p = System.Math.Clamp( (t - LiftTime - SinkTime) / RiseTime, 0f, 1f );
float e = EaseOutBack( p );
y = 26f * (1f - e);
scale = 0.92f + 0.08f * e;
rotate = -3f * _switchDir * (1f - e);
opacity = System.Math.Clamp( p * 3f, 0f, 1f );
}
}
return $"transform: translateX( {x:0.#}px ) translateY( {y:0.#}px ) scale( {scale:0.###} ) rotate( {rotate:0.##}deg ); opacity: {opacity:0.##};";
}
/// <summary>The little call to action under each board.</summary>
static string Tagline( Board board ) => board.Stat switch
{
ScoreKeeper.StretchStat => "Stretch further",
ScoreKeeper.ComboStat => "Keep the chain alive",
ScoreKeeper.SquishStat => "Squish more",
ScoreKeeper.StretchCountStat => "Stretch more",
ScoreKeeper.WrinkleStat => "Wrinkle harder",
ScoreKeeper.FatalityStat => "Finish him",
ScoreKeeper.BloodStat => "Make him bleed",
ScoreKeeper.EfficiencyStat => "Squish smarter",
_ => "Squish on",
};
RenderFragment Avatar( Entry entry ) => entry.SteamId != 0
? @<div class="avatar" style="background-image: url( avatar:@entry.SteamId )"></div>
: @<div class="avatar placeholder">@(string.IsNullOrEmpty( entry.Name ) ? "?" : entry.Name[..1].ToUpperInvariant())</div>;
/// <summary>A rainbow colour, the same one the border uses at this point of the spectrum (0..1).</summary>
string RainbowColor( float t, float saturation = 0.75f, float value = 1f )
{
float hue = (t + _rainbowTurn) % 1f * 360f;
var c = new ColorHsv( hue, saturation, value ).ToColor();
return $"rgb( {(int)(c.r * 255)}, {(int)(c.g * 255)}, {(int)(c.b * 255)} )";
}
// First-place name: Quake-style sticky colour codes. Each new colour wipes across the name left
// to right, one letter per step; letters past the wipe keep the previous colour. The palette is
// the border's rainbow (same saturation and brightness), evenly spaced around the spectrum.
const int CycleColors = 6;
/// <summary>Milliseconds per letter of the first-place colour wipe.</summary>
[Property, Range( 20, 500 )] public float CycleStepMs { get; set; } = 100f;
float _cycleTime;
long CycleStep => (long)(_cycleTime * 1000f / System.MathF.Max( CycleStepMs, 1f ));
static string PaletteColor( int index )
{
var c = new ColorHsv( index * 360f / CycleColors, 0.75f, 1f ).ToColor();
return $"rgb( {(int)(c.r * 255)}, {(int)(c.g * 255)}, {(int)(c.b * 255)} )";
}
string CycleColor( int letter, int length )
{
if ( length <= 0 ) return PaletteColor( 0 );
long step = CycleStep;
int incoming = (int)((step / length) % CycleColors);
int boundary = (int)(step % length);
return PaletteColor( letter < boundary ? incoming : (incoming + CycleColors - 1) % CycleColors );
}
/// <summary>How fast the first-place rainbow goes around (turns per second).</summary>
[Property, Range( 0, 2 )] public float RainbowSpeed { get; set; } = 0.35f;
float _rainbowTurn;
protected override void OnUpdate()
{
_rainbowTurn = (_rainbowTurn + RealTime.Delta * RainbowSpeed) % 1f;
_cycleTime += RealTime.Delta;
// Hook up to the score keeper; after it submits, refresh soon so new scores show up.
Keeper ??= Scene.GetAllComponents<ScoreKeeper>().FirstOrDefault();
if ( Keeper != _subscribed )
{
if ( _subscribed is not null ) _subscribed.Submitted -= OnSubmitted;
if ( Keeper is not null ) Keeper.Submitted += OnSubmitted;
_subscribed = Keeper;
}
if ( (_refreshTimer += RealTime.Delta) >= RefreshInterval ) RefreshCurrent();
// Keyboard arrows switch boards too.
if ( Input.Keyboard.Pressed( "LEFTARROW" ) ) Cycle( -1 );
if ( Input.Keyboard.Pressed( "RIGHTARROW" ) ) Cycle( 1 );
if ( _switchT >= 0f )
{
float before = _switchT;
_switchT += RealTime.Delta;
// Swap the content while the card is tucked into the deck.
if ( before < LiftTime + SinkTime && _switchT >= LiftTime + SinkTime )
{
_boardIndex = _nextIndex;
RefreshCurrent();
}
if ( _switchT >= SwitchTime ) { _boardIndex = _nextIndex; _switchT = -1f; }
}
}
/// <summary>
/// A full-spectrum gradient whose direction and colours rotate over time. Lower saturation and
/// value give the dark version behind the first-place row, so its bright text stays readable.
/// </summary>
string RainbowGradient( float saturation = 0.75f, float value = 1f )
{
const int Stops = 7;
var stops = new string[Stops];
for ( int k = 0; k < Stops; k++ )
{
stops[k] = RainbowColor( k / (float)(Stops - 1), saturation, value );
}
return $"linear-gradient( {(int)(_rainbowTurn * 360f)}deg, {string.Join( ", ", stops )} )";
}
protected override int BuildHash() => System.HashCode.Combine(
System.HashCode.Combine( _boardIndex, Boards.Count, (int)(_rainbowTurn * 360f), CycleStep, (int)(_switchT * 120f) ),
_live.TryGetValue( CurrentBoard.Stat, out var live ) ? live.GetHashCode() : 0, _loading.Count,
Keeper?.Profile is { } p ? CurrentBoard.Personal( p ) : "" );
}