Entities/CoinFloater.cs

A visual entity that displays a small "+N" score popup when a coin is collected. It composes 3x5 glyph sprites per character, positions them above the coin, advances them upward with an ease-out and fades them out over a short lifetime, and snaps to pixel grid via SyncTransform.

File Access
using System.Collections.Generic;

namespace BlockParty;

/// <summary>
/// The "+N" score popup a collected <see cref="Coin"/> leaves behind: baked 3x5 micro-digit sprites
/// (sprites/ui/floater_*.sprite) composed per-glyph, rising a few pixels and fading out. Purely
/// cosmetic, but ticked inside the sim loop so it freezes with hit-stop and reproduces in replays.
///
/// Pixel-perfect by construction: the rise is pre-rounded to whole pixels (discrete 1px steps, fast
/// then slow — the decelerating look) and <see cref="Entity2D.SyncTransform"/> corner-snaps the rect,
/// so the glyphs can never sit off the 240 quantize grid mid-flight. Uses no RNG at all.
/// </summary>
public sealed class CoinFloater : Entity2D
{
	private const float LIFE = 0.6f;
	private const float RISE_PX = 6f;        // total upward travel
	private const float RISE_TIME = 0.45f;   // ease-out: most of the travel lands in the first half
	private const float FADE_START = 0.3f;

	private const float GLYPH_W = 3f;        // baked glyph ink width
	private const float GLYPH_H = 5f;
	private const float GLYPH_ADVANCE = 4f;  // 3px glyph + 1px gap

	private static readonly Color TINT = new( 221 / 255f, 221 / 255f, 153 / 255f ); // YELLOW

	private readonly List<SpriteRenderer> _glyphs = new();
	private float _age;
	private float _baseY;

	/// <summary>Build the glyph layers and place the floater just above the coin's pixel rect.</summary>
	public void CreateVisuals( Vector2 coinPos )
	{
		string text = "+" + ScoreCalc.COIN_SCORE;

		float width = GLYPH_ADVANCE * text.Length - 1f;
		Size = new Vector2( width, GLYPH_H );

		// Bottom row starts 2px above the coin's snapped top edge; rise is added on top in Tick.
		_baseY = MathF.Round( coinPos.y - Coin.ART_SIZE.y / 2f ) + Coin.ART_SIZE.y + 2f + GLYPH_H / 2f;
		// Keep the whole strip inside the arena walls horizontally.
		float halfW = width / 2f;
		float x = Math.Clamp( coinPos.x, Arena.WALL_SIZE + halfW, Arena.WIDTH - Arena.WALL_SIZE - halfW );
		Pos = new Vector2( x, _baseY );

		for ( int i = 0; i < text.Length; i++ )
		{
			string name = text[i] == '+' ? "plus" : text[i].ToString();
			// Square quad of the art's LONG edge (5) so the 3x5 glyph renders 1:1 (aspect-fit rule).
			var sr = SpriteLayer.Add( GameObject, $"sprites/ui/floater_{name}.sprite",
				new Vector2( GLYPH_H, GLYPH_H ), "idle", childOrder: i );
			// Local offsets are whole pixels from the strip centre; the odd strip width snaps to a
			// half-pixel centre, so every 3px glyph's edges land back on integers. All glyphs share
			// local Z 0: they never overlap horizontally, so equal Z can't z-fight, and stacking
			// childOrder steps would push long values ("+100") past the layers above the floater.
			sr.GameObject.LocalPosition = new Vector3(
				-width / 2f + GLYPH_W / 2f + GLYPH_ADVANCE * i, 0f, 0f );
			sr.Opaque = false;
			sr.AlphaCutoff = 0f; // smooth alpha fade instead of cutoff dithering
			sr.Color = TINT;
			_glyphs.Add( sr );
		}
	}

	/// <summary>Same pixel-rect snap as the base, at the floater's own FRACTIONAL layer
	/// (<see cref="Globals.DEPTH_COIN_FLOATER"/> — <see cref="Entity2D.Depth"/> is an int and can't
	/// express it): score feedback must read over walls/obstacles and every hazard, so a coin
	/// collected against a ceiling or under an obstacle can't have its "+N" buried.</summary>
	public override void SyncTransform()
	{
		RectF r = GetPixelRect( Pos.x, Pos.y );
		WorldPosition = new Vector3( r.Left + Width / 2f, r.Bottom + Height / 2f,
			Globals.DepthToZ( Globals.DEPTH_COIN_FLOATER ) );
	}

	public override void Tick( float dt )
	{
		_age += dt;
		if ( _age >= LIFE )
		{
			Dead = true;
			return;
		}

		// Ease-out rise, rounded to whole pixels: 6 discrete 1px steps that come fast then slow.
		float t = Math.Min( _age / RISE_TIME, 1f );
		float rise = MathF.Round( RISE_PX * (1f - (1f - t) * (1f - t)) );
		Y = _baseY + rise;

		float alpha = _age <= FADE_START ? 1f : 1f - (_age - FADE_START) / (LIFE - FADE_START);
		foreach ( var sr in _glyphs )
			sr.Color = TINT.WithAlpha( alpha );
	}
}