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}