UI/MenuSelectorAnim.cs

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.

Native Interop
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 )];
			}
		}
	}
}