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