Entities/Teardrop.cs
namespace BlockParty;

public enum TeardropVariant
{
	Standard,
	Piercing
}

/// <summary>
/// The Sad block's teardrop. Port of the original <c>Teardrop</c>: falls under
/// gravity and kills the player on contact (checked in Player via <see cref="Hitbox"/> while
/// not splashed). Standard tears splash on solid geometry; piercing tears pass through it and
/// are removed once offscreen. While falling it blinks its palette as a tell (Teardrop0 ↔ Teardrop1),
/// swapping between baked "fall"/"fall_alt" anims. The fall is driven MANUALLY (PlaybackSpeed=0
/// + CurrentFrameIndex from our own clock) so the fast palette swap doesn't restart engine
/// playback and freeze it on the first frames; the splash plays normally (engine-driven).
/// </summary>
public sealed class Teardrop : Entity2D
{
	public GameStage Stage { get; set; }
	public bool HasSplashed { get; private set; }

	const float PLAYER_HITBOX_SIZE = 2f;
	const float GRAVITY = -75.0f;
	const float SPLASH_TIME = 0.2f;

	// Native art pixel sizes (Size = art dims; see SpriteLayer/Fireball convention).
	const float FALL_SIZE = 11f;
	const float SPLASH_SIZE = 17f;

	// Palette blink (the "tell"): alt flashes briefly, steady holds longer.
	const float PALETTE_BLINK_TIME = 0.15f;
	const float PALETTE_BLINK_ON_TIME = 0.05f;
	float _paletteBlinkTimer;
	bool _paletteAlt;

	// Manual fall animation (5 frames over 0.25s, plays once then holds the last frame).
	const int FALL_FRAMES = 5;
	const float FALL_PERIOD = 0.25f;
	float _animTime;
	string _curAnim;

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

	Block _creatingBlock;
	TeardropVariant _variant;
	float _splashTimer;
	SpriteRenderer _sprite;

	public void Setup( Vector2 velocity, Block creatingBlock, TeardropVariant variant )
	{
		Velocity = velocity;
		Size = new Vector2( FALL_SIZE, FALL_SIZE );
		_creatingBlock = creatingBlock;
		_variant = variant;
	}

	/// <summary>Carry the self-collision exclusion over when the creating block gets mimic-swapped
	/// mid-fall — the disguise stands where the old instance was, so without this the drop would
	/// splash on its own source the tick after the swap.</summary>
	public void OnBlockReplaced( Block old, Block replacement )
	{
		if ( ReferenceEquals( _creatingBlock, old ) ) _creatingBlock = replacement;
	}

	public void CreateVisuals()
	{
		string spritePath = _variant == TeardropVariant.Piercing
			? "sprites/teardrop_pierce.sprite"
			: "sprites/teardrop.sprite";
		_sprite = SpriteLayer.Add( GameObject, spritePath, Size, "fall" );
		_sprite.PlaybackSpeed = 0f; // fall is scrubbed manually; splash re-enables playback
		_curAnim = "fall";
	}

	public override void Tick( float dt )
	{
		if ( HasSplashed )
		{
			_splashTimer -= dt;
			if ( _splashTimer <= 0f ) Dead = true;
			return;
		}

		_animTime += dt;
		BlinkPalette( dt );

		Pos += Velocity * dt;
		Velocity += new Vector2( 0, GRAVITY ) * dt;

		if ( _variant == TeardropVariant.Piercing )
		{
			if ( IsFullyOutsideArena() ) Dead = true;
			return;
		}

		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block != _creatingBlock && GetRect().Intersects( block.GetRect() ) )
			{
				Splash();
				return;
			}
		}

		// Interior obstacles splash the drop just like a block or wall.
		foreach ( Entity2D ob in Stage.GetSolidObstacles() )
		{
			if ( GetRect().Intersects( ob.GetRect() ) )
			{
				Splash();
				return;
			}
		}

		// Splash on the floor, on either side wall (or the top) — it must not drift out of the
		// arena — or once fully offscreen as a backstop.
		var r = GetRect();
		if ( Y < 8
			|| r.Left <= Arena.WALL_SIZE
			|| r.Right >= Arena.WIDTH - Arena.WALL_SIZE
			|| r.Top >= Arena.HEIGHT - Arena.WALL_SIZE
			|| IsFullyOutsideArena() )
			Splash();
	}

	void Splash()
	{
		if ( HasSplashed ) return;
		HasSplashed = true;
		_splashTimer = SPLASH_TIME;
		if ( _sprite is not null )
		{
			_sprite.Size = new Vector2( SPLASH_SIZE, SPLASH_SIZE ); // splash art is larger than the drop
			// The bigger splash quad is centred on Pos, so it would otherwise hang (SPLASH-FALL)/2 px
			// below the impact point. Nudge it up so the splash bottom sits where the drop landed.
			_sprite.GameObject.LocalPosition += new Vector3( 0, (SPLASH_SIZE - FALL_SIZE) / 2f, 0 );
			_sprite.PlaybackSpeed = 1f; // hand the splash back to engine-driven playback
			_sprite.PlayAnimation( "splash" );
		}
		Audio.PlaySfx( SfxType.TearSplash, Position, Rng.Float( 0.4f, 0.48f ) );
	}

	void BlinkPalette( float dt )
	{
		_paletteBlinkTimer -= dt;
		if ( _paletteBlinkTimer <= 0f )
		{
			_paletteAlt = !_paletteAlt;
			_paletteBlinkTimer = _paletteAlt ? PALETTE_BLINK_ON_TIME : PALETTE_BLINK_TIME;
		}
		ApplyFrame();
	}

	void ApplyFrame()
	{
		if ( _sprite is null ) return;
		// PlayAnimation only on a palette change (it resets the clock to 0); PlaybackSpeed=0 keeps
		// the CurrentFrameIndex we then scrub. Fall plays once over FALL_PERIOD, then holds frame 4.
		string name = _paletteAlt ? "fall_alt" : "fall";
		if ( name != _curAnim )
		{
			_sprite.PlayAnimation( name );
			_curAnim = name;
		}
		int frame = Math.Clamp( (int)(_animTime / FALL_PERIOD * FALL_FRAMES), 0, FALL_FRAMES - 1 );
		_sprite.CurrentFrameIndex = frame;
	}

	bool IsFullyOutsideArena()
	{
		var r = GetRect();
		return r.Left > Arena.WIDTH || r.Right < 0 || r.Bottom > Arena.HEIGHT || r.Top < 0;
	}
}