Game/GameFeedback.cs

A component that manages purely cosmetic game feedback for Breakout: center banner text, damage flash sequencing, camera shake, debris/wall-hit/confetti prefabs, and a clear-sting sound. It exposes banner and damage state for the HUD and spawns particle/gameobject effects.

File Access
using System;
using Sandbox;

namespace Breakout;

/// <summary>
/// Owns purely-cosmetic game juice: the center banner, the damage flash, camera shake, brick
/// debris, wall-hit sparks, confetti and the board-clear sting. BreakoutGame delegates here and
/// re-exposes the banner/damage state for the HUD.
/// </summary>
[Title( "Game Feedback" ), Category( "Breakout" ), Icon( "auto_awesome" )]
public sealed class GameFeedback : Component
{
	[Property] public GameObject DebrisPrefab { get; set; }
	[Property] public GameObject WallHitPrefab { get; set; }
	[Property] public GameObject ConfettiPrefab { get; set; }
	[Property] public float LifeLostShake { get; set; } = 0.65f;

	/// <summary>
	/// Big center banner text, like "LEVEL 1" or "BOARD CLEAR!".
	/// </summary>
	public string BannerText { get; private set; }

	/// <summary>
	/// Smaller line shown beneath the banner (may be null).
	/// </summary>
	public string BannerSub { get; private set; }

	/// <summary>
	/// Ticks up each time a new banner is shown, so the HUD can restart its animation.
	/// </summary>
	public int BannerSeq { get; private set; }

	/// <summary>
	/// True while the banner should currently be visible.
	/// </summary>
	public bool BannerActive => !string.IsNullOrEmpty( BannerText ) && bannerSince < bannerHold;

	/// <summary>
	/// Ticks up each time the player takes damage, so the HUD can restart the red flash.
	/// </summary>
	public int DamageSeq { get; private set; }

	/// <summary>
	/// True while the red damage flash should currently be showing.
	/// </summary>
	public bool DamageActive => DamageSeq > 0 && damageSince < DamageFlashHold;

	private RealTimeSince bannerSince;
	private float bannerHold;
	private RealTimeSince damageSince;
	private const float DamageFlashHold = 0.05f;

	/// <summary>
	/// Shows the center banner with optional sub-text for hold seconds.
	/// </summary>
	public void ShowBanner( string text, string sub = null, float hold = 1.1f )
	{
		BannerText = text;
		BannerSub = sub;
		bannerSince = 0f;
		bannerHold = hold;
		BannerSeq++;
	}

	/// <summary>
	/// Kicks off the red screen flash and a small camera shake when the player loses a life.
	/// </summary>
	public void TriggerDamageFeedback()
	{
		damageSince = 0f;
		DamageSeq++;
		ShakeCamera( LifeLostShake );
	}

	/// <summary>
	/// Shakes the camera. Bigger amount means a stronger, longer shake.
	/// </summary>
	public void ShakeCamera( float amount )
	{
		if ( amount <= 0f )
			return;

		Scene.Camera.AddShake( amount * 20, amount * 10f, 0.5f );
	}

	/// <summary>
	/// Spawns the shatter particles for a broken brick, tinted to match it.
	/// </summary>
	public void SpawnDebris( Block block )
	{
		if ( !block.IsValid() || DebrisPrefab is null )
			return;

		var go = DebrisPrefab.Clone( new CloneConfig( new Transform( block.WorldPosition ), startEnabled: false ) );

		foreach ( var fx in go.Components.GetAll<ParticleEffect>( FindMode.EverythingInSelfAndDescendants ) )
			fx.Tint = block.HealthyTint;

		go.Enabled = true;
	}

	/// <summary>
	/// Spawns a spark burst facing inward where the ball hit a wall or the paddle.
	/// </summary>
	public void SpawnWallHit( Vector3 worldPos, Vector3 inwardNormal )
	{
		if ( WallHitPrefab is null )
			return;

		var n = inwardNormal.Normal;
		var up = MathF.Abs( n.z ) > 0.99f ? Vector3.Forward : Vector3.Up;
		var rot = Rotation.LookAt( n, up );
		WallHitPrefab.Clone( new CloneConfig( new Transform( worldPos, rot ), name: "wall_hit" ) );
	}

	/// <summary>
	/// Spawns the celebratory confetti burst used on a board clear.
	/// </summary>
	public void SpawnConfetti( Vector3 worldPos ) => ConfettiPrefab?.Clone( worldPos );

	/// <summary>
	/// Plays the rising three-note chime used when a board is cleared.
	/// </summary>
	public void PlayClearSting()
	{
		float[] pitches = { 1.15f, 1.5f, 1.9f };
		foreach ( var pitch in pitches )
		{
			var note = Sound.Play( "brick_break_01" );
			if ( note is not null )
				note.Pitch = pitch;
		}
	}
}