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