1#[derive(Clone, Copy, Debug)]
3pub struct CameraBounds {
4 pub min_x: f32,
5 pub min_y: f32,
6 pub max_x: f32,
7 pub max_y: f32,
8}
9
10pub struct Camera2D {
12 pub x: f32,
13 pub y: f32,
14 pub zoom: f32,
15 pub viewport_size: [f32; 2],
16 pub bounds: Option<CameraBounds>,
17}
18
19impl Default for Camera2D {
20 fn default() -> Self {
21 Self {
22 x: 0.0,
23 y: 0.0,
24 zoom: 1.0,
25 viewport_size: [800.0, 600.0],
26 bounds: None,
27 }
28 }
29}
30
31impl Camera2D {
32 pub fn clamp_to_bounds(&mut self) {
35 let Some(bounds) = self.bounds else { return };
36
37 let half_w = self.viewport_size[0] / (2.0 * self.zoom);
38 let half_h = self.viewport_size[1] / (2.0 * self.zoom);
39
40 let bounds_w = bounds.max_x - bounds.min_x;
41 let bounds_h = bounds.max_y - bounds.min_y;
42
43 if half_w * 2.0 >= bounds_w {
45 self.x = bounds.min_x + bounds_w / 2.0;
46 } else {
47 self.x = self.x.clamp(bounds.min_x + half_w, bounds.max_x - half_w);
48 }
49
50 if half_h * 2.0 >= bounds_h {
51 self.y = bounds.min_y + bounds_h / 2.0;
52 } else {
53 self.y = self.y.clamp(bounds.min_y + half_h, bounds.max_y - half_h);
54 }
55 }
56
57 pub fn view_proj(&self) -> [f32; 16] {
64 let half_w = self.viewport_size[0] / (2.0 * self.zoom);
65 let half_h = self.viewport_size[1] / (2.0 * self.zoom);
66
67 let left = self.x - half_w;
68 let right = self.x + half_w;
69 let top = self.y - half_h;
70 let bottom = self.y + half_h;
71
72 let sx = 2.0 / (right - left);
74 let sy = 2.0 / (top - bottom); let tx = -(right + left) / (right - left);
76 let ty = -(top + bottom) / (top - bottom);
77
78 [
79 sx, 0.0, 0.0, 0.0,
80 0.0, sy, 0.0, 0.0,
81 0.0, 0.0, 1.0, 0.0,
82 tx, ty, 0.0, 1.0,
83 ]
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 #[test]
92 fn default_camera_has_expected_values() {
93 let cam = Camera2D::default();
94 assert_eq!(cam.x, 0.0);
95 assert_eq!(cam.y, 0.0);
96 assert_eq!(cam.zoom, 1.0);
97 assert_eq!(cam.viewport_size, [800.0, 600.0]);
98 }
99
100 #[test]
101 fn view_proj_at_origin_with_zoom_1() {
102 let cam = Camera2D {
103 x: 0.0,
104 y: 0.0,
105 zoom: 1.0,
106 viewport_size: [800.0, 600.0],
107 ..Default::default()
108 };
109 let mat = cam.view_proj();
110
111 let expected_sx = 2.0 / 800.0; let expected_sy = 2.0 / -600.0; assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch");
118 assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch");
119 assert_eq!(mat[12], 0.0, "tx should be 0 at origin");
120 assert_eq!(mat[13], 0.0, "ty should be 0 at origin");
121 }
122
123 #[test]
124 fn view_proj_with_camera_offset() {
125 let cam = Camera2D {
126 x: 100.0,
127 y: 50.0,
128 zoom: 1.0,
129 viewport_size: [800.0, 600.0],
130 ..Default::default()
131 };
132 let mat = cam.view_proj();
133
134 assert!((mat[12] - -0.25).abs() < 1e-6, "tx mismatch for offset camera");
139
140 assert!((mat[13] - (100.0 / 600.0)).abs() < 1e-5, "ty mismatch for offset camera");
143 }
144
145 #[test]
146 fn view_proj_with_zoom() {
147 let cam = Camera2D {
148 x: 0.0,
149 y: 0.0,
150 zoom: 2.0,
151 viewport_size: [800.0, 600.0],
152 ..Default::default()
153 };
154 let mat = cam.view_proj();
155
156 let expected_sx = 2.0 / 400.0; let expected_sy = 2.0 / -300.0; assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch with zoom");
164 assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch with zoom");
165 }
166
167 #[test]
168 fn view_proj_with_different_viewport() {
169 let cam = Camera2D {
170 x: 0.0,
171 y: 0.0,
172 zoom: 1.0,
173 viewport_size: [1920.0, 1080.0],
174 ..Default::default()
175 };
176 let mat = cam.view_proj();
177
178 let expected_sx = 2.0 / 1920.0;
179 let expected_sy = 2.0 / -1080.0;
180
181 assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch for HD viewport");
182 assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch for HD viewport");
183 }
184
185 #[test]
186 fn view_proj_matrix_is_column_major() {
187 let cam = Camera2D::default();
188 let mat = cam.view_proj();
189
190 assert_eq!(mat[10], 1.0, "z scale should be 1.0");
198 assert_eq!(mat[15], 1.0, "w component should be 1.0");
199 assert_eq!(mat[2], 0.0, "unused z component");
200 assert_eq!(mat[3], 0.0, "unused w component");
201 }
202
203 #[test]
204 fn very_high_zoom_produces_small_view_area() {
205 let cam = Camera2D {
206 x: 0.0,
207 y: 0.0,
208 zoom: 10.0,
209 viewport_size: [800.0, 600.0],
210 ..Default::default()
211 };
212 let mat = cam.view_proj();
213
214 let expected_sx = 2.0 / 80.0; let expected_sy = 2.0 / -60.0; assert!((mat[0] - expected_sx).abs() < 1e-6);
221 assert!((mat[5] - expected_sy).abs() < 1e-5);
222 }
223
224 #[test]
225 fn very_low_zoom_produces_large_view_area() {
226 let cam = Camera2D {
227 x: 0.0,
228 y: 0.0,
229 zoom: 0.1,
230 viewport_size: [800.0, 600.0],
231 ..Default::default()
232 };
233 let mat = cam.view_proj();
234
235 let expected_sx = 2.0 / 8000.0; let expected_sy = 2.0 / -6000.0; assert!((mat[0] - expected_sx).abs() < 1e-7);
242 assert!((mat[5] - expected_sy).abs() < 1e-6);
243 }
244
245 #[test]
246 fn negative_camera_position() {
247 let cam = Camera2D {
248 x: -100.0,
249 y: -50.0,
250 zoom: 1.0,
251 viewport_size: [800.0, 600.0],
252 ..Default::default()
253 };
254 let mat = cam.view_proj();
255
256 assert!((mat[12] - 0.25).abs() < 1e-6);
260 }
261
262 #[test]
263 fn clamp_to_bounds_keeps_camera_in_range() {
264 let mut cam = Camera2D {
265 x: -100.0,
266 y: -100.0,
267 zoom: 1.0,
268 viewport_size: [800.0, 600.0],
269 bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 1600.0, max_y: 1200.0 }),
270 };
271 cam.clamp_to_bounds();
272 assert_eq!(cam.x, 400.0);
274 assert_eq!(cam.y, 300.0);
275 }
276
277 #[test]
278 fn clamp_to_bounds_right_edge() {
279 let mut cam = Camera2D {
280 x: 1500.0,
281 y: 1100.0,
282 zoom: 1.0,
283 viewport_size: [800.0, 600.0],
284 bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 1600.0, max_y: 1200.0 }),
285 };
286 cam.clamp_to_bounds();
287 assert_eq!(cam.x, 1200.0);
289 assert_eq!(cam.y, 900.0);
290 }
291
292 #[test]
293 fn clamp_to_bounds_centers_when_view_larger_than_bounds() {
294 let mut cam = Camera2D {
295 x: 0.0,
296 y: 0.0,
297 zoom: 0.5, viewport_size: [800.0, 600.0],
299 bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 400.0, max_y: 300.0 }),
300 };
301 cam.clamp_to_bounds();
302 assert_eq!(cam.x, 200.0);
304 assert_eq!(cam.y, 150.0);
305 }
306
307 #[test]
308 fn clamp_no_bounds_is_noop() {
309 let mut cam = Camera2D {
310 x: -999.0,
311 y: 999.0,
312 zoom: 1.0,
313 viewport_size: [800.0, 600.0],
314 bounds: None,
315 };
316 cam.clamp_to_bounds();
317 assert_eq!(cam.x, -999.0);
318 assert_eq!(cam.y, 999.0);
319 }
320
321 #[test]
322 fn clamp_with_zoom() {
323 let mut cam = Camera2D {
324 x: 10.0,
325 y: 10.0,
326 zoom: 2.0, viewport_size: [800.0, 600.0],
328 bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 1000.0, max_y: 800.0 }),
329 };
330 cam.clamp_to_bounds();
331 assert_eq!(cam.x, 200.0);
332 assert_eq!(cam.y, 150.0);
333 }
334
335 #[test]
336 fn square_viewport() {
337 let cam = Camera2D {
338 x: 0.0,
339 y: 0.0,
340 zoom: 1.0,
341 viewport_size: [600.0, 600.0],
342 ..Default::default()
343 };
344 let mat = cam.view_proj();
345
346 let expected_sx = 2.0 / 600.0;
347 let expected_sy = 2.0 / -600.0;
348
349 assert!((mat[0] - expected_sx).abs() < 1e-6);
350 assert!((mat[5] - expected_sy).abs() < 1e-6);
351 }
352}