Skip to main content

shuyi_ui/grid/
cursor.rs

1//! The virtual cursor on a grid map: which tile it is on, how it moves
2//! (one tile per step, never off the map) and how it pulses: between full
3//! and about half brightness, never off. A grid skin draws it
4//! ([`CursorView`](super::CursorView)).
5
6use core::f32::consts::TAU;
7
8use shuyi_core::{Dir, Pos};
9
10#[cfg(not(feature = "std"))]
11use crate::float::Float;
12use crate::input::frame_seconds;
13
14/// One full bright → dim → bright pulse, in seconds. *Tunable.*
15pub const BLINK_PERIOD: f32 = 1.0;
16
17/// The dimmest the cursor gets, as a fraction of full brightness. *Tunable.*
18pub const BLINK_MIN: f32 = 0.5;
19
20/// The cursor: a map tile and the time into its pulse.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct Cursor {
23    /// The tile under the cursor; always on the map.
24    pub pos: Pos,
25    /// Seconds into the current pulse, in `0..BLINK_PERIOD`.
26    pub blink_t: f32,
27}
28
29impl Cursor {
30    /// A cursor on `pos`, at full brightness.
31    pub const fn new(pos: Pos) -> Self {
32        Self { pos, blink_t: 0.0 }
33    }
34
35    /// Advances the pulse by `dt` seconds (negative or non-finite `dt`
36    /// counts as zero).
37    pub fn tick(&mut self, dt: f32) {
38        self.blink_t = (self.blink_t + frame_seconds(dt)) % BLINK_PERIOD;
39    }
40
41    /// Current brightness: `1` at the start of a pulse, down to
42    /// [`BLINK_MIN`] halfway through, then back up (a cosine).
43    pub fn brightness(&self) -> f32 {
44        pulse(self.blink_t, BLINK_PERIOD)
45    }
46
47    /// Moves one tile `dir`, staying on a `map_w × map_h` map. Returns
48    /// whether the cursor moved; a move restarts the pulse at full
49    /// brightness, so the cursor is easy to follow.
50    pub fn step(&mut self, dir: Dir, map_w: u16, map_h: u16) -> bool {
51        let (dx, dy) = dir.delta();
52        let clamp = |v: i32, len: u16| v.clamp(0, (i32::from(len) - 1).max(0));
53        let to = Pos::new(clamp(self.pos.x + dx, map_w), clamp(self.pos.y + dy, map_h));
54        if to == self.pos {
55            return false;
56        }
57        self.pos = to;
58        self.blink_t = 0.0;
59        true
60    }
61
62    /// Jumps to `pos` (e.g. the next unit), restarting the pulse.
63    pub fn jump(&mut self, pos: Pos) {
64        *self = Self::new(pos);
65    }
66}
67
68/// Brightness `t` seconds into a `period`-long pulse: `1` at the start,
69/// [`BLINK_MIN`] halfway, back to `1` at the end (a cosine).
70fn pulse(t: f32, period: f32) -> f32 {
71    let wave = (TAU * t / period).cos();
72    BLINK_MIN + (1.0 - BLINK_MIN) * (1.0 + wave) / 2.0
73}
74
75#[cfg(test)]
76mod tests {
77    use proptest::prelude::*;
78
79    use super::*;
80
81    const MOVES: [Dir; 4] = [Dir::Left, Dir::Right, Dir::Up, Dir::Down];
82
83    #[test]
84    fn steps_one_tile_and_clamps_at_the_edges() {
85        let mut c = Cursor::new(Pos::new(1, 1));
86        c.blink_t = 0.3;
87        assert!(c.step(Dir::Left, 3, 3));
88        assert_eq!((c.pos, c.blink_t), (Pos::new(0, 1), 0.0));
89        c.blink_t = 0.3;
90        assert!(!c.step(Dir::Left, 3, 3));
91        assert_eq!((c.pos, c.blink_t), (Pos::new(0, 1), 0.3));
92        assert!(c.step(Dir::Up, 3, 3));
93        assert!(!c.step(Dir::Up, 3, 3));
94        assert_eq!(c.pos, Pos::new(0, 0));
95        assert!(c.step(Dir::Right, 3, 3));
96        assert!(c.step(Dir::Right, 3, 3));
97        assert!(!c.step(Dir::Right, 3, 3));
98        assert!(c.step(Dir::Down, 3, 3));
99        assert!(c.step(Dir::Down, 3, 3));
100        assert!(!c.step(Dir::Down, 3, 3));
101        assert_eq!(c.pos, Pos::new(2, 2));
102        // A map of no tiles keeps it on its corner.
103        let mut corner = Cursor::new(Pos::new(0, 0));
104        assert!(!corner.step(Dir::Right, 0, 0));
105        assert_eq!(corner.pos, Pos::new(0, 0));
106    }
107
108    #[test]
109    fn jump_moves_and_restarts_the_pulse() {
110        let mut c = Cursor::new(Pos::new(1, 1));
111        c.tick(0.4);
112        c.jump(Pos::new(5, 6));
113        assert_eq!(c, Cursor::new(Pos::new(5, 6)));
114    }
115
116    #[test]
117    fn pulses_between_full_and_half_brightness() {
118        let mut c = Cursor::new(Pos::new(0, 0));
119        assert!((c.brightness() - 1.0).abs() < 1e-6);
120        c.tick(0.25);
121        assert!((c.blink_t - 0.25).abs() < 1e-6);
122        assert!((c.brightness() - 0.75).abs() < 1e-6);
123        c.tick(0.25);
124        assert!((c.brightness() - 0.5).abs() < 1e-6);
125        c.tick(0.5);
126        assert!(c.blink_t < 1e-6, "wraps after a period: {}", c.blink_t);
127        c.tick(0.25);
128        c.tick(-1.0);
129        c.tick(f32::NAN);
130        c.tick(f32::INFINITY);
131        assert!((c.blink_t - 0.25).abs() < 1e-6, "{}", c.blink_t);
132    }
133
134    #[test]
135    fn pulse_scales_with_its_period() {
136        assert!((pulse(0.0, 2.0) - 1.0).abs() < 1e-6);
137        assert!((pulse(0.5, 2.0) - 0.75).abs() < 1e-6);
138        assert!((pulse(1.0, 2.0) - BLINK_MIN).abs() < 1e-6);
139        assert!((pulse(2.0, 2.0) - 1.0).abs() < 1e-6);
140    }
141
142    proptest! {
143        #[test]
144        fn never_leaves_the_map(
145            w in 1u16..20,
146            h in 1u16..20,
147            steps in prop::collection::vec(0usize..4, 0..60),
148        ) {
149            let mut c = Cursor::new(Pos::new(0, 0));
150            for s in steps {
151                c.step(MOVES[s], w, h);
152                prop_assert!((0..i32::from(w)).contains(&c.pos.x));
153                prop_assert!((0..i32::from(h)).contains(&c.pos.y));
154            }
155        }
156
157        #[test]
158        fn brightness_stays_between_min_and_full(t in -10.0f32..10.0) {
159            let mut c = Cursor::new(Pos::new(0, 0));
160            c.tick(t);
161            let b = c.brightness();
162            prop_assert!((BLINK_MIN - 1e-6..=1.0 + 1e-6).contains(&b), "{b}");
163        }
164    }
165}