agb_eb_ext 0.25.3

AGB Extension methods
Documentation
use agb::fixnum::{Num, Vector2D, num, vec2};

/// Fraction of the remaining distance covered each [`update`](ButtonHighlight::update)
pub type HighlightSpeed = Num<i32, 8>;

const DEFAULT_SPEED: HighlightSpeed = num!(0.35);
const SNAP_DISTANCE: HighlightSpeed = num!(0.5);

/// Highlight/focus indicator for button that animates between buttons
///
/// All positions are in pixels
///
/// # Usage
///
/// Assuming buttons at `24,24` `40,24` `56,24` all 24x40 px
///
/// ```rust,ignore
/// let mut highlight = ButtonHighlight::new((24, 24));
/// //player presses right
/// highlight.set_target((40, 24));
///
/// //in update
/// highlight.update();
///
/// //in show
/// sprites::BUTTON.show(highlight.pos(), frame);
/// ```
#[derive(Debug, Clone)]
pub struct ButtonHighlight {
    pos: Vector2D<HighlightSpeed>,
    target: Vector2D<HighlightSpeed>,
    speed: HighlightSpeed,
}

impl ButtonHighlight {
    /// Create a highlight at `pos` (in px) with the default speed
    pub fn new(pos: impl Into<Vector2D<i32>>) -> Self {
        let pos = to_fixed(pos.into());
        Self {
            pos,
            target: pos,
            speed: DEFAULT_SPEED,
        }
    }

    /// Override how fast the highlight approaches its target
    ///
    /// `speed` is the fraction of the remaining distance covered per update, `0 < speed <= 1`
    pub fn with_speed(mut self, speed: HighlightSpeed) -> Self {
        debug_assert!(
            speed > num!(0) && speed <= num!(1),
            "speed must be in (0, 1]"
        );
        self.speed = speed;
        self
    }

    /// Set target (in px)
    pub fn set_target(&mut self, target: impl Into<Vector2D<i32>>) {
        self.target = to_fixed(target.into());
    }

    /// Move immediately to `pos` (in px) with no animation, and target it
    pub fn jump_to(&mut self, pos: impl Into<Vector2D<i32>>) {
        self.pos = to_fixed(pos.into());
        self.target = self.pos;
    }

    /// Move button highlight towards target
    pub fn update(&mut self) {
        self.pos += (self.target - self.pos) * self.speed;

        if (self.target.x - self.pos.x).abs() < SNAP_DISTANCE {
            self.pos.x = self.target.x;
        }
        if (self.target.y - self.pos.y).abs() < SNAP_DISTANCE {
            self.pos.y = self.target.y;
        }
    }

    /// Position (in px) of button highlight
    #[inline]
    pub fn pos(&self) -> Vector2D<i32> {
        self.pos.round()
    }

    /// Target position (in px)
    #[inline]
    pub fn target(&self) -> Vector2D<i32> {
        self.target.round()
    }

    /// `true` once the highlight has finished moving
    #[inline]
    pub fn is_at_target(&self) -> bool {
        self.pos == self.target
    }
}

#[inline]
fn to_fixed(v: Vector2D<i32>) -> Vector2D<HighlightSpeed> {
    vec2(Num::from(v.x), Num::from(v.y))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test_case]
    fn converges_and_snaps(_gba: &mut agb::Gba) {
        let mut highlight = ButtonHighlight::new((24, 24));
        assert!(highlight.is_at_target());
        highlight.set_target(vec2(40, 24));
        assert!(!highlight.is_at_target());
        assert_eq!(highlight.target(), vec2(40, 24));

        let mut steps = 0;
        while !highlight.is_at_target() {
            highlight.update();
            steps += 1;
            assert!(steps < 60, "did not converge");
        }
        assert_eq!(highlight.pos(), vec2(40, 24));
        assert!(steps > 1, "should animate rather than snap instantly");
    }

    #[test_case]
    fn jump_to_is_instant(_gba: &mut agb::Gba) {
        let mut highlight = ButtonHighlight::new((0, 0)).with_speed(num!(0.5));
        highlight.jump_to((100, 50));
        assert_eq!(highlight.pos(), vec2(100, 50));
        assert!(highlight.is_at_target());
    }
}