Entities/BlockBulletProjectile.cs

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.

File AccessNetworking
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;
}