Entities/Fireball.cs

A 2D entity representing a dragon fireball projectile. It handles spawning, movement, optional bouncing off arena walls and obstacles, manual sprite animation with a palette blink, and dissipation; it updates position, collision with arena and obstacles, plays sfx, and marks itself dead when done.

File Access
using Sandbox;

namespace BlockParty;

/// <summary>
/// Dragon projectile — port of the original <c>Fireball</c>. Spawns (harmless), then flies;
/// a bouncing fireball ricochets off the walls for a fixed lifetime, a normal one dissipates
/// when it leaves the playfield. Kills the player on contact (checked in Player against
/// <see cref="Hitbox"/> while not spawning/dissipating). It blinks its palette fast as a tell:
/// each baked variant sprite carries a steady and an "_alt" copy of every animation. Because the
/// blink swaps animations far faster than a frame, the sprite is driven MANUALLY: PlaybackSpeed is
/// 0 and we set <see cref="SpriteRenderer.CurrentFrameIndex"/> from our own clock — otherwise
/// engine playback would restart each <c>PlayAnimation</c> and the spawn/dissipate cycle (and the
/// teardrop's fall) would freeze on its first frames.
/// </summary>
public sealed class Fireball : Entity2D
{
	public GameStage Stage { get; set; }

	public bool IsBouncing { get; private set; }
	public bool IsSpawning { get; private set; }
	public bool IsDissipating { get; private set; }

	const float VISUAL_SIZE = 16f;
	const float BOUNCE_GEOMETRY_SIZE = 12f;
	const float PLAYER_HITBOX_SIZE = 4f;
	public RectF Hitbox => new RectF(
		X - PLAYER_HITBOX_SIZE * 0.5f, Y - PLAYER_HITBOX_SIZE * 0.5f,
		X + PLAYER_HITBOX_SIZE * 0.5f, Y + PLAYER_HITBOX_SIZE * 0.5f );

	const float SPAWN_TIME = 0.3f;
	const float DISSIPATE_TIME = 0.3f;
	const float BUFFER = 0.4f;
	float _bouncingLifetime = 16.0f;
	float _stateTimer;
	SpriteRenderer _sprite;

	// Palette blink (the "tell"). Faithful to the original: the alt palette flashes briefly
	// (ON_TIME) and the steady palette holds longer (NORMAL/BOUNCING).
	const float PALETTE_BLINK_TIME_NORMAL = 0.15f;
	const float PALETTE_BLINK_TIME_BOUNCING = 0.1f;
	const float PALETTE_BLINK_ON_TIME = 0.05f;
	float _paletteBlinkTimer;
	bool _paletteAlt;

	// Manual animation state (all anims are 6 frames). Period = seconds for one full play.
	string _baseAnim = "spawn";
	float _animTime;
	float _animPeriod = SPAWN_TIME;
	bool _animLoop;
	const int FRAMES = 6;
	string _curAnim;

	public void Setup( Vector2 velocity, bool bouncing )
	{
		Velocity = velocity;
		IsBouncing = bouncing;
		float geometrySize = bouncing ? BOUNCE_GEOMETRY_SIZE : VISUAL_SIZE;
		Size = new Vector2( geometrySize, geometrySize );
		IsSpawning = true;
		_stateTimer = SPAWN_TIME;
	}

	public void CreateVisuals()
	{
		_sprite = SpriteLayer.Add( GameObject, IsBouncing ? "sprites/fireball_bounce.sprite" : "sprites/fireball.sprite",
			new Vector2( VISUAL_SIZE, VISUAL_SIZE ), "spawn" );
		_sprite.PlaybackSpeed = 0f; // we scrub frames ourselves via CurrentFrameIndex
		_sprite.FlipHorizontal = Velocity.x < 0;
		SetAnim( "spawn", SPAWN_TIME, looping: false );
	}

	public override void Tick( float dt )
	{
		BlinkPalette( dt );

		if ( IsSpawning )
		{
			_stateTimer -= dt;
			if ( _stateTimer <= 0f )
			{
				IsSpawning = false;
				SetAnim( "loop", 0.25f, looping: true );
			}
		}

		if ( IsDissipating )
		{
			_stateTimer -= dt;
			UpdateAnim( dt );
			if ( _stateTimer <= 0f )
				Dead = true;
			return;
		}

		if ( IsBouncing )
		{
			_bouncingLifetime -= dt;
			if ( _bouncingLifetime <= 0f ) { Dissipate(); return; }
		}

		Pos += Velocity * dt;

		if ( IsOffscreen() )
		{
			if ( IsBouncing )
			{
				// Snap flush to the wall band edge BEFORE reversing, exactly like the obstacle reflect
				// path (which pins the fireball to the face it hit). Without this the fireball can sit
				// DEEP inside the trigger band after the flip; a wind/magnet lane that keeps pushing it
				// back into the wall faster than the reversed velocity carries it out then re-triggers
				// the offscreen test every step — flipping the velocity and replaying the bounce sfx
				// forever (a slow 15-33 px/s fireball can't clear the 6.4px band in one step against an
				// 80 px/s lane). Pinning it to the band edge guarantees one reversed step leaves the band.
				if ( X > Arena.WIDTH - (Size.x * BUFFER) )
				{
					X = Arena.WIDTH - (Size.x * BUFFER);
					VelX *= -1;
					if ( _sprite is not null ) _sprite.FlipHorizontal = VelX < 0;
				}
				else if ( X < (Size.x * BUFFER) )
				{
					X = Size.x * BUFFER;
					VelX *= -1;
					if ( _sprite is not null ) _sprite.FlipHorizontal = VelX < 0;
				}
				else if ( Y > Arena.HEIGHT - (Size.y * BUFFER) )
				{
					Y = Arena.HEIGHT - (Size.y * BUFFER);
					VelY *= -1;
				}
				else if ( Y < (Size.y * BUFFER) )
				{
					Y = Size.y * BUFFER;
					VelY *= -1;
				}
				Audio.PlaySfx( SfxType.FireballBounce, Position, 0.75f );
			}
			else
			{
				Dissipate();
			}
		}

		// Interior obstacles: a bouncing fireball ricochets off the face it hit (axis-aligned, like the
		// outer walls); a straight one dissipates rather than flying through the solid. No-op with none.
		if ( Stage is not null )
		{
			foreach ( Entity2D ob in Stage.GetSolidObstacles() )
			{
				if ( !GetRect().Intersects( ob.GetRect() ) ) continue;

				if ( IsBouncing )
				{
					ReflectOffObstacle( ob );
					Audio.PlaySfx( SfxType.FireballBounce, Position, 0.75f );
				}
				else
				{
					Dissipate();
				}
				break;
			}
		}

		UpdateAnim( dt );
	}

	/// <summary>Reflect off the obstacle along the min-penetration axis and snap flush to that face
	/// (shared geometry in <see cref="Entity2D.ResolveMinPenetration"/>).</summary>
	void ReflectOffObstacle( Entity2D ob )
	{
		switch ( ResolveMinPenetration( ob.GetRect() ) )
		{
			case ReflectAxis.Horizontal:
				VelX *= -1;
				if ( _sprite is not null ) _sprite.FlipHorizontal = VelX < 0;
				break;
			case ReflectAxis.Vertical:
				VelY *= -1;
				break;
		}
	}

	void Dissipate()
	{
		if ( IsDissipating ) return;
		IsDissipating = true;
		_stateTimer = DISSIPATE_TIME;
		SetAnim( "dissipate", DISSIPATE_TIME, looping: false );
		Audio.PlaySfx( SfxType.FireballDissipate, Position );
	}

	/// <summary>Switch the logical animation, restarting its manual clock.</summary>
	void SetAnim( string baseName, float period, bool looping )
	{
		_baseAnim = baseName;
		_animPeriod = period;
		_animLoop = looping;
		_animTime = 0f;
		ApplyFrame();
	}

	/// <summary>Advance our own clock and push the resulting frame to the (paused) renderer.</summary>
	void UpdateAnim( float dt )
	{
		_animTime += dt;
		ApplyFrame();
	}

	void ApplyFrame()
	{
		if ( _sprite is null ) return;
		// PlayAnimation only when the resolved name changes (palette toggle or base switch); it
		// resets the clock to frame 0, but PlaybackSpeed=0 means CurrentFrameIndex then sticks.
		string name = _paletteAlt ? _baseAnim + "_alt" : _baseAnim;
		if ( name != _curAnim )
		{
			_sprite.PlayAnimation( name );
			_curAnim = name;
		}
		float t = _animPeriod > 0f ? _animTime / _animPeriod : 0f;
		int frame = (int)(t * FRAMES);
		frame = _animLoop ? ((frame % FRAMES) + FRAMES) % FRAMES : Math.Clamp( frame, 0, FRAMES - 1 );
		_sprite.CurrentFrameIndex = frame;
	}

	void BlinkPalette( float dt )
	{
		_paletteBlinkTimer -= dt;
		if ( _paletteBlinkTimer > 0f ) return;

		_paletteAlt = !_paletteAlt;
		ApplyFrame(); // swap palette without disturbing the clock
		_paletteBlinkTimer = _paletteAlt
			? PALETTE_BLINK_ON_TIME
			: (IsBouncing ? PALETTE_BLINK_TIME_BOUNCING : PALETTE_BLINK_TIME_NORMAL);
	}

	bool IsOffscreen()
	{
		return X > Arena.WIDTH - (Size.x * BUFFER) || X < (Size.x * BUFFER) ||
			   Y > Arena.HEIGHT - (Size.y * BUFFER) || Y < (Size.y * BUFFER);
	}
}