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