Ball/Ball.cs

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.

File Access
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;
	}
}