Rendering/GhostJumpLabel.cs
namespace BlockParty;

/// <summary>
/// The tutorial ghost's "JUMP" callout: a baked 3x5 micro-glyph word strip (sprites/ui/floater_word_jump)
/// that flies out of the ghost along its launch direction — straight up off the floor, up-and-away
/// off a wall — then fades, so a demo like the high-jump level's floor-jump-into-wall-jump reads as
/// two distinct jumps. Ticked per fixed sim step by <see cref="GhostPlayback"/>, pixel-snapped like
/// the coin floater, and Rng-free: purely cosmetic, so it can't perturb a run or replay.
/// </summary>
public sealed class GhostJumpLabel
{
	private const int LIFE_TICKS = 48;
	private const int DRIFT_TICKS = 30;      // ease-out: most of the travel lands early
	private const int FADE_START_TICK = 24;
	private const float START_OFFSET_PX = 9f; // spawn just clear of the ghost's body
	private const float DRIFT_PX = 8f;
	private const float ALPHA = 0.85f;
	private static readonly Vector2 ART_SIZE = new( 15f, 5f );
	private static readonly Color TINT = new( 221 / 255f, 221 / 255f, 153 / 255f ); // YELLOW, as the coin floater

	private readonly GameObject _root;
	private readonly SpriteRenderer _sprite;
	private readonly Vector2 _origin;
	private readonly Vector2 _dir;
	private readonly float _envelope;
	private int _age;

	public bool Done => _age >= LIFE_TICKS;

	/// <param name="stage">The stage the callout is a child of (torn down with it).</param>
	/// <param name="from">Launch position (the ghost's centre).</param>
	/// <param name="launchDir">Direction away from the jumped-off surface; the strip flies this way.</param>
	/// <param name="envelope">The ghost's fade envelope (0..1) at spawn — a callout fired while the
	/// ghost is still fading in stays as faint as its body.</param>
	public GhostJumpLabel( GameStage stage, Vector2 from, Vector2 launchDir, float envelope )
	{
		_dir = launchDir.LengthSquared > 0f ? launchDir.Normal : new Vector2( 0f, 1f );
		_origin = from + _dir * START_OFFSET_PX;
		_envelope = envelope;

		_root = stage.CreateChild( "GhostJumpLabel" );
		// Square quad of the art's long edge so the 15x5 strip renders 1:1 (aspect-fit rule).
		float longEdge = MathF.Max( ART_SIZE.x, ART_SIZE.y );
		_sprite = SpriteLayer.Add( _root, "sprites/ui/floater_word_jump.sprite", new Vector2( longEdge, longEdge ) );
		_sprite.Opaque = false;
		_sprite.AlphaCutoff = 0f; // smooth alpha fade instead of cutoff dithering
		Place( 0 );
	}

	public void Tick()
	{
		if ( Done )
			return;
		if ( ++_age >= LIFE_TICKS )
		{
			_root.Destroy();
			return;
		}
		Place( _age );
	}

	private void Place( int age )
	{
		float t = Math.Min( age / (float)DRIFT_TICKS, 1f );
		Vector2 center = _origin + _dir * (DRIFT_PX * (1f - (1f - t) * (1f - t)));
		// Keep the strip inside the arena walls, then corner-snap it to the pixel grid.
		float halfW = ART_SIZE.x / 2f, halfH = ART_SIZE.y / 2f;
		center.x = Math.Clamp( center.x, Arena.WALL_SIZE + halfW, Arena.WIDTH - Arena.WALL_SIZE - halfW );
		center.y = Math.Clamp( center.y, Arena.WALL_SIZE + halfH, Arena.HEIGHT - Arena.WALL_SIZE - halfH );
		Vector2 aligned = SpriteLayer.PixelAlignedCenter( center, ART_SIZE );
		// One layer under the coin floater: over walls, blocks, both players and every hazard —
		// a hint that gets buried teaches nothing.
		_root.WorldPosition = new Vector3( aligned.x, aligned.y,
			Globals.DepthToZ( Globals.DEPTH_COIN_FLOATER ) - SpriteLayer.LAYER_Z_STEP );

		float fade = age <= FADE_START_TICK ? 1f
			: 1f - (age - FADE_START_TICK) / (float)(LIFE_TICKS - FADE_START_TICK);
		_sprite.Color = TINT.WithAlpha( ALPHA * _envelope * fade );
	}
}