Paddle/Paddle.cs

A Paddle component for a Breakout-style game. It handles player input to move the paddle, manages width (expands/shrinks and lerps toward a target), applies squash-and-stretch hit reaction, and computes bounce direction when the ball hits the paddle.

NetworkingFile Access
using System;
using Sandbox;

namespace Breakout;

/// <summary>
/// The player's paddle. It slides left and right to follow your aim (mouse or controller stick)
/// and knocks the ball back up. Where the ball lands on the paddle sets the bounce angle (catch it
/// near an edge for a sharper angle), which is how the player aims. It can grow (wide-paddle
/// pickup), shrink (a Classic-mode penalty), and does a little squash-and-stretch wobble when hit.
/// </summary>
[Title( "Paddle" ), Category( "Breakout" ), Icon( "remove" )]
public sealed class Paddle : Component, IBallHittable
{
	[Property] public float Width { get; set; } = 96f;
	[Property] public float MaxWidth { get; set; } = 210f;
	[Property] public float MaxBounceAngle { get; set; } = 60f;
	[Property] public float ModelUnitWidth { get; set; } = 96f;
	[Property] public float WidthLerpSpeed { get; set; } = 8f;
	[Property] public float LookSensitivity { get; set; } = 8f;
	[Property, Group( "Hit Reaction" )] public float HitBounceStrength { get; set; } = 5f;

	public Playfield Playfield { get; set; }

	/// <summary>
	/// Half the paddle's current visible width. Used to clamp movement and to work out where the ball struck.
	/// </summary>
	public float HalfWidth => (currentWidth > 0f ? currentWidth : Width) * 0.5f;

	private const float BounceStiffness = 300f;

	private const float BounceDamping = 12f;

	private Vector3 baseScale;

	private float currentWidth;

	private float shrinkFactor = 1f;

	private float squashPos;

	private float squashVel;

	private BreakoutGame game;

	protected override void OnEnabled()
	{
		Playfield ??= Scene.Get<Playfield>();
		baseScale = LocalScale;
		currentWidth = TargetWidth;
		GameObject.Tags.Add( "paddle" );
	}

	protected override void OnUpdate()
	{
		if ( Playfield is null )
			return;

		UpdateWidth();
		FollowLook();
		UpdateHitReaction();
		ApplyWidthScale();
	}

	private void FollowLook()
	{
		game ??= Scene.Get<BreakoutGame>();
		if ( game is not null && !game.PaddleControlActive )
			return;

		// Aim with either the mouse or a controller. Input.AnalogLook is how far the player looked
		// this frame in degrees, and it already blends mouse and gamepad input, so both just work.
		// Yaw counts as positive when looking left, so we flip the sign to make "look right" push
		// the paddle right, then clamp so it can't leave the play area.
		float move = -Input.AnalogLook.yaw * LookSensitivity;

		//Move with wasd and left stick.
		move += -Input.AnalogMove.y * LookSensitivity;

		//We want to move with the Dpad.
		move += Input.Down( "arrow_left" ) ? 1 : 0;
		move += Input.Down( "arrow_right" ) ? -1 : 0;

		var pos = WorldPosition;
		pos.x = Playfield.ClampX( WorldPosition.x + move, HalfWidth );
		pos.y = Playfield.PlayY;
		WorldPosition = pos;
	}

	/// <summary>
	/// The width the paddle is aiming for right now: start from the base Width, multiply in every
	/// active expand pickup, apply the Classic-mode shrink, then cap at MaxWidth. UpdateWidth eases
	/// the visible width toward this each frame.
	/// </summary>
	public float TargetWidth
	{
		get
		{
			float factor = 1f;
			foreach ( var fx in Components.GetAll<PaddleExpandEffect>( FindMode.EverythingInSelfAndDescendants ) )
				factor *= fx.Multiplier;

			float w = Width * factor * shrinkFactor;
			return MaxWidth > 0f ? MathF.Min( w, MaxWidth ) : w;
		}
	}

	/// <summary>
	/// Removes every active expand pickup so the paddle returns to its normal width.
	/// </summary>
	public void ClearExpandEffects()
	{
		foreach ( var fx in Components.GetAll<PaddleExpandEffect>( FindMode.EverythingInSelfAndDescendants ) )
			fx.Destroy();
	}

	/// <summary>
	/// True while the Classic-mode shrink penalty is active.
	/// </summary>
	public bool IsShrunk { get; private set; }

	/// <summary>
	/// Temporarily scales the paddle down (Classic mode shrinks it once the ball reaches the top
	/// wall). Composes with expand effects through TargetWidth; ResetWidth() restores full size.
	/// </summary>
	public void Shrink( float factor )
	{
		shrinkFactor = factor;
		IsShrunk = true;
	}

	/// <summary>
	/// Undoes Shrink and returns the paddle to full width.
	/// </summary>
	public void ResetWidth()
	{
		shrinkFactor = 1f;
		IsShrunk = false;
	}

	private void UpdateWidth()
	{
		float target = TargetWidth;
		if ( currentWidth <= 0f )
			currentWidth = target;

		float t = MathF.Min( 1f, WidthLerpSpeed * Time.Delta );
		currentWidth += (target - currentWidth) * t;

		if ( MathF.Abs( target - currentWidth ) < 0.05f )
			currentWidth = target;
	}

	// A little spring that settles the squash wobble back to rest. squashPos is how squashed the
	// paddle is right now; a ball hit shoves it (squashVel), and this pulls it back with some
	// bounce (stiffness) while slowing it down over time (damping).
	private void UpdateHitReaction()
	{
		if ( MathF.Abs( squashPos ) < 0.0001f && MathF.Abs( squashVel ) < 0.0001f )
		{
			squashPos = 0f;
			squashVel = 0f;
			return;
		}

		float dt = MathF.Min( Time.Delta, 0.05f );
		squashVel += (-BounceStiffness * squashPos - BounceDamping * squashVel) * dt;
		squashPos += squashVel * dt;
	}

	private void ApplyWidthScale()
	{
		float sx = baseScale.x;
		if ( ModelUnitWidth > 0f )
			sx = currentWidth / ModelUnitWidth;

		float squashZ = MathF.Max( 0.2f, 1f - squashPos );
		float squashX = 1f + squashPos * 0.6f;

		LocalScale = new Vector3( sx * squashX, baseScale.y, baseScale.z * squashZ );
	}

	bool IBallHittable.OnBallHit( Ball ball, Vector3 hitNormal, Vector3 hitPoint )
	{
		// Ignore a ball that's already heading upward so it can't snag on the paddle's underside.
		if ( ball.Direction.z > 0f )
			return false;

		// Where along the paddle did it land? -1 is the far left edge, 0 the middle, +1 the far
		// right. That offset scales the launch angle, so edge hits fire off at a steeper angle
		// and the player can aim by moving the paddle under the ball.
		float offset = ((ball.WorldPosition.x - WorldPosition.x) / HalfWidth).Clamp( -1f, 1f );
		float radians = (offset * MaxBounceAngle).DegreeToRadian();

		var thud = Sound.Play( "ball_impact_pad_01" );
		if ( thud is not null )
			thud.Pitch = Sandbox.Game.Random.Float( 0.95f, 1.06f );

		squashVel += HitBounceStrength;

		game ??= Scene.Get<BreakoutGame>();
		game?.OnPaddleBounce();

		ball.Direction = new Vector3( MathF.Sin( radians ), 0f, MathF.Cos( radians ) );

		game?.SpawnWallHit( hitPoint, hitNormal );

		// We set the ball's new direction ourselves, so tell it not to run its own bounce too.
		return false;
	}
}