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