A game component implementing the Breakout ball. It controls velocity, bouncing off walls and collisions, launching/un-sticking behavior, boost particle emission and trail toggling, and notifies the BreakoutGame/Playfield of top/death events.
using System;
using System.ComponentModel.DataAnnotations;
using Sandbox;
namespace Breakout;
/// <summary>
/// The ball. Rather than letting the physics engine bounce it around freely, the ball steers
/// itself: it keeps a Direction (a unit vector) and a Speed, and every physics step it sets its
/// own velocity to Direction * Speed. "Bouncing" is just reflecting that Direction.
/// </summary>
[Title( "Ball" ), Category( "Breakout" ), Icon( "sports_baseball" )]
public sealed class Ball : Component, Component.ICollisionListener, IScenePhysicsEvents
{
/// <summary>
/// How fast the ball currently travels, in units per second.
/// </summary>
[Property] public float Speed { get; set; } = 380f;
/// <summary>
/// The speed this ball started with. Speed gets scaled up by mode progression, but this stays fixed so BallManager can recompute from the original value.
/// </summary>
public float BaseSpeed { get; private set; }
/// <summary>
/// The ball's radius, used for wall bounces and inherited by split balls.
/// </summary>
[Property] public float Radius { get; set; } = 11f;
/// <summary>
/// If true, the ball punches through breakable bricks instead of bouncing off them.
/// </summary>
[Property] public bool Piercing { get; set; } = false;
/// <summary>
/// How many hit points each brick hit removes.
/// </summary>
[Property] public int Damage { get; set; } = 1;
[Property] public TrailRenderer Trail { get; set; }
[Property, Group( "Boost" )] public GameObject BoostParticlesPrefab { get; set; }
[Property, Group( "Boost" )] public float BoostMinRate { get; set; } = 18f;
[Property, Group( "Boost" )] public float BoostMaxRate { get; set; } = 80f;
public bool Boosted { get; private set; }
[Property, Group( "Anti-Stuck" )] public float StuckWindow { get; set; } = 0.4f;
[Property, Group( "Anti-Stuck" )] public float StuckSpan { get; set; } = 50f;
/// <summary>
/// The ball's current heading as a unit vector on the X/Z plane.
/// </summary>
public Vector3 Direction { get; set; } = Vector3.Up;
/// <summary>
/// While true the ball sits frozen in place. Used while it waits to be served.
/// </summary>
public bool Stuck { get; set; }
public BreakoutGame Game { get; set; }
public Playfield Playfield { get; set; }
private Rigidbody body;
private ParticleSphereEmitter boostEmitter;
private Vector3 preStepVelocity;
private Vector3 stuckBoundsMin;
private Vector3 stuckBoundsMax;
private float stuckTimer;
private bool stuckTracking;
protected override void OnEnabled()
{
Game ??= Scene.Get<BreakoutGame>();
Playfield ??= Scene.Get<Playfield>();
GameObject.Tags.Add( "ball" );
BaseSpeed = Speed;
Trail ??= GameObject.GetComponentInChildren<TrailRenderer>();
if ( Trail.IsValid() )
Trail.Emitting = false;
SpawnBoostFx();
body = Components.Get<Rigidbody>( FindMode.EverythingInSelfAndDescendants );
ConfigureBody();
}
private void ConfigureBody()
{
if ( !body.IsValid() )
return;
// Set the body up for clean arcade motion: continuous collision so a fast ball
// can't tunnel through thin bricks, and no gravity/damping/spin. The lock keeps
// it on the X/Z play plane by pinning Y and all rotation.
body.EnhancedCcd = true;
body.Gravity = false;
body.LinearDamping = 0f;
body.AngularDamping = 0f;
body.AngularVelocity = Vector3.Zero;
body.CollisionEventsEnabled = true;
body.Locking = new PhysicsLock { Y = true, Pitch = true, Yaw = true, Roll = true };
}
// Runs just before the physics engine resolves this step's collisions. We remember the
// velocity from *before* the hit so OnCollisionStart can bounce off the direction the
// ball was actually travelling, not the tangled-up value left after the collision.
void IScenePhysicsEvents.PrePhysicsStep()
{
if ( body.IsValid() && !Stuck )
preStepVelocity = body.Velocity;
}
protected override void OnFixedUpdate()
{
if ( !body.IsValid() || Playfield is null )
return;
// A stuck ball is parked: motion off, no velocity.
if ( Stuck )
{
body.MotionEnabled = false;
body.Velocity = Vector3.Zero;
stuckTracking = false;
return;
}
body.MotionEnabled = true;
// Snap back onto the play plane if physics nudged us off it in depth (Y).
if ( MathF.Abs( WorldPosition.y - Playfield.PlayY ) > 0.01f )
WorldPosition = WorldPosition.WithY( Playfield.PlayY );
ReflectOffWalls();
// Keep a little up/down travel, then drive the body from our own Direction and Speed.
Direction = EnforceMinVertical( Direction );
body.Velocity = Direction * Speed;
WatchForWedge();
// Dropped past the bottom edge? Tell the game this ball is lost.
if ( Playfield.IsBelowDeath( WorldPosition ) )
Game?.OnBallLost( this );
}
void ICollisionListener.OnCollisionStart( Collision collision )
{
if ( Stuck )
return;
var other = collision.Other.GameObject;
if ( !other.IsValid() )
return;
if ( other.Tags.Has( "ball" ) )
return;
var contactPoint = collision.Contact.Point;
var normal = OutwardNormal( collision.Contact.Normal, contactPoint );
// Ask whatever we hit what it wants. A block or paddle handles its own reaction and
// returns whether the ball should bounce ("mirror") or carry straight on (for example
// a piercing ball punching through a brick).
var hittable = other.Components.Get<IBallHittable>( FindMode.EverythingInSelfAndAncestors );
bool mirror = hittable?.OnBallHit( this, normal, contactPoint ) ?? true;
if ( mirror )
{
// Bounce like a real ball: it leaves at the mirror of the angle it came in at.
// We flip the part of its motion heading into the wall and keep the sliding part.
var incoming = preStepVelocity.WithY( 0f );
if ( incoming.Length < 1f )
incoming = Direction * Speed;
var bounce = Sound.Play( "ball_hit_wall_01" );
if ( bounce is not null )
bounce.Pitch = Sandbox.Game.Random.Float( 0.94f, 1.08f );
// "into" is negative only when the ball is heading into the wall, so bounce then.
float into = Vector3.Dot( incoming, normal );
if ( into < 0f )
Direction = SanitizeDirection( incoming - 2f * into * normal );
}
Direction = EnforceMinVertical( Direction );
if ( body.IsValid() )
body.Velocity = Direction * Speed;
}
void ICollisionListener.OnCollisionUpdate( Collision collision ) { }
void ICollisionListener.OnCollisionStop( CollisionStop collision ) { }
/// <summary>
/// Unsticks the ball and sends it off in the given direction at its current speed.
/// </summary>
public void Launch( Vector3 direction )
{
Stuck = false;
stuckTracking = false;
Direction = SanitizeDirection( direction );
body ??= Components.Get<Rigidbody>( FindMode.EverythingInSelfAndDescendants );
if ( body.IsValid() )
{
body.MotionEnabled = true;
body.Velocity = Direction * Speed;
}
if ( Trail.IsValid() )
Trail.Emitting = true;
}
/// <summary>
/// Turns the "boosted" trail particles on or off. strength (0 to 1) scales how intense they are.
/// </summary>
public void SetBoosted( bool on, float strength = 1f )
{
Boosted = on;
if ( !boostEmitter.IsValid() )
return;
strength = strength.Clamp( 0f, 1f );
boostEmitter.Rate = on ? BoostMinRate + (BoostMaxRate - BoostMinRate) * strength : 0f;
}
private void SpawnBoostFx()
{
if ( boostEmitter.IsValid() || !BoostParticlesPrefab.IsValid() )
return;
var fx = BoostParticlesPrefab.Clone( WorldPosition );
fx.SetParent( GameObject, true );
boostEmitter = fx.Components.Get<ParticleSphereEmitter>( FindMode.EverythingInSelfAndDescendants );
if ( boostEmitter.IsValid() )
boostEmitter.Rate = 0f;
}
// Bounce off the left, right and top walls by hand. Each check flips the matching part
// of the direction and nudges the ball back inside the bounds, so it can't tunnel out at
// high speed. Reaching the top wall also tells the game (Classic mode shrinks the paddle).
private void ReflectOffWalls()
{
var pos = WorldPosition;
var dir = Direction;
if ( pos.x - Radius < Playfield.MinX && dir.x < 0 )
{
dir.x = -dir.x; pos.x = Playfield.MinX + Radius;
OnWallBounce( new Vector3( Playfield.MinX, Playfield.PlayY, pos.z ), new Vector3( 1f, 0f, 0f ) );
}
if ( pos.x + Radius > Playfield.MaxX && dir.x > 0 )
{
dir.x = -dir.x; pos.x = Playfield.MaxX - Radius;
OnWallBounce( new Vector3( Playfield.MaxX, Playfield.PlayY, pos.z ), new Vector3( -1f, 0f, 0f ) );
}
if ( pos.z + Radius > Playfield.TopZ && dir.z > 0 )
{
dir.z = -dir.z; pos.z = Playfield.TopZ - Radius;
OnWallBounce( new Vector3( pos.x, Playfield.PlayY, Playfield.TopZ ), new Vector3( 0f, 0f, -1f ) );
Game?.OnBallReachedTop();
}
WorldPosition = pos.WithY( Playfield.PlayY );
Direction = SanitizeDirection( dir );
}
private void OnWallBounce( Vector3 contact, Vector3 inwardNormal )
{
var snd = Sound.Play( "ball_hit_wall_01" );
if ( snd is not null )
{
snd.Pitch = Sandbox.Game.Random.Float( 0.94f, 1.08f );
snd.Volume = 0.7f;
}
Game?.ShakeCamera( 0.3f );
Game?.SpawnWallHit( contact, inwardNormal );
}
// Anti-stuck safety net. A ball can occasionally get wedged bouncing in a tiny area (say,
// between two blocks) and make no real progress. We track how far it roams over a short
// window; if it barely moved, Unstick() sends it back toward the middle of the screen.
private void WatchForWedge()
{
var p = WorldPosition;
if ( !stuckTracking )
{
stuckBoundsMin = p;
stuckBoundsMax = p;
stuckTimer = 0f;
stuckTracking = true;
return;
}
stuckBoundsMin = new Vector3( MathF.Min( stuckBoundsMin.x, p.x ), p.y, MathF.Min( stuckBoundsMin.z, p.z ) );
stuckBoundsMax = new Vector3( MathF.Max( stuckBoundsMax.x, p.x ), p.y, MathF.Max( stuckBoundsMax.z, p.z ) );
stuckTimer += Time.Delta;
if ( stuckTimer < StuckWindow )
return;
if ( (stuckBoundsMax - stuckBoundsMin).Length < StuckSpan )
Unstick();
stuckBoundsMin = p;
stuckBoundsMax = p;
stuckTimer = 0f;
}
// Aim a wedged ball back toward the center of the play area and push it clear.
private void Unstick()
{
var center = new Vector3( Playfield.CenterX, Playfield.PlayY, 0f );
var toCenter = (center - WorldPosition).WithY( 0f );
Direction = EnforceMinVertical( SanitizeDirection( toCenter ) );
WorldPosition = (WorldPosition + Direction * (Radius * 1.5f)).WithY( Playfield.PlayY );
if ( body.IsValid() )
body.Velocity = Direction * Speed;
}
// Work out which way the surface faces: the direction pointing straight out of it toward the
// ball. The value the physics gives us can be missing or point the wrong way, so when it looks
// off we use the line from the contact point to the ball instead.
private Vector3 OutwardNormal( Vector3 rawNormal, Vector3 contactPoint )
{
var toBall = (WorldPosition - contactPoint).WithY( 0f );
var n = rawNormal.WithY( 0f );
if ( n.Length < 0.001f )
n = toBall;
if ( n.Length < 0.001f )
return Vector3.Up;
n = n.Normal;
if ( Vector3.Dot( n, toBall ) < 0f )
n = -n;
return n;
}
// Keep at least a little up/down movement so the ball never travels almost flat, which would
// make it bounce side to side forever and feel broken.
private static Vector3 EnforceMinVertical( Vector3 dir )
{
dir = SanitizeDirection( dir );
const float minZ = 0.18f;
if ( MathF.Abs( dir.z ) < minZ )
{
dir.z = (dir.z < 0f ? -1f : 1f) * minZ;
dir = SanitizeDirection( dir );
}
return dir;
}
private static Vector3 SanitizeDirection( Vector3 dir )
{
dir = dir.WithY( 0 );
return dir.Length < 0.001f ? Vector3.Up : dir.Normal;
}
}