Ball/BallManager.cs

Component that manages all ball-related behavior for the Breakout game. It tracks active balls, spawns the serving ball, launches it, removes and clears balls, handles extra-ball spawning and transformations, and applies mode-specific speed progression and boosts.

File Access
using System;
using Sandbox;

namespace Breakout;

/// <summary>
/// Owns the active-ball registry and everything about ball motion: serving, launching,
/// multiball/transform spawning, and per-mode speed progression. BreakoutGame keeps the
/// game flow (lives, state) and delegates ball work here.
/// </summary>
[Title( "Ball Manager" ), Category( "Breakout" ), Icon( "sports_baseball" )]
public sealed class BallManager : Component
{
	[Property] public GameObject BallPrefab { get; set; }
	[Property] public float ServeHeight { get; set; } = 24f;
	[Property, Group( "Classic Mode" )] public float ClassicSpeedStep { get; set; } = 55f;
	[Property, Group( "Classic Mode" )] public float ClassicMaxSpeed { get; set; } = 650f;
	[Property, Group( "Arcade Mode" )] public float ArcadeSpeedPerLevel { get; set; } = 25f;
	[Property, Group( "Arcade Mode" )] public float ArcadeMaxSpeed { get; set; } = 700f;
	[Property, Group( "Weekly Challenge" )] public float WeeklySpeedPerLevel { get; set; } = 90f;
	[Property, Group( "Weekly Challenge" )] public float WeeklyMaxSpeed { get; set; } = 1200f;

	private readonly List<Ball> balls = new();
	private Ball servingBall;

	private int classicHits;
	private float classicSpeedBonus;
	private bool hitOrange;
	private bool hitRed;

	private BreakoutGame owner;
	private BreakoutGame Owner => owner ??= Components.Get<BreakoutGame>() ?? Scene.Get<BreakoutGame>();

	/// <summary>
	/// True if at least one ball is still in play.
	/// </summary>
	public bool HasBalls => balls.Any( b => b.IsValid() );

	/// <summary>
	/// Adds a ball to the tracked list (does nothing if it's already there).
	/// </summary>
	public void RegisterBall( Ball ball )
	{
		if ( !balls.Contains( ball ) )
			balls.Add( ball );
	}

	/// <summary>
	/// Spawns the stuck serve ball above the paddle. Game state is owned by BreakoutGame.
	/// </summary>
	public void SpawnServingBall()
	{
		if ( BallPrefab is null || !Owner.Paddle.IsValid() )
			return;

		var go = BallPrefab.Clone( ServePosition() );
		go.Enabled = true;

		servingBall = go.Components.Get<Ball>( FindMode.EverythingInSelfAndDescendants );
		if ( servingBall is null )
			return;

		servingBall.Stuck = true;
		RegisterBall( servingBall );
		ApplyBallSpeed( servingBall );
	}

	/// <summary>
	/// Keeps the stuck ball glued above the paddle while serving.
	/// </summary>
	public void UpdateServingPosition()
	{
		if ( !servingBall.IsValid() || !Owner.Paddle.IsValid() )
			return;

		servingBall.WorldPosition = ServePosition();
	}

	/// <summary>
	/// Launches the served ball. Returns true if it actually launched (so the game can switch
	/// to Playing), or false if it's still waiting (for example while bricks are spawning in).
	/// </summary>
	public bool LaunchServingBall()
	{
		if ( !servingBall.IsValid() )
			return false;

		if ( Scene.GetAllComponents<BlockSpawnIn>().Any() )
			return false;

		var dir = new Vector3( Game.Random.Float( -0.35f, 0.35f ), 0f, 1f );
		servingBall.Launch( dir );

		var launch = Sound.Play( "ball_impact_pad_01" );
		if ( launch is not null )
			launch.Pitch = 1.4f;

		servingBall = null;
		return true;
	}

	/// <summary>
	/// Removes a lost ball (destroy plus sound). The game decides life/game-over from HasBalls.
	/// </summary>
	public void RemoveBall( Ball ball )
	{
		balls.Remove( ball );
		if ( ball.IsValid() )
			ball.GameObject.Destroy();

		Sound.Play( "ball_fall_01" );
	}

	/// <summary>
	/// Destroys every ball and clears the serving ball. Used on reset, menu and board clear.
	/// </summary>
	public void ClearBalls()
	{
		foreach ( var ball in balls.ToList() )
		{
			if ( ball.IsValid() )
				ball.GameObject.Destroy();
		}

		balls.Clear();
		servingBall = null;
	}

	/// <summary>
	/// Classic-mode stepped speed-up as the player racks up hits and reaches higher-value bricks.
	/// The built-up bonus stays until the board is cleared.
	/// </summary>
	public void OnBlockHit( Ball ball, Block block )
	{
		if ( Owner.Mode != GameMode.Classic )
			return;

		classicHits++;

		int steps = 0;
		if ( classicHits == 4 ) steps++;
		if ( classicHits == 12 ) steps++;
		if ( !hitOrange && block.IsValid() && block.ScoreValue >= 5 ) { hitOrange = true; steps++; }
		if ( !hitRed && block.IsValid() && block.ScoreValue >= 7 ) { hitRed = true; steps++; }

		if ( steps == 0 )
			return;

		classicSpeedBonus += steps * ClassicSpeedStep;
		foreach ( var b in balls )
			ApplyBallSpeed( b );
	}

	/// <summary>
	/// Clears the Classic-mode speed build-up (called on reset and each board clear).
	/// </summary>
	public void ResetClassicProgression()
	{
		classicHits = 0;
		classicSpeedBonus = 0f;
		hitOrange = false;
		hitRed = false;
	}

	/// <summary>
	/// Sets a ball's speed from its base speed plus the current mode's progression.
	/// </summary>
	public void ApplyBallSpeed( Ball ball )
	{
		if ( !ball.IsValid() )
			return;

		float baseSpeed = ball.BaseSpeed > 0f ? ball.BaseSpeed : ball.Speed;

		if ( Owner.Mode == GameMode.Classic )
		{
			ball.Speed = MathF.Min( baseSpeed + classicSpeedBonus, ClassicMaxSpeed );

			float span = MathF.Max( 1f, ClassicMaxSpeed - baseSpeed );
			ball.SetBoosted( classicSpeedBonus > 0f, classicSpeedBonus / span );
			return;
		}

		if ( Owner.Mode == GameMode.Weekly )
		{
			float weeklyBonus = MathF.Max( 0f, Owner.Level - 1 ) * WeeklySpeedPerLevel;
			ball.Speed = MathF.Min( baseSpeed + weeklyBonus, WeeklyMaxSpeed );
			return;
		}

		float bonus = MathF.Max( 0f, Owner.Level - 1 ) * ArcadeSpeedPerLevel;
		ball.Speed = MathF.Min( baseSpeed + bonus, ArcadeMaxSpeed );
	}

	/// <summary>
	/// Splits extra balls off an active ball (the multi-ball pickup). A null prefab clones the
	/// standard ball and inherits the source ball's radius.
	/// </summary>
	public void SpawnExtraBalls( GameObject prefab, int count, float spreadAngle )
	{
		var toSpawn = prefab ?? BallPrefab;
		if ( toSpawn is null )
			return;

		var source = balls.FirstOrDefault( b => b.IsValid() && !b.Stuck );
		if ( source is null )
			return;

		bool inheritSource = prefab is null;

		for ( int i = 1; i <= count; i++ )
		{
			float sign = (i % 2 == 0) ? 1f : -1f;
			float angle = spreadAngle * sign * ((i + 1) / 2);

			var go = toSpawn.Clone( source.WorldPosition );
			go.Enabled = true;

			var ball = go.Components.Get<Ball>( FindMode.EverythingInSelfAndDescendants );
			if ( ball is null )
				continue;

			if ( inheritSource )
			{
				ball.Radius = source.Radius;
			}

			ApplyBallSpeed( ball );
			ball.Launch( RotateInPlane( source.Direction, angle ) );
			RegisterBall( ball );
		}
	}

	/// <summary>
	/// Swaps an existing ball for a different prefab, keeping its position and heading.
	/// </summary>
	public void TransformBall( Ball oldBall, GameObject prefab )
	{
		if ( prefab is null || !oldBall.IsValid() )
			return;

		var pos = oldBall.WorldPosition;
		var dir = oldBall.Direction;
		bool wasStuck = oldBall.Stuck;

		var go = prefab.Clone( pos );
		go.Enabled = true;

		var newBall = go.Components.Get<Ball>( FindMode.EverythingInSelfAndDescendants );
		if ( !newBall.IsValid() )
		{
			go.Destroy();
			return;
		}

		balls.Remove( oldBall );
		oldBall.GameObject.Destroy();

		ApplyBallSpeed( newBall );

		if ( wasStuck )
		{
			newBall.Stuck = true;
			newBall.Direction = dir;
		}
		else
		{
			newBall.Launch( dir );
		}

		RegisterBall( newBall );
		Owner.HitStop( 0.045f );
	}

	private Vector3 ServePosition() => Owner.Paddle.WorldPosition + Vector3.Up * ServeHeight;

	private static Vector3 RotateInPlane( Vector3 dir, float degrees )
	{
		float rad = degrees.DegreeToRadian();
		float cos = MathF.Cos( rad );
		float sin = MathF.Sin( rad );
		return new Vector3( dir.x * cos - dir.z * sin, 0f, dir.x * sin + dir.z * cos ).Normal;
	}
}