Entity for a hostile aimed projectile (fireball) used in the BlockParty game. It handles spawn, acceleration, movement, optional ricochet from arena walls and authored obstacles, animation/palette blinking, collision with players, and timed dissipation.
namespace BlockParty;
public enum BlockBulletVariant
{
BulletPhase1,
BulletPhase2,
DragonBouncePhase1,
DragonBouncePhase2,
}
/// <summary>
/// Hostile aimed block projectile. It accelerates from rest, always ignores blocks, and either
/// leaves the arena or ricochets from authored geometry according to its variant.
/// </summary>
public sealed class BlockBulletProjectile : Entity2D
{
private const float VISUAL_SIZE = 16f;
private const float BOUNCE_GEOMETRY_SIZE = 12f;
private const float PLAYER_HITBOX_SIZE = 4f;
private const float BULLET_PHASE_1_SPEED = 70f;
private const float BULLET_PHASE_2_SPEED = 120f;
private const float DRAGON_BOUNCE_PHASE_1_SPEED = 50f;
private const float DRAGON_BOUNCE_PHASE_2_SPEED = 100f;
private const float ACCELERATION_TIME = 2f;
private const float SPAWN_TIME = 0.3f;
private const float STRAIGHT_LIFETIME = 6f;
private const float BOUNCING_LIFETIME = 12f;
private const float DISSIPATE_TIME = 0.3f;
private const float MAX_LEAD_ANGLE_DEGREES = 16f;
private const float LEAD_FACTOR_MIN = 0f;
private const float LEAD_FACTOR_MAX = 1.5f;
private const float MIN_SURFACE_ANGLE_DEGREES = 7f;
private const float BOUNCE_SPEED_FACTOR = 0.25f;
private const float BOUNCE_RECOVERY_TIME = 0.45f;
private const float PALETTE_BLINK_TIME = 0.1f;
private const float PALETTE_BLINK_ON_TIME = 0.05f;
private const float LOOP_ANIMATION_TIME = 0.25f;
private const int ANIMATION_FRAMES = 6;
private const int PHASE_2_FIREBALL_LAYER_OFFSET = 1;
private const int PHASE_1_WALL_LAYER_OFFSET = 5;
private const float MAX_STEP = 1.5f;
private static readonly float MinSurfaceNormalDot = MathF.Sin( MIN_SURFACE_ANGLE_DEGREES * MathF.PI / 180f );
private Vector2 _direction;
private BlockBulletVariant _variant;
private bool _spawning;
private bool _dissipating;
private bool _hasBounced;
private float _age;
private float _bounceRecoveryElapsed;
private float _spawnTimer;
private float _dissipateTimer;
private float _paletteBlinkTimer;
private bool _paletteAlt;
private string _baseAnimation = "loop";
private float _animationTime;
private float _animationPeriod = LOOP_ANIMATION_TIME;
private bool _animationLoops = true;
private string _currentAnimation;
private SpriteRenderer _sprite;
public GameStage Stage { get; set; }
public RectF Hitbox => new(
X - PLAYER_HITBOX_SIZE * 0.5f, Y - PLAYER_HITBOX_SIZE * 0.5f,
X + PLAYER_HITBOX_SIZE * 0.5f, Y + PLAYER_HITBOX_SIZE * 0.5f );
public bool Bouncing => IsBouncing( _variant );
public static bool IsBouncing( BlockBulletVariant variant )
=> variant is BlockBulletVariant.DragonBouncePhase1 or BlockBulletVariant.DragonBouncePhase2;
public static float FullSpeed( BlockBulletVariant variant ) => variant switch
{
BlockBulletVariant.BulletPhase1 => BULLET_PHASE_1_SPEED,
BlockBulletVariant.BulletPhase2 => BULLET_PHASE_2_SPEED,
BlockBulletVariant.DragonBouncePhase1 => DRAGON_BOUNCE_PHASE_1_SPEED,
BlockBulletVariant.DragonBouncePhase2 => DRAGON_BOUNCE_PHASE_2_SPEED,
_ => BULLET_PHASE_2_SPEED,
};
public static Vector2 LeadDirection( Vector2 origin, Player player, float projectileSpeed )
=> LeadDirection( origin, player, projectileSpeed, ACCELERATION_TIME, 0f );
private static Vector2 LeadDirection(
Vector2 origin, Player player, float projectileSpeed, float rampTime, float initialSpeedFactor )
{
Vector2 toPlayer = player.Position - origin;
Vector2 directAim = Utils.Normalized( toPlayer );
if ( directAim == Vector2.Zero ) return Vector2.Down;
float travelTime = TravelTimeForDistance( toPlayer.Length, projectileSpeed, rampTime, initialSpeedFactor );
float leadFactor = Rng.Float( LEAD_FACTOR_MIN, LEAD_FACTOR_MAX );
Vector2 playerVelocity = player.Velocity + new Vector2( player.ExtraVelX, player.ExtraVelY );
Vector2 direction = Utils.Normalized( toPlayer + playerVelocity * travelTime * leadFactor );
if ( direction == Vector2.Zero ) return directAim;
float cross = directAim.x * direction.y - directAim.y * direction.x;
float leadDegrees = Utils.Rad2Deg( MathF.Atan2( cross, Vector2.Dot( directAim, direction ) ) );
if ( Math.Abs( leadDegrees ) <= MAX_LEAD_ANGLE_DEGREES ) return direction;
return Utils.RotateVector( directAim, Math.Sign( leadDegrees ) * MAX_LEAD_ANGLE_DEGREES );
}
private static float TravelTimeForDistance(
float distance, float fullSpeed, float rampTime, float initialSpeedFactor )
{
if ( distance <= 0f || fullSpeed <= 0f ) return 0f;
if ( rampTime <= 0f ) return distance / fullSpeed;
initialSpeedFactor = Math.Clamp( initialSpeedFactor, 0f, 1f );
float rampDistance = fullSpeed * rampTime * (1f + initialSpeedFactor) * 0.5f;
if ( distance >= rampDistance )
return rampTime + (distance - rampDistance) / fullSpeed;
float targetDistance = distance / (fullSpeed * rampTime);
float low = 0f;
float high = 1f;
for ( int i = 0; i < 16; i++ )
{
float progress = (low + high) * 0.5f;
float smoothstepDistance = progress * progress * progress
- 0.5f * progress * progress * progress * progress;
float traveled = initialSpeedFactor * progress
+ (1f - initialSpeedFactor) * smoothstepDistance;
if ( traveled < targetDistance ) low = progress;
else high = progress;
}
return (low + high) * 0.5f * rampTime;
}
public void Setup( Vector2 direction, BlockBulletVariant variant )
{
_direction = Utils.Normalized( direction );
_variant = variant;
_spawning = true;
_spawnTimer = SPAWN_TIME;
float geometrySize = Bouncing ? BOUNCE_GEOMETRY_SIZE : VISUAL_SIZE;
Size = new Vector2( geometrySize, geometrySize );
}
public void CreateVisuals()
{
string sprite = _variant switch
{
BlockBulletVariant.BulletPhase1 => "sprites/fireball_bullet_slow.sprite",
BlockBulletVariant.BulletPhase2 => "sprites/fireball_bullet.sprite",
BlockBulletVariant.DragonBouncePhase1 => "sprites/fireball_dragon_bounce.sprite",
BlockBulletVariant.DragonBouncePhase2 => "sprites/fireball_dragon_bounce_fast.sprite",
_ => "sprites/fireball_bullet.sprite",
};
int childOrder = Bouncing ? PHASE_2_FIREBALL_LAYER_OFFSET : PHASE_1_WALL_LAYER_OFFSET;
_sprite = SpriteLayer.Add( GameObject, sprite, new Vector2( VISUAL_SIZE, VISUAL_SIZE ), "spawn", childOrder );
_sprite.PlaybackSpeed = 0f;
_sprite.FlipHorizontal = _direction.x < 0f;
SetAnimation( "spawn", SPAWN_TIME, looping: false );
}
public override void Tick( float dt )
{
if ( Bouncing )
BlinkPalette( dt );
if ( _dissipating )
{
_dissipateTimer -= dt;
UpdateAnimation( dt );
if ( _dissipateTimer <= 0f )
Dead = true;
return;
}
if ( _spawning )
{
_spawnTimer -= dt;
if ( _spawnTimer <= 0f )
{
_spawning = false;
SetAnimation( "loop", LOOP_ANIMATION_TIME, looping: true );
}
}
_age += dt;
if ( _hasBounced )
_bounceRecoveryElapsed += dt;
float lifetime = Bouncing ? BOUNCING_LIFETIME : STRAIGHT_LIFETIME;
if ( _age >= lifetime ) { Dissipate(); return; }
float speedFactor = CurrentSpeedFactor();
float speed = FullSpeed( _variant );
float remainingDistance = speed * speedFactor * dt;
while ( remainingDistance > 0f && !Dead )
{
float distance = Math.Min( MAX_STEP, remainingDistance );
Pos += _direction * distance;
remainingDistance -= distance;
if ( Bouncing )
BounceFromGeometry();
else if ( IsOutsideArena() )
{
Dissipate();
break;
}
if ( !_spawning )
TryHitPlayers();
}
if ( _dissipating ) return;
Velocity = _direction * speed * CurrentSpeedFactor();
if ( _sprite is not null ) _sprite.FlipHorizontal = _direction.x < 0f;
UpdateAnimation( dt );
}
private float CurrentSpeedFactor()
{
float progress;
if ( _hasBounced )
{
progress = Math.Clamp( _bounceRecoveryElapsed / BOUNCE_RECOVERY_TIME, 0f, 1f );
progress = progress * progress * (3f - 2f * progress);
return BOUNCE_SPEED_FACTOR + (1f - BOUNCE_SPEED_FACTOR) * progress;
}
progress = Math.Clamp( _age / ACCELERATION_TIME, 0f, 1f );
return progress * progress * (3f - 2f * progress);
}
private void BounceFromGeometry()
{
float left = Arena.WALL_SIZE + Width * 0.5f;
float right = Arena.WIDTH - Arena.WALL_SIZE - Width * 0.5f;
float bottom = Arena.WALL_SIZE + Height * 0.5f;
float top = Arena.HEIGHT - Arena.WALL_SIZE - Height * 0.5f;
bool bounced = false;
Vector2 normalA = Vector2.Zero;
Vector2 normalB = Vector2.Zero;
Vector2 normalC = Vector2.Zero;
int normalCount = 0;
if ( X < left ) { X = left; _direction.x = Math.Abs( _direction.x ); AddNormal( new Vector2( 1f, 0f ) ); bounced = true; }
else if ( X > right ) { X = right; _direction.x = -Math.Abs( _direction.x ); AddNormal( new Vector2( -1f, 0f ) ); bounced = true; }
if ( Y < bottom ) { Y = bottom; _direction.y = Math.Abs( _direction.y ); AddNormal( new Vector2( 0f, 1f ) ); bounced = true; }
else if ( Y > top ) { Y = top; _direction.y = -Math.Abs( _direction.y ); AddNormal( new Vector2( 0f, -1f ) ); bounced = true; }
if ( Stage is not null )
{
foreach ( Obstacle obstacle in Stage.GetObstacles() )
{
if ( obstacle.IsGlass ) continue; // hazards pass straight through glass panes
if ( !GetRect().Intersects( obstacle.GetRect() ) ) continue;
switch ( ResolveMinPenetration( obstacle.GetRect() ) )
{
case ReflectAxis.Horizontal:
float normalX = X < obstacle.X ? -1f : 1f;
_direction.x = normalX * Math.Abs( _direction.x );
AddNormal( new Vector2( normalX, 0f ) );
break;
case ReflectAxis.Vertical:
float normalY = Y < obstacle.Y ? -1f : 1f;
_direction.y = normalY * Math.Abs( _direction.y );
AddNormal( new Vector2( 0f, normalY ) );
break;
}
bounced = true;
break;
}
}
if ( bounced )
{
_hasBounced = true;
_bounceRecoveryElapsed = 0f;
RedirectBounceTowardPlayer( normalA, normalB, normalC, normalCount );
Audio.PlaySfx( SfxType.FireballBounce, Position, 0.65f, 1.25f );
}
void AddNormal( Vector2 normal )
{
if ( normal == Vector2.Zero ) return;
if ( normalCount > 0 && Vector2.Dot( normalA, normal ) > 0.999f ) return;
if ( normalCount > 1 && Vector2.Dot( normalB, normal ) > 0.999f ) return;
if ( normalCount > 2 && Vector2.Dot( normalC, normal ) > 0.999f ) return;
switch ( normalCount++ )
{
case 0: normalA = normal; break;
case 1: normalB = normal; break;
case 2: normalC = normal; break;
}
}
}
private void RedirectBounceTowardPlayer( Vector2 normalA, Vector2 normalB, Vector2 normalC, int normalCount )
{
Player player = Stage?.ClosestTargetablePlayer( Position );
if ( player is null ) return;
Vector2 candidate = LeadDirection(
Position, player, FullSpeed( _variant ), BOUNCE_RECOVERY_TIME, BOUNCE_SPEED_FACTOR );
if ( normalCount > 0 && Vector2.Dot( candidate, normalA ) < MinSurfaceNormalDot ) return;
if ( normalCount > 1 && Vector2.Dot( candidate, normalB ) < MinSurfaceNormalDot ) return;
if ( normalCount > 2 && Vector2.Dot( candidate, normalC ) < MinSurfaceNormalDot ) return;
_direction = candidate;
}
private void Dissipate()
{
if ( _dissipating ) return;
_dissipating = true;
_dissipateTimer = DISSIPATE_TIME;
Velocity = Vector2.Zero;
SetAnimation( "dissipate", DISSIPATE_TIME, looping: false );
Audio.PlaySfx( SfxType.FireballDissipate, Position );
}
private void SetAnimation( string baseName, float period, bool looping )
{
_baseAnimation = baseName;
_animationPeriod = period;
_animationLoops = looping;
_animationTime = 0f;
ApplyAnimationFrame();
}
private void UpdateAnimation( float dt )
{
_animationTime += dt;
ApplyAnimationFrame();
}
private void ApplyAnimationFrame()
{
if ( _sprite is null ) return;
string animation = _paletteAlt ? _baseAnimation + "_alt" : _baseAnimation;
if ( animation != _currentAnimation )
{
_sprite.PlayAnimation( animation );
_currentAnimation = animation;
}
float progress = _animationPeriod > 0f ? _animationTime / _animationPeriod : 0f;
int frame = (int)(progress * ANIMATION_FRAMES);
frame = _animationLoops
? ((frame % ANIMATION_FRAMES) + ANIMATION_FRAMES) % ANIMATION_FRAMES
: Math.Clamp( frame, 0, ANIMATION_FRAMES - 1 );
_sprite.CurrentFrameIndex = frame;
}
private void BlinkPalette( float dt )
{
_paletteBlinkTimer -= dt;
if ( _paletteBlinkTimer > 0f ) return;
_paletteAlt = !_paletteAlt;
ApplyAnimationFrame();
_paletteBlinkTimer = _paletteAlt ? PALETTE_BLINK_ON_TIME : PALETTE_BLINK_TIME;
}
private void TryHitPlayers()
{
if ( Stage is null ) return;
foreach ( Player player in Stage.LivingPlayers )
{
if ( !player.GetRect().Intersects( Hitbox ) ) continue;
player.KilledByFireball( Position );
}
foreach ( Player clone in Stage.LivingFriendlyClones )
{
if ( !clone.GetRect().Intersects( Hitbox ) ) continue;
clone.KilledByFireball( Position );
}
}
private bool IsOutsideArena()
=> Right < 0f || Left > Arena.WIDTH || Top < 0f || Bottom > Arena.HEIGHT;
}