Leaderboard helper for the Breakout game. Exposes fetching and submitting high scores, personal best lookup, and a fake-data generator for UI/testing; it wraps s&box Stats and Leaderboards services and formats results as ScoreEntry records.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;
using Sandbox.Services;
namespace Breakout;
/// <summary>
/// Which slice of the leaderboard to show.
/// </summary>
public enum ScoreFilter
{
Global,
Friends,
Country,
AroundMe
}
/// <summary>
/// One row of leaderboard data: a player's rank, score, identity and country.
/// </summary>
public readonly record struct ScoreEntry( long Rank, double Value, long SteamId, string DisplayName, string CountryCode );
/// <summary>
/// Talks to the s&box stats and leaderboard services for high scores. Each game mode has its own
/// board, and Weekly gets a brand new board every week (the stat name includes the week number).
/// Setting UseFakeData swaps in a hand-written list of scores so the UI can be tried out offline.
/// </summary>
public static class BreakoutLeaderboards
{
public const string ScoreStat = "score";
public static bool UseFakeData = false;
/// <summary>
/// The stat name scores are stored/read under for this mode. Weekly tacks on the current week
/// id so every week is a separate board.
/// </summary>
public static string StatFor( GameMode mode ) => mode switch
{
GameMode.Arcade => "score_arcade",
GameMode.Weekly => $"score_weekly_{BreakoutGame.WeeklyId}",
_ => ScoreStat
};
/// <summary>
/// The local player's best score for the given mode.
/// </summary>
public static long PersonalBest( GameMode mode )
{
if ( UseFakeData )
return mode == GameMode.Classic ? 9860 : 108460;
return (long)Stats.LocalPlayer.Get( StatFor( mode ) ).Max;
}
/// <summary>
/// Submits a score for the given mode, but only if it beats the player's previous best. Returns true when it was a new best.
/// </summary>
public static bool SubmitScore( int score, GameMode mode )
{
if ( score <= 0 )
return false;
var stat = StatFor( mode );
double previousBest = Stats.LocalPlayer.Get( stat ).Max;
bool isNewBest = score > previousBest;
if ( isNewBest )
{
Stats.SetValue( stat, score );
Stats.Flush();
}
Log.Info( $"[Breakout] Score {score} for stat '{stat}' (previous best {previousBest:N0}, new best: {isNewBest})" );
return isNewBest;
}
/// <summary>
/// Loads up to maxEntries leaderboard rows for the given mode and filter (Global, Friends, Country or AroundMe).
/// </summary>
public static async Task<ScoreEntry[]> Fetch( ScoreFilter filter, int maxEntries, GameMode mode )
{
if ( UseFakeData )
return FakeEntries( filter, maxEntries, mode );
var board = Leaderboards.GetFromStat( StatFor( mode ) );
board.MaxEntries = maxEntries;
board.SetAggregationMax();
board.SetSortDescending();
switch ( filter )
{
case ScoreFilter.Friends:
board.SetFriendsOnly( true );
break;
case ScoreFilter.Country:
board.SetCountryAuto();
break;
case ScoreFilter.AroundMe:
board.CenterOnMe();
break;
}
await board.Refresh();
return (board.Entries ?? Enumerable.Empty<Leaderboards.Board2.Entry>())
.Select( e => new ScoreEntry( e.Rank, e.Value, e.SteamId, e.DisplayName, e.CountryCode ) )
.ToArray();
}
private static ScoreEntry[] FakeEntries( ScoreFilter filter, int maxEntries, GameMode mode )
{
long me = Sandbox.Utility.Steam.SteamId;
var raw = new (double Value, string Name, string Country, long SteamId)[]
{
(18540, "PixelQueen", "US", 76561197973858781),
(17220, "BrickBuster", "GB", 76561197996859119),
(15980, "NeonNinja", "JP", 76561190000000003),
(14310, "QuantumQuokka", "DE", 76561190000000004),
(13460, "ArcadeAce", "US", 76561190000000012),
(12750, "Volley", "FR", 76561190000000005),
(11940, "BlockBlitz", "ES", 76561190000000013),
(11090, "ComboKing", "BR", 76561190000000006),
(10310, "PaddlePro", "IT", 76561190000000014),
( 9860, "You", "", me),
( 9210, "Deflecto", "PL", 76561190000000015),
( 8420, "Spinner", "CA", 76561190000000007),
( 7810, "RicochetRik", "NO", 76561190000000016),
( 7300, "TileTitan", "AU", 76561190000000008),
( 6720, "BumperBoss", "FI", 76561190000000017),
( 6110, "Bouncer", "SE", 76561190000000009),
( 5560, "CaromKid", "PT", 76561190000000018),
( 5240, "Retroid", "KR", 76561190000000010),
( 4720, "PixelPop", "TR", 76561190000000019),
( 4050, "Chip", "NL", 76561190000000011),
( 3560, "Wallbanger", "IE", 76561190000000020),
( 3120, "Ballistic", "ZA", 76561190000000021),
( 2680, "Knockout", "AR", 76561190000000022),
( 2290, "ReboundRex", "CH", 76561190000000023),
( 1870, "TinkerTot", "DK", 76561190000000024),
( 1450, "Rookie", "IN", 76561190000000025),
};
double scale = mode == GameMode.Classic ? 1.0 : 11.0;
var master = raw
.Select( ( r, i ) => new ScoreEntry( i + 1, Math.Round( r.Value * scale ), r.SteamId, ArcadeName( mode, i, r.Name ), r.Country ) )
.ToArray();
long meRank = master.FirstOrDefault( e => e.SteamId == me ).Rank;
IEnumerable<ScoreEntry> subset = filter switch
{
ScoreFilter.Friends => master.Where( e => e.SteamId == me || e.Rank is 2 or 4 or 6 or 9 or 13 or 18 ),
ScoreFilter.Country => master.Where( e => e.SteamId == me || e.CountryCode is "US" or "GB" or "JP" ),
ScoreFilter.AroundMe => master.Where( e => meRank > 0 && Math.Abs( e.Rank - meRank ) <= 2 ),
_ => master,
};
return subset.Take( maxEntries ).ToArray();
}
private static string ArcadeName( GameMode mode, int index, string name ) => mode == GameMode.Arcade
? index switch { 0 => "MegaByte", 2 => "PowerPlay", 4 => "ChainGang", 7 => "ComboCat", _ => name }
: name;
}