Skip to main content

retroglyph_core/
camera.rs

1//! A scrolling viewport into a world larger than the screen.
2//!
3//! [`Camera`] is pure geometry: it converts between world coordinates (cells in
4//! some large space) and screen coordinates (cells in a [`Rect`] on the
5//! terminal), and reports which world cells are currently visible. It holds no
6//! rendering opinion, so it works with any drawing style and is testable
7//! without a backend.
8//!
9//! Centering clamps to the world edges (the "scrolling map" convention): the
10//! viewport never scrolls past `[0, world)`, so the target stays centered
11//! except near the edges, where it drifts toward the corner. A world smaller
12//! than the viewport pins the origin at `(0, 0)`.
13//!
14//! See the `12_dungeon_scroll` example for `Camera` in action:
15//! <https://main.retroglyph.dev/examples/12_dungeon_scroll/terminal/>.
16//!
17//! # Example
18//!
19//! ```
20//! use retroglyph_core::{Camera, Pos, Rect, Size};
21//!
22//! // A 10x10 viewport onto a 100x100 world.
23//! let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size { width: 100, height: 100 });
24//! cam.center_on(Pos::new(50, 50));
25//! assert_eq!(cam.origin(), Pos::new(45, 45));
26//! assert_eq!(cam.world_to_screen(Pos::new(50, 50)), Some(Pos::new(5, 5)));
27//! // Near an edge the view clamps rather than showing past the world.
28//! cam.center_on(Pos::new(1, 1));
29//! assert_eq!(cam.origin(), Pos::new(0, 0));
30//! ```
31
32use crate::grid::{Pos, Rect, Size};
33
34/// A rectangular viewport onto a larger world, with world/screen conversions.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct Camera {
37    viewport: Rect,
38    world: Size,
39    origin: Pos,
40}
41
42impl Camera {
43    /// Create a camera drawing into `viewport` (screen cells) over a world of
44    /// `world` cells. The initial origin is `(0, 0)`; call
45    /// [`center_on`](Self::center_on) to follow a target.
46    #[must_use]
47    pub const fn new(viewport: Rect, world: Size) -> Self {
48        Self {
49            viewport,
50            world,
51            origin: Pos::new(0, 0),
52        }
53    }
54
55    /// The screen rectangle the world is drawn into.
56    #[must_use]
57    pub const fn viewport(&self) -> Rect {
58        self.viewport
59    }
60
61    /// The world dimensions.
62    #[must_use]
63    pub const fn world(&self) -> Size {
64        self.world
65    }
66
67    /// The world cell shown at the viewport's top-left corner.
68    #[must_use]
69    pub const fn origin(&self) -> Pos {
70        self.origin
71    }
72
73    /// Replace the viewport (for example after a terminal resize), keeping the
74    /// world unchanged and re-clamping the origin so it stays in bounds.
75    ///
76    /// Never panics: a `viewport` larger than `world` re-clamps the origin to `(0, 0)` via
77    /// [`saturating_sub`](u16::saturating_sub) rather than underflowing.
78    pub fn set_viewport(&mut self, viewport: Rect) {
79        self.viewport = viewport;
80        self.origin = Pos::new(
81            self.origin
82                .x
83                .min(max_origin(viewport.width(), self.world.width)),
84            self.origin
85                .y
86                .min(max_origin(viewport.height(), self.world.height)),
87        );
88    }
89
90    /// Center the view on `target` (world coords), clamped to the world edges so
91    /// the viewport never scrolls past `[0, world)`.
92    ///
93    /// Never panics, even for a `target` outside `[0, world)`: the offset and clamp are both
94    /// computed with saturating arithmetic.
95    pub fn center_on(&mut self, target: Pos) {
96        self.origin = Pos::new(
97            center_axis(target.x, self.viewport.width(), self.world.width),
98            center_axis(target.y, self.viewport.height(), self.world.height),
99        );
100    }
101
102    /// The world rectangle currently visible, clamped to world bounds.
103    ///
104    /// Never panics: the clamp against `world` uses
105    /// [`saturating_sub`](u16::saturating_sub), so it cannot underflow even if `origin` is
106    /// somehow past `world`'s edge.
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// use retroglyph_core::{Camera, Pos, Rect, Size};
112    ///
113    /// // A 10x10 viewport near the bottom-right corner of a 12x12 world: the origin clamps
114    /// // to (2, 2), so the visible rect is narrower than the viewport rather than reading
115    /// // past the world edge.
116    /// let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size { width: 12, height: 12 });
117    /// cam.center_on(Pos::new(11, 11));
118    /// assert_eq!(cam.origin(), Pos::new(2, 2));
119    /// assert_eq!(cam.visible_bounds(), Rect::new(2, 2, 10, 10));
120    ///
121    /// // A world smaller than the viewport: the visible rect is the whole world, not the
122    /// // full viewport size.
123    /// let small = Camera::new(Rect::new(0, 0, 20, 20), Size { width: 5, height: 5 });
124    /// assert_eq!(small.visible_bounds(), Rect::new(0, 0, 5, 5));
125    /// ```
126    #[must_use]
127    pub fn visible_bounds(&self) -> Rect {
128        let w = self
129            .viewport
130            .width()
131            .min(self.world.width.saturating_sub(self.origin.x));
132        let h = self
133            .viewport
134            .height()
135            .min(self.world.height.saturating_sub(self.origin.y));
136        Rect::new(self.origin.x, self.origin.y, w, h)
137    }
138
139    /// Map a world position to its screen position, or `None` if it is outside
140    /// the visible viewport.
141    #[must_use]
142    pub const fn world_to_screen(&self, world: Pos) -> Option<Pos> {
143        if world.x < self.origin.x || world.y < self.origin.y {
144            return None;
145        }
146        let dx = world.x - self.origin.x;
147        let dy = world.y - self.origin.y;
148        if dx >= self.viewport.width() || dy >= self.viewport.height() {
149            return None;
150        }
151        Some(Pos::new(
152            self.viewport.left() + dx,
153            self.viewport.top() + dy,
154        ))
155    }
156
157    /// Map a screen position back to a world position, or `None` if it is
158    /// outside the viewport or beyond the world (useful for mouse picking).
159    ///
160    /// # Examples
161    ///
162    /// ```
163    /// use retroglyph_core::{Camera, Pos, Rect, Size};
164    ///
165    /// let mut cam = Camera::new(Rect::new(5, 5, 10, 10), Size { width: 100, height: 100 });
166    /// cam.center_on(Pos::new(50, 50));
167    ///
168    /// // Inside the viewport: maps back to the world cell under it.
169    /// assert_eq!(cam.screen_to_world(Pos::new(5, 5)), Some(Pos::new(45, 45)));
170    ///
171    /// // Off the viewport entirely (the viewport starts at x = 5): `None`, not a clamp.
172    /// assert_eq!(cam.screen_to_world(Pos::new(0, 0)), None);
173    /// ```
174    #[must_use]
175    pub fn screen_to_world(&self, screen: Pos) -> Option<Pos> {
176        if !self.viewport.contains_pos(screen) {
177            return None;
178        }
179        let wx = self.origin.x + (screen.x - self.viewport.left());
180        let wy = self.origin.y + (screen.y - self.viewport.top());
181        if wx >= self.world.width || wy >= self.world.height {
182            return None;
183        }
184        Some(Pos::new(wx, wy))
185    }
186
187    /// Iterate the visible cells as `(world, screen)` position pairs, in
188    /// row-major order. Only cells that exist in the world are yielded, so the
189    /// caller can fill the rest of the viewport with a background.
190    #[must_use = "iterators are lazy and do nothing unless consumed"]
191    pub fn cells(&self) -> impl Iterator<Item = (Pos, Pos)> + '_ {
192        let vis = self.visible_bounds();
193        let vp = self.viewport;
194        let origin = self.origin;
195        (vis.top()..vis.bottom()).flat_map(move |wy| {
196            (vis.left()..vis.right()).map(move |wx| {
197                let screen = Pos::new(vp.left() + (wx - origin.x), vp.top() + (wy - origin.y));
198                (Pos::new(wx, wy), screen)
199            })
200        })
201    }
202}
203
204/// The largest in-bounds origin for a `view`-wide window over `[0, world)`.
205/// Zero when the world is no larger than the view.
206const fn max_origin(view: u16, world: u16) -> u16 {
207    world.saturating_sub(view)
208}
209
210/// Origin that centers `target` in a `view`-wide window, clamped to bounds.
211fn center_axis(target: u16, view: u16, world: u16) -> u16 {
212    target.saturating_sub(view / 2).min(max_origin(view, world))
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn cam() -> Camera {
220        Camera::new(
221            Rect::new(0, 0, 10, 10),
222            Size {
223                width: 100,
224                height: 100,
225            },
226        )
227    }
228
229    #[test]
230    fn centers_in_the_interior() {
231        let mut c = cam();
232        c.center_on(Pos::new(50, 50));
233        assert_eq!(c.origin(), Pos::new(45, 45));
234        assert_eq!(c.world_to_screen(Pos::new(50, 50)), Some(Pos::new(5, 5)));
235        assert_eq!(c.screen_to_world(Pos::new(5, 5)), Some(Pos::new(50, 50)));
236    }
237
238    #[test]
239    fn clamps_at_the_low_edge() {
240        let mut c = cam();
241        c.center_on(Pos::new(1, 1));
242        assert_eq!(c.origin(), Pos::new(0, 0));
243        assert_eq!(c.world_to_screen(Pos::new(1, 1)), Some(Pos::new(1, 1)));
244    }
245
246    #[test]
247    fn clamps_at_the_high_edge() {
248        let mut c = cam();
249        c.center_on(Pos::new(99, 99));
250        // origin = min(99 - 5, 100 - 10) = min(94, 90) = 90.
251        assert_eq!(c.origin(), Pos::new(90, 90));
252        assert_eq!(c.world_to_screen(Pos::new(99, 99)), Some(Pos::new(9, 9)));
253    }
254
255    #[test]
256    fn offscreen_positions_return_none() {
257        let mut c = cam();
258        c.center_on(Pos::new(50, 50)); // shows world [45,55)
259        assert_eq!(c.world_to_screen(Pos::new(44, 50)), None);
260        assert_eq!(c.world_to_screen(Pos::new(55, 50)), None);
261    }
262
263    #[test]
264    fn world_smaller_than_viewport_pins_origin() {
265        let mut c = Camera::new(
266            Rect::new(2, 2, 20, 20),
267            Size {
268                width: 5,
269                height: 5,
270            },
271        );
272        c.center_on(Pos::new(3, 3));
273        assert_eq!(c.origin(), Pos::new(0, 0));
274        let visible = c.visible_bounds();
275        assert_eq!((visible.width(), visible.height()), (5, 5));
276        // Cells map into the viewport, offset by its top-left.
277        assert_eq!(c.world_to_screen(Pos::new(0, 0)), Some(Pos::new(2, 2)));
278    }
279
280    #[test]
281    fn cells_yields_visible_world_and_screen_pairs() {
282        let mut c = cam();
283        c.center_on(Pos::new(50, 50));
284        let pairs: Vec<_> = c.cells().collect();
285        assert_eq!(pairs.len(), 100); // 10x10 viewport, world larger
286        assert_eq!(pairs[0], (Pos::new(45, 45), Pos::new(0, 0)));
287        assert_eq!(pairs[99], (Pos::new(54, 54), Pos::new(9, 9)));
288    }
289}