Entities/ImpostorPortal.cs
namespace BlockParty;

/// <summary>A short-lived tell that opens before a Summoner impostor enters play.</summary>
public sealed class ImpostorPortal : Entity2D
{
	private const float PHASE_1_DURATION = 1.5f;
	private const float PHASE_2_DURATION = 1.0f;
	private const float MOTE_INTERVAL = 0.14f;
	private const int COMPLETION_PARTICLES = 10;
	private const int DEPENETRATION_PASSES = 3;

	private static readonly Vector2 StartSize = new( 7f, 9f );
	private static readonly Vector2 FullSize = new( 15f, 21f );
	private static readonly Color Phase1Color = new( 214 / 255f, 74 / 255f, 70 / 255f );
	private static readonly Color Phase1Highlight = new( 1f, 155 / 255f, 118 / 255f );
	private static readonly Color Phase2Color = new( 182 / 255f, 44 / 255f, 104 / 255f );
	private static readonly Color Phase2Highlight = new( 1f, 94 / 255f, 178 / 255f );
	private static readonly Color PortalCore = new( 24 / 255f, 9 / 255f, 19 / 255f );

	public GameStage Stage { get; set; }

	private SpriteRenderer _outer;
	private SpriteRenderer _inner;
	private SpriteRenderer _core;
	private int _phase;
	private float _age;
	private float _moteTimer;

	public void Setup( int phase )
	{
		_phase = phase >= 2 ? 2 : 1;
		Size = Player.COLLISION_SIZE;
		Depth = Globals.DEPTH_SUMMON_PORTAL;
	}

	public void CreateVisuals()
	{
		_outer = AddLayer( 0 );
		_inner = AddLayer( 1 );
		_core = AddLayer( 2 );
		UpdateVisuals();
		// Quiet hint only: the growing portal is the real tell, the completion cue below is the beat.
		Audio.PlaySfx( SfxType.ImpostorPortalOpen, Pos, 0.15f, _phase >= 2 ? 1.15f : 0.85f );
	}

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

	public override void Tick( float dt )
	{
		ResolveSolidPenetration();
		_age += dt;
		_moteTimer += dt;

		float duration = _phase >= 2 ? PHASE_2_DURATION : PHASE_1_DURATION;
		if ( _age >= duration )
		{
			Complete();
			return;
		}

		while ( _moteTimer >= MOTE_INTERVAL )
		{
			_moteTimer -= MOTE_INTERVAL;
			SpawnMote();
		}

		UpdateVisuals();
	}

	/// <summary>Keep the eventual impostor footprint outside moving blocks and player-solid geometry.
	/// A few bounded passes handle one push placing the portal into a neighbouring solid without risking
	/// an unbounded search when the surrounding gap is genuinely too small.</summary>
	private void ResolveSolidPenetration()
	{
		for ( int pass = 0; pass < DEPENETRATION_PASSES; pass++ )
		{
			bool moved = false;
			foreach ( Block block in Stage.GetBlocks() )
			{
				if ( block.IsDead || block.PhasingIn ) continue;
				moved |= Unpenetrate( X, Y, block );
			}

			foreach ( Entity2D obstacle in Stage.GetPlayerSolidObstacles() )
				moved |= Unpenetrate( X, Y, obstacle );

			Vector2 beforeClamp = Pos;
			ClampToBounds( X, Y );
			moved |= Pos != beforeClamp;
			if ( !moved ) break;
		}
	}

	private void UpdateVisuals()
	{
		if ( _outer is null ) return;

		float duration = _phase >= 2 ? PHASE_2_DURATION : PHASE_1_DURATION;
		float progress = Math.Clamp( _age / duration, 0f, 1f );
		float eased = MathF.Sin( progress * MathF.PI * 0.5f );
		float fade = Math.Clamp( progress / 0.28f, 0f, 1f );
		float pulse = 0.92f + MathF.Sin( progress * MathF.PI * 8f ) * 0.08f * progress;
		Vector2 size = new(
			(StartSize.x + (FullSize.x - StartSize.x) * eased) * pulse,
			(StartSize.y + (FullSize.y - StartSize.y) * eased) * pulse );
		Color color = _phase >= 2 ? Phase2Color : Phase1Color;
		Color highlight = _phase >= 2 ? Phase2Highlight : Phase1Highlight;

		_outer.Size = size;
		_outer.Color = color.WithAlpha( fade * 0.72f );
		_inner.Size = size * 0.72f;
		_inner.Color = PortalCore.WithAlpha( fade * 0.94f );
		_core.Size = size * 0.34f;
		_core.Color = highlight.WithAlpha( fade * (0.68f + 0.18f * pulse) );
	}

	private void SpawnMote()
	{
		float angle = Rng.CosmeticFloat( 0f, MathF.Tau );
		Vector2 direction = new( MathF.Cos( angle ), MathF.Sin( angle ) );
		Vector2 radii = FullSize * 0.55f;
		Vector2 position = Pos + new Vector2( direction.x * radii.x, direction.y * radii.y );
		Color color = Rng.CosmeticValue() < 0.65f
			? (_phase >= 2 ? Phase2Color : Phase1Color)
			: (_phase >= 2 ? Phase2Highlight : Phase1Highlight);
		Stage.AddColoredParticle( position, -direction * Rng.CosmeticFloat( 14f, 24f ), 0.9f,
			color.WithAlpha( 0.7f ), Rng.CosmeticFloat( 0.18f, 0.32f ), 1,
			Globals.DEPTH_SUMMON_PORTAL, translucent: true );
	}

	private void Complete()
	{
		Stage.SpawnImpostor( Pos, _phase );
		Audio.PlaySfx( SfxType.ImpostorPortalComplete, Pos, 0.6f, _phase >= 2 ? 0.92f : 1.05f );

		Color baseColor = _phase >= 2 ? Phase2Color : Phase1Color;
		Color highlight = _phase >= 2 ? Phase2Highlight : Phase1Highlight;
		for ( int i = 0; i < COMPLETION_PARTICLES; i++ )
		{
			float angle = (i + Rng.CosmeticFloat( -0.18f, 0.18f )) / COMPLETION_PARTICLES * MathF.Tau;
			Vector2 direction = new( MathF.Cos( angle ), MathF.Sin( angle ) );
			Color color = (i & 1) == 0 ? baseColor : highlight;
			Stage.AddColoredParticle( Pos + direction * Rng.CosmeticFloat( 2f, 5f ),
				direction * Rng.CosmeticFloat( 28f, 58f ), 0.86f, color.WithAlpha( 0.85f ),
				Rng.CosmeticFloat( 0.22f, 0.4f ), (i % 3) == 0 ? 2 : 1,
				Globals.DEPTH_SUMMON_PORTAL, translucent: true );
		}

		Dead = true;
	}
}