Entities/Coin.cs
namespace BlockParty;

/// <summary>
/// Collectible coin, placed in levels as an object (<see cref="LevelDef.Coins"/>). Stationary and
/// non-colliding: nothing in the sim interacts with it except the pickup check below, and it renders
/// BEHIND blocks/hazards/player (<see cref="Globals.DEPTH_COIN"/>) so gameplay passes in front of it.
///
/// Collecting awards <see cref="ScoreCalc.COIN_SCORE"/> in the score tally. Real run bodies (both
/// Twins) and friendly Swarm clones collect; hostile impostors never do (they're simply not iterated).
///
/// Deterministic / replay-safe: the pickup is derived purely from entity overlap on the fixed tick
/// (recorded inputs reproduce it), spawning consumes no RNG, and the sparkle/burst cosmetics draw
/// only from the cosmetic stream. Ticks inside the sim loop, so it freezes during hit-stop.
/// </summary>
public sealed class Coin : Entity2D
{
	public GameStage Stage { get; set; }

	/// <summary>Art and pickup footprint, in logical pixels.</summary>
	public const float ART_W = 4f;
	public const float ART_H = 6f;
	public static readonly Vector2 ART_SIZE = new( ART_W, ART_H );

	// The sparkle: a single white pixel that periodically appears somewhere on the coin's centre
	// column band (covered by every rotation frame) and winks out — a static shine, not a mote.
	private static readonly Color SPARKLE_COLOR = new( 248 / 255f, 245 / 255f, 230 / 255f ); // WHITE
	private const float SPARKLE_ON_TIME = 0.12f;
	private const float SPARKLE_OFF_MIN = 0.9f;
	private const float SPARKLE_OFF_MAX = 2.4f;

	private SpriteRenderer _sparkle;
	private bool _sparkleOn;
	private float _sparkleTimer;

	public void CreateVisuals()
	{
		Size = ART_SIZE;

		// Stationary + fractional depth layer (Entity2D.Depth is an int), so place the GameObject
		// exactly once here instead of joining the per-frame SyncTransforms loop: pixel-grid snap by
		// the same corner-aligned rect SyncTransform uses, at the coin's own Z.
		RectF r = GetPixelRect( Pos.x, Pos.y );
		WorldPosition = new Vector3( r.Left + Width / 2f, r.Bottom + Height / 2f,
			Globals.DepthToZ( Globals.DEPTH_COIN ) );

		// Non-square art: the engine fits the texture into a SQUARE Size box scaled by its LONGER
		// edge, so the 4x6 art needs a 6x6 quad to render 1:1 (see GameStage.AddBackgroundInfoSprite).
		SpriteLayer.Add( GameObject, "sprites/coin.sprite", new Vector2( ART_SIZE.y, ART_SIZE.y ), "idle" );

		_sparkle = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", new Vector2( 1f, 1f ), "idle", childOrder: 1 );
		_sparkle.Color = SPARKLE_COLOR;
		_sparkle.Enabled = false;
		_sparkleTimer = Rng.CosmeticFloat( SPARKLE_OFF_MIN, SPARKLE_OFF_MAX );
	}

	public override void Tick( float dt )
	{
		TickSparkle( dt );

		// The score locks the moment the game-over beat begins (matching IsGameOver's contract):
		// _gameTime is frozen during the beat, so a beat pickup would be FREE leaderboard points
		// with no time-bonus trade-off — and a player who Confirm-skips the beat would score less
		// than one who farms it. The sparkle above keeps playing; only collection stops.
		if ( Stage.IsGameOver ) return;

		// Pickup: a plain rect intersect (a pickup should be generous — no minimum-overlap gate like
		// the impostor kill). LivingPlayers covers the real run bodies (both Twins, excludes dead /
		// hardened); friendly Swarm clones opt back in via their own filtered view. Hostile impostors
		// are never iterated, so they can't collect by construction.
		foreach ( var player in Stage.LivingPlayers )
		{
			if ( GetRect().Intersects( player.GetRect() ) )
			{
				Collect();
				return;
			}
		}

		foreach ( var clone in Stage.LivingFriendlyClones )
		{
			if ( GetRect().Intersects( clone.GetRect() ) )
			{
				Collect();
				return;
			}
		}
	}

	private void TickSparkle( float dt )
	{
		_sparkleTimer -= dt;
		if ( _sparkleTimer > 0f ) return;

		if ( _sparkleOn )
		{
			_sparkleOn = false;
			_sparkle.Enabled = false;
			_sparkleTimer = Rng.CosmeticFloat( SPARKLE_OFF_MIN, SPARKLE_OFF_MAX );
			return;
		}

		// Pick a pixel in the centre 2x4 of the 4x6 art — inside the coin face in every rotation
		// frame. The coin's even-sized rect is pixel-aligned, so the half-pixel offsets put the 1x1
		// sparkle's edges exactly on the grid.
		float ox = Rng.CosmeticInt( 0, 2 ) - 0.5f;
		float oy = Rng.CosmeticInt( 0, 4 ) - 1.5f;
		_sparkle.GameObject.LocalPosition = new Vector3( ox, oy, SpriteLayer.LAYER_Z_STEP );
		_sparkleOn = true;
		_sparkle.Enabled = true;
		_sparkleTimer = SPARKLE_ON_TIME;
	}

	private void Collect()
	{
		if ( Dead ) return;
		Dead = true;

		Stage.OnCoinCollected();

		// Small radial burst in the coin's own colours (cosmetic stream only — same rules as every
		// other visual effect; see SpawnMimicChoiceSparkles).
		const int count = 8;
		for ( int i = 0; i < count; i++ )
		{
			float angle = MathF.PI * 2f * i / count;
			var radial = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			ParticleKind kind = (i & 1) == 0 ? ParticleKind.CoinBurst0 : ParticleKind.CoinBurst1;
			Stage.AddParticle( Pos + radial * Rng.CosmeticFloat( 1f, 2.5f ), radial * Rng.CosmeticFloat( 20f, 45f ),
				0.9f, 0f, kind, Rng.CosmeticFloat( 0.25f, 0.4f ), Rng.CosmeticInt( 1, 3 ) );
		}

		Audio.PlaySfx( SfxType.CoinPickup, Pos, 0.7f );
		Stage.AddCoinFloater( Pos );
	}
}