MenuSelectorAnim is a small UI animation helper that tracks a slider position (in option-index units) and a sprite-sheet frame for a smiling-cube menu selector. It lerps the Pos toward a target index, plays predefined frame sequences for move and wobble animations, and exposes Tick, Snap, SnapWithin, PlayMove and PlayWobble.
namespace BlockParty;
/// <summary>
/// Shared "smiling cube" menu-selector animation, extracted from <see cref="TitleStage"/> so the
/// title menu and the options menu stay in lock-step. Tracks a sliding position (in option-index
/// units, not pixels — the UI multiplies by its row pitch) that lerps toward the selected row,
/// plus the squash/stretch frame (0..6) into the menu_selector sprite sheet.
///
/// The frame sequences and timings are ported verbatim from the original game's MenuSelector.
/// </summary>
public sealed class MenuSelectorAnim
{
/// <summary>Current position in option-index units (lerped toward the target row).</summary>
public float Pos { get; private set; }
/// <summary>Current sprite-sheet frame (0..6) driving the squash/stretch.</summary>
public int Frame { get; private set; }
// Original MenuSelector frame sequences. Each plays once over ANIM_DURATION and ends back on
// frame 0. "Move" is the bigger squash on changing option; "Wobble" the smaller bump played
// when nudging past the list ends or confirming.
private static readonly int[] MoveSeq = { 0, 1, 2, 3, 2, 1, 0, 4, 5, 6, 6, 6, 5, 4, 0 };
private static readonly int[] WobbleSeq = { 0, 1, 2, 1, 0, 4, 5, 4, 0, 1, 0, 4, 0 };
private const float ANIM_DURATION = 0.15f;
// Slide speed in option-index units/second. The original's 12.5 (250 px/s over 20px spacing)
// couldn't keep pace with the leaderboard's held-key repeat (~15 rows/sec at NavRepeatRate),
// leaving the cube trailing a row behind while scrolling; 20 outruns the repeat so it catches up.
private const float MOVE_SPEED = 20f;
private int[] _animSeq;
private float _animTime;
public void PlayMove() { _animSeq = MoveSeq; _animTime = 0f; }
public void PlayWobble() { _animSeq = WobbleSeq; _animTime = 0f; }
/// <summary>Place the cube directly on a row with no slide (used when opening a list already
/// focused on a row — a zero-dt Tick can't move the slide, so it would otherwise crawl in from 0).</summary>
public void Snap( int index ) => Pos = index;
/// <summary>
/// Cap how far the cube is allowed to be from <paramref name="targetIndex"/> so a long jump
/// (e.g. clicking a far-off list row) doesn't crawl for seconds. Leaves the last <paramref
/// name="maxGap"/> units to slide normally for a bit of motion.
/// </summary>
public void SnapWithin( int targetIndex, float maxGap )
{
if ( MathF.Abs( targetIndex - Pos ) > maxGap )
Pos = targetIndex - MathF.Sign( targetIndex - Pos ) * maxGap;
}
/// <summary>Advance the slide toward <paramref name="targetIndex"/> and the squash animation.</summary>
public void Tick( float dt, int targetIndex )
{
float toTarget = targetIndex - Pos;
if ( MathF.Abs( toTarget ) > 0.001f )
Pos += MathF.Sign( toTarget ) * MathF.Min( MOVE_SPEED * dt, MathF.Abs( toTarget ) );
else
Pos = targetIndex;
if ( _animSeq is not null )
{
_animTime += dt;
if ( _animTime >= ANIM_DURATION )
{
_animSeq = null;
Frame = 0;
}
else
{
int i = (int)(_animTime / ANIM_DURATION * _animSeq.Length);
Frame = _animSeq[Math.Clamp( i, 0, _animSeq.Length - 1 )];
}
}
}
}