Transposer/Scenes/MainMenuScene.cs
namespace Sandbox.Transposer;
/// <summary>
/// Main menu scene. Shows the "transposer" title bouncing around, score text,
/// and replays the last game's grid swaps in the background progressively faster.
/// After the player has died at least once, streams leaderboard entries upward
/// from the bottom of the screen in a continuous loop.
/// </summary>
public class MainMenuScene : TransposerScene
{
private ScoreText _instructionsText;
private bool _hasStartedSwitching;
private bool _replayingSwaps;
private int _replayIndex;
private float _replayWaitTime;
private float _replayTimer;
private bool _replayStarted;
private float _replayDelayTimer;
// ── Leaderboard scroll state ──────────────────────────────────────────
private List<string> _lbLines = new();
private int _lbIndex;
private float _lbSpawnTimer;
private float _lbWaitTimer;
private bool _lbReady; // true once lines are populated and ready to spawn
private bool _lbActive; // true while this scene instance is alive; guards late async returns
private bool _lbWaiting;
private const float SPAWN_INTERVAL = 0.5f;
private const float LOOP_WAIT = 3f;
public override void Activate()
{
base.Activate();
_hasStartedSwitching = false;
AddEntity( new Background( 0, 0, this ) );
// Title text at 2× scale, centred.
string titleStr = "transposer";
int titleWidth = titleStr.Length * 14; // (6+1)*2 = 14 per char at 2x
TitleText title = new( titleStr, this,
Screen.PixelWidth / 2 - (int)MathF.Round( titleWidth * 0.5f ),
Screen.PixelHeight / 2 - 9, "font", 2 );
title.Depth -= 1;
AddEntity( title );
if ( !Globals.HasPlayedOnce )
{
_instructionsText = new( "press space to start", this,
Screen.PixelWidth / 2 - 70, 60, "font" );
AddEntity( _instructionsText );
}
else
{
_lbLines.Clear();
_lbActive = true;
_lbReady = false;
_lbWaiting = false;
_lbIndex = 0;
FetchLeaderboard();
}
// Set up swap replay (starts after 3s delay).
_replayingSwaps = Globals.LastNumSwaps > 0;
_replayIndex = 0;
_replayWaitTime = 1f;
_replayTimer = 0f;
_replayStarted = false;
_replayDelayTimer = 3f;
FadeIn( 0.25f );
AudioManager.MusicPitch = 1f;
AudioManager.FadeInMusic( 1f );
}
public override void Deactivate()
{
base.Deactivate();
_replayingSwaps = false;
_lbActive = false; // prevent a late-arriving fetch result from going live
}
public override void UpdateScene( float deltaTime )
{
Screen.Clear( new Color32( 0, 0, 0, 255 ) );
base.UpdateScene( deltaTime );
// Swap prompt text between controller and keyboard hint.
if ( _instructionsText is not null )
{
if ( Input.UsingController )
{
_instructionsText.Text = "press a to start";
_instructionsText.PixelX = Screen.PixelWidth / 2 - 56; // 16 chars × 7px / 2
}
else
{
_instructionsText.Text = "press space to start";
_instructionsText.PixelX = Screen.PixelWidth / 2 - 70; // 20 chars × 7px / 2
}
}
// Toggle mute with M.
if ( Input.Pressed( "Mute" ) )
AudioManager.ToggleMute();
if ( AudioManager.IsMuted )
DrawMuteIcon();
// Space/Z → start game, but not while the ESC screen is covering the menu.
if ( !_switchingScene && !_hasStartedSwitching
&& Input.Pressed( "Start" ) )
{
SetFadeToBlackCallback( () => SwitchScene( SceneType.Game ) );
FadeToBlack( 0.25f );
_hasStartedSwitching = true;
}
// Leaderboard scroll tick.
if ( _lbReady )
{
if ( _lbWaiting )
{
_lbWaitTimer -= deltaTime;
if ( _lbWaitTimer <= 0f )
{
_lbWaiting = false;
_lbIndex = 0;
_lbSpawnTimer = 0f;
}
}
else
{
_lbSpawnTimer -= deltaTime;
if ( _lbSpawnTimer <= 0f )
{
SpawnNextLeaderboardEntry();
_lbSpawnTimer = SPAWN_INTERVAL;
}
}
}
// Replay swaps from the last game.
if ( _replayingSwaps )
HandleSwapReplay( deltaTime );
HandleTransposing( deltaTime );
HandleFading( deltaTime );
}
// ── Leaderboard ───────────────────────────────────────────────────────
private async void FetchLeaderboard()
{
//Log.Info( $"[Transposer] FetchLeaderboard: fetching '{Globals.LEADERBOARD_STAT}'" );
var lb = Sandbox.Services.Leaderboards.GetFromStat( Globals.LEADERBOARD_STAT );
lb.SetAggregationMax();
lb.SetSortDescending();
lb.MaxEntries = 100;
await lb.Refresh();
//Log.Info( $"[Transposer] Leaderboard refresh done. Entry count: {lb.Entries.Count()}" );
_lbLines.Clear();
int rank = 1;
foreach ( var entry in lb.Entries )
_lbLines.Add( FormatLine( rank++, entry.DisplayName, (int)entry.Value ) );
//Log.Info( $"[Transposer] {_lbLines.Count} lines ready." );
// Deactivate() sets _lbReady=false; if the scene was torn down while we
// were awaiting, discard the results — UpdateScene is no longer running.
if ( !_lbActive )
{
_lbLines.Clear();
return;
}
_lbReady = true;
_lbIndex = 0;
_lbSpawnTimer = 0f;
}
private static string SanitizeName( string name )
{
name = name.Trim();
var sb = new System.Text.StringBuilder( name.Length );
foreach ( char c in name )
{
if ( c >= 'a' && c <= 'z' || c >= '0' && c <= '9' )
sb.Append( c );
else if ( c >= 'A' && c <= 'Z' )
sb.Append( (char)(c + 32) );
else if ( c == ' ' )
sb.Append( ' ' );
else
sb.Append( '?' );
}
return sb.ToString();
}
private static string FormatLine( int rank, string name, int score )
{
name = SanitizeName( name );
if ( name.Length > 10 ) name = name[..10];
return rank + ": " + name + ": " + score;
}
private void SpawnNextLeaderboardEntry()
{
if ( _lbIndex >= _lbLines.Count )
{
_lbWaiting = true;
_lbWaitTimer = LOOP_WAIT;
return;
}
string line = _lbLines[_lbIndex];
int lineW = (line.Length * 7) - 1;
int x = Screen.PixelWidth / 2 - lineW / 2;
int y = -_lbLetterHeight;
AddEntity( new LeaderboardScrollText( line, this, x, y, "font", _lbIndex + 1 ) );
_lbIndex++;
}
// Letter height of the "font" sprite at scale 1 — used for spawn-below-screen offset.
// Matches TextDisplay._letterHeight for "font" (which is 9px).
private const int _lbLetterHeight = 9;
// ── Swap replay ───────────────────────────────────────────────────────
/// <summary>
/// Progressively replay the last game's grid swaps with decreasing delay,
/// starting 3 seconds after scene activation.
/// </summary>
private void HandleSwapReplay( float deltaTime )
{
if ( !_replayStarted )
{
_replayDelayTimer -= deltaTime;
if ( _replayDelayTimer > 0f )
return;
_replayStarted = true;
}
if ( _replayIndex >= Globals.LastNumSwaps )
{
_replayingSwaps = false;
return;
}
_replayTimer -= deltaTime;
if ( _replayTimer <= 0f )
{
SwapGridSquares();
_replayIndex++;
// Decrease the wait time between each replayed swap so the replay
// progressively accelerates, bottoming out at 10ms between swaps.
_replayWaitTime -= 0.025f;
if ( _replayWaitTime < 0.01f )
_replayWaitTime = 0.01f;
_replayTimer = _replayWaitTime;
}
}
}