Skip to main content

game_gem/
camera.rs

1//! Camera / viewport system with multiple camera support.
2//!
3//! Advantages over macroquad's single global camera:
4//! - **Multiple cameras** — render different layers with different cameras
5//! - **Smooth follow** — built-in lerp-based following with deadzone
6//! - **Camera shake** — one-liner screen shake
7//! - **Zoom limits** — clamp min/max zoom
8//! - **Viewport splitting** — split-screen support
9
10use crate::math::{Vec2, Vec3, Rect, Mat4, Vec2Ext};
11
12/// A 2D camera that controls the view transform.
13///
14/// By default, the camera is at the center of the screen looking "down"
15/// the Y axis (screen-space: +Y = down).
16#[derive(Debug, Clone)]
17pub struct Camera {
18    /// World-space position (the "eye" point — center of the screen).
19    pub position: Vec2,
20    /// Rotation in radians.
21    pub rotation: f32,
22    /// Zoom level (1.0 = default, >1 = zoom in, <1 = zoom out).
23    pub zoom: f32,
24    /// Minimum zoom level.
25    pub min_zoom: f32,
26    /// Maximum zoom level.
27    pub max_zoom: f32,
28    /// Target position for smooth following.
29    follow_target: Option<Vec2>,
30    /// Follow lerp speed (0 = no movement, 1 = instant snap).
31    follow_lerp: f32,
32    /// Deadzone around the target before the camera starts moving.
33    follow_deadzone: f32,
34    /// Active shake.
35    shake: CameraShake,
36    /// The viewport rect on screen (for split-screen).
37    viewport: Option<Rect>,
38    /// Render layer mask (which layers this camera renders).
39    layers: u32,
40    /// Z-order (cameras with lower z render first).
41    z_order: i32,
42}
43
44/// Camera shake state.
45#[derive(Debug, Clone, Default)]
46struct CameraShake {
47    intensity: f32,
48    duration: f32,
49    remaining: f32,
50    offset: Vec2,
51}
52
53impl Camera {
54    /// Create a new camera at the given position.
55    pub fn new(x: f32, y: f32) -> Self {
56        Self {
57            position: Vec2::new(x, y),
58            rotation: 0.0,
59            zoom: 1.0,
60            min_zoom: 0.01,
61            max_zoom: 100.0,
62            follow_target: None,
63            follow_lerp: 0.1,
64            follow_deadzone: 0.0,
65            shake: CameraShake::default(),
66            viewport: None,
67            layers: u32::MAX, // All layers
68            z_order: 0,
69        }
70    }
71
72    /// Create a camera centered at the origin.
73    pub fn centered() -> Self {
74        Self::new(0.0, 0.0)
75    }
76
77    /// Set the zoom level (clamped to min/max).
78    pub fn set_zoom(&mut self, zoom: f32) {
79        self.zoom = zoom.clamp(self.min_zoom, self.max_zoom);
80    }
81
82    /// Set zoom limits.
83    pub fn set_zoom_limits(&mut self, min: f32, max: f32) {
84        self.min_zoom = min.min(max);
85        self.max_zoom = max.max(min);
86        self.zoom = self.zoom.clamp(self.min_zoom, self.max_zoom);
87    }
88
89    /// Make the camera follow a target position with smooth lerp.
90    pub fn follow(&mut self, target: Vec2, lerp_speed: f32) {
91        self.follow_target = Some(target);
92        self.follow_lerp = lerp_speed;
93    }
94
95    /// Set the deadzone for follow (camera won't move while target is within this distance).
96    pub fn set_follow_deadzone(&mut self, deadzone: f32) {
97        self.follow_deadzone = deadzone;
98    }
99
100    /// Stop following.
101    pub fn stop_following(&mut self) {
102        self.follow_target = None;
103    }
104
105    /// Trigger screen shake.
106    ///
107    /// - `intensity` — maximum pixel offset in each direction
108    /// - `duration` — how long the shake lasts (seconds)
109    pub fn shake(&mut self, intensity: f32, duration: f32) {
110        self.shake.intensity = intensity;
111        self.shake.duration = duration;
112        self.shake.remaining = duration;
113    }
114
115    /// Set the viewport rectangle (for split-screen).
116    ///
117    /// Coordinates are in screen pixels (0,0 = top-left).
118    pub fn set_viewport(&mut self, viewport: Rect) {
119        self.viewport = Some(viewport);
120    }
121
122    /// Clear the viewport (render to full screen).
123    pub fn clear_viewport(&mut self) {
124        self.viewport = None;
125    }
126
127    /// Set which layers this camera renders (bitmask).
128    pub fn set_layers(&mut self, layers: u32) {
129        self.layers = layers;
130    }
131
132    /// Set the z-order (lower renders first).
133    pub fn set_z_order(&mut self, z: i32) {
134        self.z_order = z;
135    }
136
137    /// Update the camera (call once per frame).
138    ///
139    /// Handles smooth following and shake.
140    pub fn update(&mut self, dt: f32) {
141        // Smooth follow
142        if let Some(target) = self.follow_target {
143            let diff = target - self.position;
144            let dist = diff.length();
145            if dist > self.follow_deadzone {
146                self.position = self.position.move_toward(target, dist * self.follow_lerp);
147            }
148        }
149
150        // Shake
151        if self.shake.remaining > 0.0 {
152            self.shake.remaining -= dt;
153            let t = self.shake.remaining / self.shake.duration;
154            let current_intensity = self.shake.intensity * t;
155            let angle = quad_rand::gen_range(0.0, std::f32::consts::TAU);
156            self.shake.offset = Vec2::new(
157                angle.cos() * current_intensity,
158                angle.sin() * current_intensity,
159            );
160        } else {
161            self.shake.offset = Vec2::ZERO;
162        }
163    }
164
165    /// Get the view-projection matrix for this camera.
166    ///
167    /// This transforms world coordinates to screen coordinates.
168    pub fn view_matrix(&self, screen_size: Vec2) -> Mat4 {
169        // Translate so camera position is at screen center
170        let offset = self.position + self.shake.offset;
171        let mut view = Mat4::from_translation(Vec3::new(
172            -offset.x,
173            -offset.y,
174            0.0,
175        ));
176
177        // Rotate
178        if self.rotation.abs() > 1e-6 {
179            view = Mat4::from_rotation_z(-self.rotation) * view;
180        }
181
182        // Scale (zoom)
183        if (self.zoom - 1.0).abs() > 1e-6 {
184            let scale = Mat4::from_scale(Vec3::new(self.zoom, self.zoom, 1.0));
185            let to_center = Mat4::from_translation(Vec3::new(
186                screen_size.x * 0.5,
187                screen_size.y * 0.5,
188                0.0,
189            ));
190            let from_center = Mat4::from_translation(Vec3::new(
191                -screen_size.x * 0.5,
192                -screen_size.y * 0.5,
193                0.0,
194            ));
195            view = to_center * scale * from_center * view;
196        }
197
198        view
199    }
200
201    /// Convert screen coordinates to world coordinates.
202    pub fn screen_to_world(&self, screen_pos: Vec2, screen_size: Vec2) -> Vec2 {
203        let center = screen_size * 0.5;
204        let mut world = (screen_pos - center) / self.zoom;
205
206        // Un-rotate
207        if self.rotation.abs() > 1e-6 {
208            world = world.rotated(-self.rotation);
209        }
210
211        world + self.position + self.shake.offset
212    }
213
214    /// Convert world coordinates to screen coordinates.
215    pub fn world_to_screen(&self, world_pos: Vec2, screen_size: Vec2) -> Vec2 {
216        let center = screen_size * 0.5;
217        let relative = world_pos - self.position - self.shake.offset;
218
219        let rotated = if self.rotation.abs() > 1e-6 {
220            relative.rotated(self.rotation)
221        } else {
222            relative
223        };
224
225        rotated * self.zoom + center
226    }
227
228    /// Get the visible world-space rectangle (accounting for zoom and position).
229    pub fn visible_rect(&self, screen_size: Vec2) -> Rect {
230        let half_extents = screen_size / (2.0 * self.zoom);
231        Rect::centered(self.position, half_extents * 2.0)
232    }
233}