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