Entities/SwapperProjectile.cs
namespace BlockParty;

/// <summary>A slower magical bolt that moves its owning Swapper portal to the first solid it hits. Collision
/// is swept like the Gunner's <see cref="Bullet"/> (see <see cref="ProjectileCollision"/>): the earliest surface
/// on each step wins, and the first step starts back at the caster's centre so a solid hugging them is hit.</summary>
public sealed class SwapperProjectile : Entity2D
{
	private const int HITBOX_SIZE = 3;
	private const float MAX_LIFETIME = 8f;
	private const float TRAIL_INTERVAL = 0.05f;
	private const int IMPACT_PARTICLES = 18;
	private const int DEPARTURE_PARTICLES = 12;
	private static readonly Color OuterColor = new( 72 / 255f, 205 / 255f, 218 / 255f, 0.58f );
	private static readonly Color CoreColor = new( 180 / 255f, 248 / 255f, 1f, 0.95f );
	private static readonly Color AccentColor = new( 1f, 0.82f, 0.38f, 0.9f );

	private Vector2 _direction;
	private Portal _portal;
	private int _impactHitStopFrames;
	private float _portalSurfacePadding;
	private float _age;
	private float _trailTimer;
	private float _visualPhase;
	private SpriteRenderer _outerSprite;
	private SpriteRenderer _coreSprite;
	private SpriteRenderer _accentSprite;

	// The caster's centre at fire time; the first step sweeps from here (the muzzle spawns clear of the body).
	private Vector2 _sweepOrigin;
	private bool _sweepFromOrigin;

	public GameStage Stage { get; set; }
	public Portal TargetPortal => _portal;

	public void Setup( Vector2 direction, Vector2 sweepOrigin, Portal portal, float speed, int impactHitStopFrames, float portalSurfacePadding )
	{
		_direction = Utils.Normalized( direction );
		_sweepOrigin = sweepOrigin;
		_sweepFromOrigin = true;
		_portal = portal;
		_impactHitStopFrames = Math.Max( 0, impactHitStopFrames );
		_portalSurfacePadding = Math.Max( 0f, portalSurfacePadding );
		Velocity = _direction * Math.Max( 1f, speed );
		Size = new Vector2( HITBOX_SIZE, HITBOX_SIZE );
	}

	public void CreateVisuals()
	{
		_outerSprite = AddLayer( new Vector2( 7f, 7f ), OuterColor, 0 );
		_coreSprite = AddLayer( new Vector2( 4f, 4f ), CoreColor, 1 );
		_accentSprite = AddLayer( new Vector2( 2f, 2f ), AccentColor, 2 );
	}

	public override void Tick( float dt )
	{
		_age += dt;
		if ( _age > MAX_LIFETIME || _portal is null )
		{
			Dead = true;
			return;
		}

		_visualPhase += dt * 4f;
		_visualPhase -= MathF.Floor( _visualPhase );
		float pulse = 0.5f - 0.5f * MathF.Cos( _visualPhase * MathF.Tau );
		_outerSprite.Size = new Vector2( 6.25f + pulse, 6.25f + pulse );
		_coreSprite.Size = new Vector2( 3.5f + pulse * 0.5f, 3.5f + pulse * 0.5f );
		_accentSprite.Color = Color.Lerp( AccentColor, CoreColor, pulse );

		Vector2 previousPos = _sweepFromOrigin ? _sweepOrigin : Pos;
		_sweepFromOrigin = false;
		Pos += Velocity * dt;
		TickTrail( dt );

		// The earliest solid on this step's path wins: a block behind an obstacle is never the anchor.
		if ( !ProjectileCollision.TryFindFirstHit( this, Stage, previousPos, _direction, out ProjectileCollision.Hit hit ) ) return;
		if ( hit.Kind == ProjectileCollision.HitKind.Block )
			Impact( hit.Point, anchorBlock: hit.Block );
		else
			Impact( hit.Point, hit.Side );
	}

	private SpriteRenderer AddLayer( Vector2 size, Color color, int childOrder )
	{
		SpriteRenderer sprite = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", size, "idle", childOrder );
		sprite.Color = color;
		sprite.Opaque = false;
		sprite.AlphaCutoff = 0f;
		return sprite;
	}

	private void TickTrail( float dt )
	{
		_trailTimer += dt;
		while ( _trailTimer >= TRAIL_INTERVAL )
		{
			_trailTimer -= TRAIL_INTERVAL;
			Vector2 perpendicular = new( -_direction.y, _direction.x );
			Vector2 position = Pos - _direction * Rng.CosmeticFloat( 1.5f, 3.5f )
				+ perpendicular * Rng.CosmeticFloat( -1.2f, 1.2f );
			Vector2 velocity = -_direction * Rng.CosmeticFloat( 5f, 16f )
				+ perpendicular * Rng.CosmeticFloat( -7f, 7f );
			Color color = Rng.CosmeticValue() < 0.72f ? OuterColor : AccentColor.WithAlpha( 0.55f );
			Stage.AddColoredParticle( position, velocity, 0.9f, color,
				Rng.CosmeticFloat( 0.18f, 0.3f ), Rng.CosmeticInt( 1, 3 ), Globals.DEPTH_PARTICLE_1, translucent: true );
		}
	}

	private void Impact( Vector2 impact, Direction surfaceFace = Direction.None, Block anchorBlock = null )
	{
		Pos = impact;
		SpawnPortalDepartureParticles( _portal.Pos );
		if ( anchorBlock is not null )
			_portal.MoveToBlock( impact, anchorBlock );
		else if ( surfaceFace == Direction.None )
			_portal.MoveTo( impact );
		else
			_portal.MoveToSurface( impact, surfaceFace, _portalSurfacePadding );
		Stage.RequestHitStop( _impactHitStopFrames );
		SpawnImpactParticles( impact );
		Audio.PlaySfx( SfxType.TurnVisible, new Vector3( impact.x, impact.y, 0f ), 0.75f, 1.25f );
		Dead = true;
	}

	private void SpawnPortalDepartureParticles( Vector2 portalPosition )
	{
		for ( int i = 0; i < DEPARTURE_PARTICLES; i++ )
		{
			float angle = (float)i / DEPARTURE_PARTICLES * MathF.Tau;
			Vector2 outward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
			Color color = (i & 2) == 0 ? OuterColor.WithAlpha( 0.75f ) : CoreColor.WithAlpha( 0.82f );
			Stage.AddColoredParticle( portalPosition + outward * Rng.CosmeticFloat( 6f, 10f ),
				-outward * Rng.CosmeticFloat( 24f, 58f ), Rng.CosmeticFloat( 0.84f, 0.92f ), color,
				Rng.CosmeticFloat( 0.18f, 0.32f ), Rng.CosmeticInt( 1, 4 ), Globals.DEPTH_PARTICLE_1, translucent: true );
		}
	}

	private void SpawnImpactParticles( Vector2 impact )
	{
		for ( int i = 0; i < IMPACT_PARTICLES; i++ )
		{
			float angle = (i + Rng.CosmeticFloat( -0.25f, 0.25f )) / IMPACT_PARTICLES * MathF.Tau;
			Vector2 outward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
			Color color = (i & 3) == 0 ? AccentColor : ((i & 1) == 0 ? CoreColor : OuterColor.WithAlpha( 0.82f ));
			Stage.AddColoredParticle( impact + outward * Rng.CosmeticFloat( 0f, 2f ),
				outward * Rng.CosmeticFloat( 45f, 125f ), Rng.CosmeticFloat( 0.82f, 0.92f ), color,
				Rng.CosmeticFloat( 0.2f, 0.42f ), Rng.CosmeticInt( 2, 5 ), Globals.DEPTH_PARTICLE_1, translucent: true );
		}
	}
}