Game/BreakoutGame.cs

Main game coordinator component for Breakout. Manages game flow (menu, serving, playing, game over), tracks score, lives, level, combos, spawning/clearing boards, hit-stop, banners, and delegates ball/level/paddle work to BallManager, LevelDirector, LevelSpawner, GameFeedback and other components.

File AccessNetworking
using System;
using Sandbox;
using Sandbox.Services;

namespace Breakout;

/// <summary>
/// The main game coordinator. It runs the overall flow (menu, serving, playing,
/// game over), tracks score/lives/level, and reacts to gameplay events such as a
/// block breaking or the ball being lost.
/// </summary>
[Title( "Breakout Game" ), Category( "Breakout" ), Icon( "sports_esports" )]
public sealed class BreakoutGame : Component
{
	[Property] public Playfield Playfield { get; set; }
	[Property] public Paddle Paddle { get; set; }
	[Property] public LevelSpawner Spawner { get; set; }
	[Property] public GameObject PaddlePrefab { get; set; }
	[Property] public int StartingLives { get; set; } = 3;
	[Property] public float PaddleZ { get; set; } = -300f;
	[Property, Group( "Juice" )] public float BoardClearPause { get; set; } = 1.5f;
	[Property, Group( "Classic Mode" )] public float ClassicPaddleShrink { get; set; } = 0.5f;
	[Property, Group( "Weekly Challenge" )] public int WeeklyMaxExpandsPerLevel { get; set; } = 2;

	private readonly Sandbox.Game.Overlay _overlay = new();

	/// <summary>
	/// True while the s&amp;box pause menu is open.
	/// </summary>
	public bool IsPaused => _overlay.IsPauseMenuOpen;

	/// <summary>
	/// True only while the player is allowed to steer the paddle: during serving or
	/// playing, and never while the pause menu is up.
	/// </summary>
	public bool PaddleControlActive => (State == GameState.Serving || State == GameState.Playing) && !IsPaused;

	/// <summary>
	/// Which mode is being played (Classic, Arcade or Weekly).
	/// </summary>
	public GameMode Mode { get; private set; } = GameMode.Classic;

	/// <summary>
	/// The current mode's name in capitals, for showing on the HUD.
	/// </summary>
	public string ModeLabel => Mode switch
	{
		GameMode.Arcade => "ARCADE",
		GameMode.Weekly => "WEEKLY",
		_ => "CLASSIC"
	};

	// Weekly mode is a shared challenge that rolls over once a week. We count how many
	// whole weeks have passed since this fixed start date, so everyone playing in the
	// same week gets the same board and competes on the same leaderboard.
	private static readonly DateTime WeekEpoch = new( 2024, 1, 1, 0, 0, 0, DateTimeKind.Utc );

	/// <summary>
	/// How many whole weeks have passed since WeekEpoch. Identifies the current weekly challenge.
	/// </summary>
	public static int WeeklyId => (int)((DateTime.UtcNow - WeekEpoch).TotalDays / 7.0);

	/// <summary>
	/// Which part of the game loop we're in (menu, serving, playing, game over).
	/// </summary>
	public GameState State { get; private set; } = GameState.Menu;

	/// <summary>
	/// The player's current score.
	/// </summary>
	public int Score { get; private set; }

	/// <summary>
	/// Balls remaining before it's game over.
	/// </summary>
	public int Lives { get; private set; }

	/// <summary>
	/// The level number within the current run (starts at 1).
	/// </summary>
	public int Level { get; private set; } = 1;

	/// <summary>
	/// True if the score just submitted beat the player's previous best.
	/// </summary>
	public bool IsNewHighScore { get; private set; }

	/// <summary>
	/// True once every brick has finished its drop-in animation and the board is ready to play.
	/// </summary>
	public bool BricksSpawnedIn => bricksSpawnedIn;

	/// <summary>
	/// How many bricks have been broken in a row without the ball touching the paddle.
	/// </summary>
	public int Combo => combo;

	// These mirror the banner and damage-flash state from GameFeedback, because the HUD reads them
	// from BreakoutGame. The "?." guards matter: the HUD can draw one frame before OnStart has
	// resolved gameFeedback, so until then we hand back harmless defaults.

	/// <summary>
	/// Big center banner text, like "LEVEL 1" or "BOARD CLEAR!".
	/// </summary>
	public string BannerText => gameFeedback?.BannerText;

	/// <summary>
	/// Smaller line shown beneath the banner (may be null).
	/// </summary>
	public string BannerSub => gameFeedback?.BannerSub;

	/// <summary>
	/// Ticks up each time a new banner is shown, so the HUD can restart its animation.
	/// </summary>
	public int BannerSeq => gameFeedback?.BannerSeq ?? 0;

	/// <summary>
	/// True while the banner should currently be visible.
	/// </summary>
	public bool BannerActive => gameFeedback?.BannerActive ?? false;

	/// <summary>
	/// Ticks up each time the player takes damage, so the HUD can restart the red flash.
	/// </summary>
	public int DamageSeq => gameFeedback?.DamageSeq ?? 0;

	/// <summary>
	/// True while the red damage flash should currently be showing.
	/// </summary>
	public bool DamageActive => gameFeedback?.DamageActive ?? false;

	private readonly List<Block> blocks = new();
	private SkyController sky;
	private bool advancePending;                // waiting out the pause after a clear before loading the next level
	private RealTimeSince sinceCleared;         // how long since the board was cleared (drives that pause)
	private TimeSince timeSinceSpawn;           // how long since the current board's bricks were spawned
	private bool bricksSpawnedIn = true;        // false while bricks are still playing their drop-in animation

	public LevelDirector levelDirector { get; private set; }
	private BallManager ballManager;
	private GameFeedback gameFeedback;
	private bool lostLifeThisBoard;             // used for the "flawless" (clear a board without dying) achievement
	private bool lostLifeThisRun;               // used for the "untouchable" (clear 3 boards, no deaths) achievement
	private int expandsThisBoard;               // wide-paddle pickups used this board (weekly caps how many count)
	private int boardsClearedThisRun;

	private int combo;                          // blocks broken in a row without the ball touching the paddle
	private RealTimeSince sinceHitStop;
	private float hitStopLength = -1f;          // -1 means no hit-stop is currently running

	private const int ComboMaxMultiplier = 50;

	protected override void OnStart()
	{
		// Find the shared scene objects, and get (or create) the three sibling components.
		// GetOrCreate means the game still runs even if someone forgot to add them in the editor.
		Playfield ??= Scene.Get<Playfield>();
		Spawner ??= Scene.Get<LevelSpawner>();
		sky ??= Scene.Get<SkyController>();
		levelDirector = Components.GetOrCreate<LevelDirector>();
		ballManager = Components.GetOrCreate<BallManager>();
		gameFeedback = Components.GetOrCreate<GameFeedback>();

		// If no paddle was placed in the scene, spawn one from the prefab at the bottom center.
		if ( !Paddle.IsValid() && PaddlePrefab is not null )
		{
			float cx = Playfield?.CenterX ?? 0f;
			float py = Playfield?.PlayY ?? 0f;
			var go = PaddlePrefab.Clone( new Vector3( cx, py, PaddleZ ) );
			go.Enabled = true;
			Paddle = go.Components.Get<Paddle>( FindMode.EverythingInSelfAndDescendants );
		}

		Paddle ??= Scene.Get<Paddle>();

		Lives = StartingLives;
		ShowMenu();
	}

	protected override void OnUpdate()
	{
		// Hide the cursor while the player is steering the paddle, show it in menus.
		Mouse.Visibility = PaddleControlActive ? MouseVisibility.Hidden : MouseVisibility.Visible;

		// Hit-stop is a brief slow-motion freeze on impact. When its timer runs out,
		// put time back to normal.
		if ( hitStopLength >= 0f && sinceHitStop >= hitStopLength )
		{
			Scene.TimeScale = 1f;
			hitStopLength = -1f;
		}

		// After a board is cleared we wait BoardClearPause seconds (to enjoy the
		// confetti) and then load the next level.
		if ( advancePending && sinceCleared >= BoardClearPause )
		{
			advancePending = false;
			Level++;
			StartLevel();
		}

		// Bricks play a drop-in animation (BlockSpawnIn) when a level loads. The board
		// counts as "ready" once none of those animations are still running.
		bricksSpawnedIn = State != GameState.Serving || !Scene.GetAllComponents<BlockSpawnIn>().Any();

		// If no breakable bricks are left, the board is clear. The timeSinceSpawn check
		// skips the first half second so this can't fire before the bricks even exist.
		if ( State == GameState.Playing && !advancePending && timeSinceSpawn > 0.5f
			&& !blocks.Any( b => b.IsValid() && b.Breakable ) )
		{
			OnLevelCleared();
		}

		switch ( State )
		{
			case GameState.Serving:
				UpdateServing();
				break;
			case GameState.GameOver:
				if ( LaunchPressed() )
					ShowMenu();
				break;
		}

		// Freeze the world while the pause menu is open during play, otherwise run normally.
		if ( (State == GameState.Serving || State == GameState.Playing) && IsPaused )
		{
			Scene.TimeScale = 0f;
		}
		else
		{
			Scene.TimeScale = 1f;
		}
	}

	private static bool LaunchPressed() => Input.Pressed( "Attack1" ) || Input.Pressed( "Jump" );

	// Returns to the main menu. The classic brick wall is respawned as a backdrop so the
	// menu screen has something moving behind it.
	private void ShowMenu()
	{
		ClearHitStop();
		combo = 0;
		ballManager.ClearBalls();
		ClearPowerUps();
		blocks.Clear();
		Spawner?.SpawnClassic();
		sky?.ToDay();
		State = GameState.Menu;
	}

	/// <summary>
	/// Starts a fresh run in the given mode. Resets score, lives and level, then loads the first level.
	/// </summary>
	public void StartGame( GameMode mode )
	{
		ClearHitStop();

		Mode = mode;
		Score = 0;
		Lives = StartingLives;
		Level = 1;
		IsNewHighScore = false;
		advancePending = false;

		if ( Mode == GameMode.Arcade )
			levelDirector.BeginArcadeRun();

		ballManager.ResetClassicProgression();

		lostLifeThisRun = false;
		boardsClearedThisRun = 0;

		if ( Paddle.IsValid() )
			Paddle.ResetWidth();

		if ( Mode == GameMode.Classic )
			sky?.BeginClassicCycle();

		StartLevel();
	}

	private void StartLevel()
	{
		lostLifeThisBoard = false;
		expandsThisBoard = 0;

		if ( Paddle.IsValid() )
			Paddle.ClearExpandEffects();

		ballManager.ClearBalls();
		ClearPowerUps();
		blocks.Clear();

		if ( Mode == GameMode.Classic )
			Spawner?.SpawnClassic();
		else if ( Mode == GameMode.Weekly )
			Spawner?.Spawn( levelDirector.CurrentLevel( Mode, Level ), levelDirector.WeeklySeed( Level ), WeeklyMaxExpandsPerLevel );
		else
			Spawner?.Spawn( levelDirector.CurrentLevel( Mode, Level ) );

		if ( Mode != GameMode.Classic )
			sky?.Randomize();

		timeSinceSpawn = 0;
		ServeBall();

		gameFeedback.ShowBanner( $"LEVEL {Level}", ModeLabel, 1.0f );
	}

	/// <summary>
	/// Registers a breakable block so the game can tell when the board has been cleared.
	/// </summary>
	public void RegisterBlock( Block block )
	{
		if ( !blocks.Contains( block ) )
			blocks.Add( block );
	}

	/// <summary>
	/// Called when the ball strikes a block, so ball-speed progression can respond.
	/// </summary>
	public void OnBlockHit( Ball ball, Block block ) => ballManager.OnBlockHit( ball, block );

	/// <summary>
	/// Called when the ball bounces off the top wall. In Classic mode this shrinks the paddle.
	/// </summary>
	public void OnBallReachedTop()
	{
		if ( Mode != GameMode.Classic || !Paddle.IsValid() || Paddle.IsShrunk )
			return;

		Paddle.Shrink( ClassicPaddleShrink );
	}

	/// <summary>
	/// Called when a block is destroyed. Awards score (with the combo multiplier) and checks for a board clear.
	/// </summary>
	public void OnBlockDestroyed( Block block, int score )
	{
		blocks.Remove( block );

		// In Arcade and Weekly, breaking blocks without the ball touching the paddle
		// builds a combo that multiplies the score. Classic mode always scores at 1x so
		// it matches the original game.
		combo++;
		int mult = Mode != GameMode.Classic ? Math.Clamp( combo, 1, ComboMaxMultiplier ) : 1;
		int awarded = score * mult;

		Score += awarded;
		Stats.Increment( "bricks", 1 );

		SpawnScorePopup( block, awarded, mult );
		gameFeedback.SpawnDebris( block );

		if ( timeSinceSpawn > 0.15f && !blocks.Any( b => b.IsValid() && b.Breakable ) )
			OnLevelCleared();
	}

	private void OnLevelCleared()
	{
		HitStop( 0.10f, 0.04f );
		gameFeedback.PlayClearSting();

		gameFeedback.SpawnConfetti( new Vector3( 0, 0, PaddleZ ) );

		if ( Mode == GameMode.Arcade )
			levelDirector.AdvanceArcadeOrder();

		ballManager.ResetClassicProgression();

		if ( Paddle.IsValid() )
			Paddle.ResetWidth();

		Stats.Increment( "boards", 1 );

		if ( !lostLifeThisBoard )
			Achievements.Unlock( "flawless" );

		boardsClearedThisRun++;
		if ( !lostLifeThisRun && boardsClearedThisRun >= 3 )
			Achievements.Unlock( "untouchable" );

		ballManager.ClearBalls();
		ClearPowerUps();
		gameFeedback.ShowBanner( "BOARD CLEAR!", null, 1.15f );

		advancePending = true;
		sinceCleared = 0f;
	}

	private void SubmitScore() => IsNewHighScore = BreakoutLeaderboards.SubmitScore( Score, Mode );

	/// <summary>
	/// Shakes the camera by the given amount, for impact feel.
	/// </summary>
	public void ShakeCamera( float amount ) => gameFeedback?.ShakeCamera( amount );

	/// <summary>
	/// Briefly slows the whole scene to a crawl for a punchy "oomph" on big moments.
	/// OnUpdate switches speed back to normal once the duration has passed.
	/// </summary>
	public void HitStop( float duration, float scale = 0.06f )
	{
		if ( duration <= 0f || State != GameState.Playing )
			return;

		Scene.TimeScale = scale;
		sinceHitStop = 0f;
		hitStopLength = duration;
	}

	private void ClearHitStop()
	{
		hitStopLength = -1f;
		if ( Scene is not null )
			Scene.TimeScale = 1f;
	}

	/// <summary>
	/// Called when the ball hits the paddle. Resets the combo, since the streak only counts while the ball is airborne.
	/// </summary>
	public void OnPaddleBounce() => combo = 0;

	/// <summary>
	/// Asks whether a wide-paddle pickup may apply. Returns false once Weekly mode's per-board cap is reached.
	/// </summary>
	public bool TryApplyPaddleExpand()
	{
		expandsThisBoard++;
		return Mode != GameMode.Weekly || expandsThisBoard <= WeeklyMaxExpandsPerLevel;
	}

	private void SpawnScorePopup( Block block, int awarded, int mult )
	{
		if ( awarded <= 0 || !block.IsValid() )
			return;

		string text = mult > 1 ? $"+{awarded}  x{mult}" : $"+{awarded}";
		var color = Color.Lerp( Color.White, new Color( 1f, 0.82f, 0.25f ),
			(mult - 1) / (float)(ComboMaxMultiplier - 1) );
		float scale = 0.22f + (mult - 1) * 0.03f;


		if ( mult > 1 )
		{
			var snd = Sound.Play( "combo_01" );
			if ( snd is not null )
				snd.Pitch = 1 + mult * 0.1f;
		}

		MusicManager.DuckFor( 0.82f, 0.1f );

		ScorePopup.Spawn( Scene, block.WorldPosition + Vector3.Up * 6f, text, color, scale );
	}

	/// <summary>
	/// Spawns a spark effect where the ball hit a wall or the paddle.
	/// </summary>
	public void SpawnWallHit( Vector3 worldPos, Vector3 inwardNormal ) => gameFeedback?.SpawnWallHit( worldPos, inwardNormal );

	private void ServeBall()
	{
		State = GameState.Serving;
		combo = 0;
		ballManager.SpawnServingBall();
	}

	private void UpdateServing()
	{
		ballManager.UpdateServingPosition();

		if ( LaunchPressed() )
			LaunchServingBall();
	}

	/// <summary>
	/// Fires the stuck serving ball, moving from Serving to Playing (only if it actually launched).
	/// </summary>
	public void LaunchServingBall()
	{
		if ( State != GameState.Serving )
			return;

		if ( ballManager.LaunchServingBall() )
			State = GameState.Playing;
	}

	/// <summary>
	/// Returns to the menu from the game-over screen.
	/// </summary>
	public void ReturnToMenu()
	{
		if ( State == GameState.GameOver )
			ShowMenu();
	}

	/// <summary>
	/// Called when a ball drops off the bottom. BallManager removes that ball; if it was the
	/// last one in play the player loses a life. Out of lives means game over, otherwise we
	/// serve a fresh ball.
	/// </summary>
	public void OnBallLost( Ball ball )
	{
		ballManager.RemoveBall( ball );

		if ( State != GameState.Playing || ballManager.HasBalls )
			return;

		Lives--;
		lostLifeThisBoard = true;
		lostLifeThisRun = true;

		gameFeedback.TriggerDamageFeedback();

		if ( Lives <= 0 )
		{
			State = GameState.GameOver;
			Sound.Play( "game_over_01" );
			MusicManager.DuckFor( 0.25f, 3 );
			SubmitScore();
			Achievements.Unlock( "warm_up" );
		}
		else
		{
			if ( Lives == 1 )
				gameFeedback.ShowBanner( "LAST LIFE!", null, 1.1f );

			ServeBall();
		}
	}

	/// <summary>
	/// Spawns extra balls that fan out from a ball already in play (the multi-ball pickup).
	/// </summary>
	public void SpawnExtraBalls( int count, float spreadAngle ) => ballManager.SpawnExtraBalls( null, count, spreadAngle );

	/// <summary>
	/// Spawns extra balls of a specific prefab (the extra-ball pickup).
	/// </summary>
	public void SpawnExtraBalls( GameObject prefab, int count, float spreadAngle ) => ballManager.SpawnExtraBalls( prefab, count, spreadAngle );

	/// <summary>
	/// Swaps a ball for a different ball-type prefab, keeping its position and heading.
	/// </summary>
	public void TransformBall( Ball oldBall, GameObject prefab ) => ballManager.TransformBall( oldBall, prefab );

	private void ClearPowerUps()
	{
		foreach ( var powerUp in Scene.GetAllComponents<PowerUp>().ToList() )
		{
			if ( powerUp.IsValid() )
				powerUp.GameObject.Destroy();
		}
	}
}