Transposer/Scenes/MainMenuScene.cs

Main menu scene for the Transposer game. Shows title and start instructions, optionally replays the last games grid swaps in the background, and fetches plus scrolls leaderboard entries when the player has played before.

Networking
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;
		}
	}
}