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