Skip to main content

shuyi_ui/grid/
camera.rs

1//! The tile camera: which map tiles the viewport shows. How many tiles
2//! that is (the view's size) comes from the grid skin (game ADR-0038).
3
4use shuyi_core::Pos;
5
6/// The map tile shown in the viewport's top-left corner. It can be negative
7/// (or run past the map) when the map is smaller than the viewport: the map
8/// is then centred and the cells around it are left blank.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub struct Camera {
11    /// Top-left tile of the viewport.
12    pub origin: Pos,
13}
14
15impl Camera {
16    /// Default [`follow`](Self::follow) margin, in tiles.
17    pub const MARGIN: i32 = 3;
18
19    /// A camera with `target` as near the centre of a viewport of `view`
20    /// tiles (across, down) as the map allows, on a `map_w × map_h` map.
21    pub fn centred_on(target: Pos, map_w: u16, map_h: u16, view: (i32, i32)) -> Self {
22        let origin = Pos::new(
23            clamp_axis(target.x - view.0 / 2, map_w, view.0),
24            clamp_axis(target.y - view.1 / 2, map_h, view.1),
25        );
26        Self { origin }
27    }
28
29    /// Scrolls as little as possible so `target` is at least `margin` tiles
30    /// from each edge of a viewport of `view` tiles (across, down), without
31    /// showing past the map's edges. On an axis where the map is smaller
32    /// than the viewport, the map is centred. A margin over half the
33    /// viewport is treated as half.
34    pub fn follow(&mut self, target: Pos, map_w: u16, map_h: u16, view: (i32, i32), margin: i32) {
35        self.origin = Pos::new(
36            follow_axis(self.origin.x, target.x, map_w, view.0, margin),
37            follow_axis(self.origin.y, target.y, map_h, view.1, margin),
38        );
39    }
40}
41
42/// One axis of [`Camera::follow`].
43fn follow_axis(origin: i32, target: i32, map_len: u16, view: i32, margin: i32) -> i32 {
44    let margin = margin.clamp(0, (view - 1) / 2);
45    let lowest = target - (view - 1 - margin);
46    let highest = target - margin;
47    clamp_axis(origin.clamp(lowest, highest), map_len, view)
48}
49
50/// Keeps `origin` within the map (`0..=map_len - view`), or centres a map
51/// shorter than the viewport.
52fn clamp_axis(origin: i32, map_len: u16, view: i32) -> i32 {
53    let map_len = i32::from(map_len);
54    if map_len <= view {
55        -((view - map_len) / 2)
56    } else {
57        origin.clamp(0, map_len - view)
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use proptest::prelude::*;
64
65    use super::*;
66
67    /// A view of 35 × 30 tiles (the glyph skin's on a 70 × 30-cell map
68    /// area).
69    const VIEW: (i32, i32) = (35, 30);
70
71    /// Whether `tile` is in a `view`-tile viewport of camera `c`.
72    fn visible(tile: Pos, c: Camera, view: (i32, i32)) -> bool {
73        (c.origin.x..c.origin.x + view.0).contains(&tile.x)
74            && (c.origin.y..c.origin.y + view.1).contains(&tile.y)
75    }
76
77    fn cam(x: i32, y: i32) -> Camera {
78        Camera {
79            origin: Pos::new(x, y),
80        }
81    }
82
83    fn followed(from: Camera, target: Pos, w: u16, h: u16) -> Pos {
84        let mut c = from;
85        c.follow(target, w, h, VIEW, Camera::MARGIN);
86        c.origin
87    }
88
89    #[test]
90    fn small_map_is_centred() {
91        // 14 × 8: (35 − 14) / 2 = 10 blank tiles left, (30 − 8) / 2 = 11 above.
92        for target in [Pos::new(0, 0), Pos::new(13, 7), Pos::new(6, 3)] {
93            assert_eq!(followed(cam(0, 0), target, 14, 8), Pos::new(-10, -11));
94            assert_eq!(followed(cam(5, 9), target, 14, 8), Pos::new(-10, -11));
95        }
96        assert_eq!(
97            Camera::centred_on(Pos::new(0, 0), 14, 8, VIEW),
98            cam(-10, -11)
99        );
100        // Exactly viewport-sized: origin 0.
101        assert_eq!(
102            followed(cam(3, 3), Pos::new(34, 29), 35, 30),
103            Pos::new(0, 0)
104        );
105        // One axis small, the other large.
106        assert_eq!(
107            followed(cam(0, 0), Pos::new(0, 50), 34, 64),
108            Pos::new(0, 24)
109        );
110    }
111
112    #[test]
113    fn large_map_clamps_at_every_edge() {
114        let (w, h) = (64, 40);
115        // Top-left.
116        assert_eq!(followed(cam(10, 5), Pos::new(0, 0), w, h), Pos::new(0, 0));
117        // Bottom-right: origin = size − view.
118        assert_eq!(
119            followed(cam(0, 0), Pos::new(63, 39), w, h),
120            Pos::new(29, 10)
121        );
122        // Top-right and bottom-left.
123        assert_eq!(followed(cam(0, 5), Pos::new(63, 0), w, h), Pos::new(29, 0));
124        assert_eq!(followed(cam(20, 0), Pos::new(0, 39), w, h), Pos::new(0, 10));
125        // An origin already out of range is pulled back in.
126        assert_eq!(
127            followed(cam(-5, 99), Pos::new(10, 20), w, h),
128            Pos::new(0, 10)
129        );
130        let centred = |x, y| Camera::centred_on(Pos::new(x, y), w, h, VIEW);
131        assert_eq!(centred(63, 39), cam(29, 10));
132        assert_eq!(centred(0, 0), cam(0, 0));
133        assert_eq!(centred(32, 20), cam(15, 5));
134    }
135
136    #[test]
137    fn margin_is_respected() {
138        let (w, h) = (64, 64);
139        let start = cam(10, 10);
140        // Inside the margins: no scrolling.
141        assert_eq!(followed(start, Pos::new(13, 13), w, h), Pos::new(10, 10));
142        assert_eq!(followed(start, Pos::new(41, 36), w, h), Pos::new(10, 10));
143        // One tile into a margin scrolls by one.
144        assert_eq!(followed(start, Pos::new(12, 12), w, h), Pos::new(9, 9));
145        assert_eq!(followed(start, Pos::new(42, 37), w, h), Pos::new(11, 11));
146        // Far away: jumps so the target sits exactly at the margin.
147        assert_eq!(followed(start, Pos::new(60, 3), w, h), Pos::new(29, 0));
148        // Custom and oversized margins.
149        let mut c = start;
150        c.follow(Pos::new(10, 10), w, h, VIEW, 0);
151        assert_eq!(c.origin, Pos::new(10, 10));
152        c.follow(Pos::new(30, 30), w, h, VIEW, 99);
153        // Clamped to half the view (17, 14): the least scroll that centres it.
154        assert_eq!(c.origin, Pos::new(13, 15));
155        c.follow(Pos::new(30, 30), w, h, VIEW, -4);
156        assert_eq!(c.origin, Pos::new(13, 15));
157    }
158
159    #[test]
160    fn the_view_size_is_the_callers() {
161        // A 17 × 15 view (a skin with bigger tiles) on a 64 × 40 map.
162        let view = (17, 15);
163        let centred = |x, y, w, h| Camera::centred_on(Pos::new(x, y), w, h, view);
164        assert_eq!(centred(32, 20, 64, 40), cam(24, 13));
165        assert_eq!(centred(63, 39, 64, 40), cam(47, 25));
166        assert_eq!(centred(0, 0, 64, 40), cam(0, 0));
167        // A 14 × 8 map is smaller than it: centred, 1 blank tile left and 3
168        // above.
169        assert_eq!(centred(3, 5, 14, 8), cam(-1, -3));
170        // Across and down are separate.
171        assert_eq!(
172            Camera::centred_on(Pos::new(32, 20), 64, 40, (10, 4)),
173            cam(27, 18)
174        );
175        let mut c = cam(0, 0);
176        c.follow(Pos::new(20, 20), 64, 40, view, 3);
177        assert_eq!(c.origin, Pos::new(7, 9));
178        c.follow(Pos::new(8, 10), 64, 40, view, 3);
179        assert_eq!(c.origin, Pos::new(5, 7));
180        assert!(visible(Pos::new(8, 10), c, view));
181        assert!(!visible(Pos::new(22, 10), c, view));
182        assert!(!visible(Pos::new(8, 22), c, view));
183        assert!(!visible(Pos::new(4, 10), c, view));
184        assert!(!visible(Pos::new(8, 6), c, view));
185    }
186
187    proptest! {
188        #[test]
189        fn follow_keeps_target_visible_and_map_filling_the_view(
190            ox in -100..100i32, oy in -100..100i32,
191            tx in 0..64i32, ty in 0..64i32,
192            w in 1u16..65, h in 1u16..65, margin in 0..10i32,
193            view in (1..40i32, 1..40i32),
194        ) {
195            let target = Pos::new(tx.min(i32::from(w) - 1), ty.min(i32::from(h) - 1));
196            let mut c = cam(ox, oy);
197            c.follow(target, w, h, view, margin);
198            prop_assert!(visible(target, c, view));
199            for (o, len, view) in [(c.origin.x, w, view.0), (c.origin.y, h, view.1)] {
200                let len = i32::from(len);
201                if len > view {
202                    prop_assert!(o >= 0 && o + view <= len);
203                } else {
204                    prop_assert_eq!(o, -((view - len) / 2));
205                }
206            }
207        }
208    }
209}