src/camera.rs 7.8 K · 267 lines · raw · history

1 use bevy::{
2 input::mouse::{AccumulatedMouseScroll, MouseScrollPixelsPerLine},
3 prelude::*,
4 window::PrimaryWindow,
5 };
6
7 use crate::{
8 actions::Action,
9 world::WorldMap,
10 worldgen::{WorldGenerated, spawn_tiles},
11 };
12
13 pub struct CameraPlugin;
14
15 const DRAG_BUTTONS: [MouseButton; 2] = [MouseButton::Middle, MouseButton::Right];
16
17 impl Plugin for CameraPlugin {
18 fn build(&self, app: &mut App) {
19 app.add_systems(Startup, spawn_camera).add_systems(
20 Update,
21 (
22 center_on_spawn.run_if(on_message::<WorldGenerated>),
23 keyboard_pan,
24 drag_pan,
25 scroll_zoom,
26 button_zoom,
27 clamp_to_world,
28 )
29 .chain(),
30 );
31 }
32 }
33
34 #[derive(Component, Clone, Debug)]
35 pub struct CameraController {
36 pub pan_speed: f32,
37 pub fast_multiplier: f32,
38 pub zoom_per_line: f32,
39 pub min_scale: f32,
40 pub max_scale: f32,
41 }
42
43 impl Default for CameraController {
44 fn default() -> Self {
45 Self {
46 pan_speed: 600.0,
47 fast_multiplier: 3.0,
48 zoom_per_line: 0.15,
49 min_scale: 0.2,
50 max_scale: 4.0,
51 }
52 }
53 }
54
55 const BUTTON_ZOOM_LINES: f32 = 2.0;
56
57 impl CameraController {
58 pub fn zoomed(&self, scale: f32, lines: f32) -> f32 {
59 (scale * (1.0 - lines * self.zoom_per_line)).clamp(self.min_scale, self.max_scale)
60 }
61 }
62
63 fn button_zoom(
64 mut actions: MessageReader<Action>,
65 camera: Single<(&mut Projection, &CameraController)>,
66 ) {
67 let (mut projection, controller) = camera.into_inner();
68 let projection = ortho(&mut projection);
69 for action in actions.read() {
70 let lines = match action {
71 Action::ZoomIn => BUTTON_ZOOM_LINES,
72 Action::ZoomOut => -BUTTON_ZOOM_LINES,
73 _ => continue,
74 };
75 projection.scale = controller.zoomed(projection.scale, lines);
76 }
77 }
78
79 fn spawn_camera(mut commands: Commands, map: Res<WorldMap>) {
80 commands.spawn((
81 Camera2d,
82 CameraController::default(),
83 Transform::from_translation((map.world_size() / 2.0).extend(0.0)),
84 ));
85 }
86
87 fn center_on_spawn(map: Res<WorldMap>, mut camera: Single<&mut Transform, With<CameraController>>) {
88 if let Some(&tile) = spawn_tiles(&map, 1).first() {
89 camera.translation = WorldMap::tile_center(tile).extend(camera.translation.z);
90 }
91 }
92
93 fn ortho(projection: &mut Projection) -> &mut OrthographicProjection {
94 match projection {
95 Projection::Orthographic(o) => o,
96 _ => panic!("camera must use an orthographic projection"),
97 }
98 }
99
100 fn keyboard_pan(
101 keys: Res<ButtonInput<KeyCode>>,
102 time: Res<Time<Real>>,
103 camera: Single<(&mut Transform, &mut Projection, &CameraController)>,
104 ) {
105 let (mut transform, mut projection, controller) = camera.into_inner();
106 let mut dir = Vec2::ZERO;
107 for (key, d) in [
108 (KeyCode::KeyW, Vec2::Y),
109 (KeyCode::ArrowUp, Vec2::Y),
110 (KeyCode::KeyS, Vec2::NEG_Y),
111 (KeyCode::ArrowDown, Vec2::NEG_Y),
112 (KeyCode::KeyA, Vec2::NEG_X),
113 (KeyCode::ArrowLeft, Vec2::NEG_X),
114 (KeyCode::KeyD, Vec2::X),
115 (KeyCode::ArrowRight, Vec2::X),
116 ] {
117 if keys.pressed(key) {
118 dir += d;
119 }
120 }
121 if dir == Vec2::ZERO {
122 return;
123 }
124 let fast = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
125 let speed = controller.pan_speed
126 * if fast {
127 controller.fast_multiplier
128 } else {
129 1.0
130 };
131 let scale = ortho(&mut projection).scale;
132 transform.translation += (dir.normalize() * speed * scale * time.delta_secs()).extend(0.0);
133 }
134
135 fn drag_pan(
136 buttons: Res<ButtonInput<MouseButton>>,
137 window: Single<&Window, With<PrimaryWindow>>,
138 camera: Single<(&mut Transform, &mut Projection), With<CameraController>>,
139 mut last_cursor: Local<Option<Vec2>>,
140 ) {
141 let cursor = window.cursor_position();
142 let previous = std::mem::replace(&mut *last_cursor, cursor);
143 if !buttons.any_pressed(DRAG_BUTTONS) {
144 return;
145 }
146 let (Some(cursor), Some(previous)) = (cursor, previous) else {
147 return;
148 };
149 let (mut transform, mut projection) = camera.into_inner();
150 let scale = ortho(&mut projection).scale;
151 transform.translation += (screen_to_world_delta(cursor - previous) * -scale).extend(0.0);
152 }
153
154 fn scroll_zoom(
155 scroll: Res<AccumulatedMouseScroll>,
156 pixels_per_line: Res<MouseScrollPixelsPerLine>,
157 window: Single<&Window, With<PrimaryWindow>>,
158 camera: Single<(
159 &Camera,
160 &GlobalTransform,
161 &mut Transform,
162 &mut Projection,
163 &CameraController,
164 )>,
165 ) {
166 let lines = scroll.to_lines(&pixels_per_line).delta.y;
167 if lines == 0.0 {
168 return;
169 }
170 let (camera, global, mut transform, mut projection, controller) = camera.into_inner();
171 let projection = ortho(&mut projection);
172 let old_scale = projection.scale;
173 let new_scale = controller.zoomed(old_scale, lines);
174 projection.scale = new_scale;
175
176 let focus = window
177 .cursor_position()
178 .and_then(|cursor| camera.viewport_to_world_2d(global, cursor).ok());
179 if let Some(focus) = focus {
180 let pos = zoom_towards(
181 transform.translation.truncate(),
182 focus,
183 old_scale,
184 new_scale,
185 );
186 transform.translation = pos.extend(transform.translation.z);
187 }
188 }
189
190 fn clamp_to_world(map: Res<WorldMap>, mut camera: Single<&mut Transform, With<CameraController>>) {
191 let clamped = clamp_to_rect(camera.translation.truncate(), Vec2::ZERO, map.world_size());
192 camera.translation = clamped.extend(camera.translation.z);
193 }
194
195 pub fn screen_to_world_delta(screen: Vec2) -> Vec2 {
196 Vec2::new(screen.x, -screen.y)
197 }
198
199 pub fn zoom_towards(camera: Vec2, focus: Vec2, old_scale: f32, new_scale: f32) -> Vec2 {
200 focus + (camera - focus) * (new_scale / old_scale)
201 }
202
203 pub fn clamp_to_rect(pos: Vec2, min: Vec2, max: Vec2) -> Vec2 {
204 pos.clamp(min, max)
205 }
206
207 #[cfg(test)]
208 mod tests {
209 use super::*;
210
211 fn world_point_at_offset(camera: Vec2, scale: f32, screen_offset: Vec2) -> Vec2 {
212 camera + screen_offset * scale
213 }
214
215 #[test]
216 fn zoom_buttons_zoom_in_steps_within_limits() {
217 let controller = CameraController::default();
218 assert!(controller.zoomed(1.0, BUTTON_ZOOM_LINES) < 1.0);
219 assert!(controller.zoomed(1.0, -BUTTON_ZOOM_LINES) > 1.0);
220 assert_eq!(
221 controller.zoomed(controller.min_scale, 10.0),
222 controller.min_scale
223 );
224 assert_eq!(
225 controller.zoomed(controller.max_scale, -10.0),
226 controller.max_scale
227 );
228 }
229
230 #[test]
231 fn zooming_keeps_the_point_under_the_cursor_fixed() {
232 let camera = Vec2::new(100.0, 50.0);
233 let cursor_offset = Vec2::new(200.0, -120.0);
234 let focus = world_point_at_offset(camera, 1.0, cursor_offset);
235
236 let new_camera = zoom_towards(camera, focus, 1.0, 0.5);
237
238 assert!(world_point_at_offset(new_camera, 0.5, cursor_offset).abs_diff_eq(focus, 1e-4));
239 }
240
241 #[test]
242 fn zooming_on_camera_center_does_not_move_camera() {
243 let camera = Vec2::new(10.0, 20.0);
244 assert_eq!(zoom_towards(camera, camera, 1.0, 3.0), camera);
245 }
246
247 #[test]
248 fn dragging_moves_world_opposite_to_screen_y_because_screen_y_points_down() {
249 assert_eq!(
250 screen_to_world_delta(Vec2::new(3.0, 4.0)),
251 Vec2::new(3.0, -4.0)
252 );
253 }
254
255 #[test]
256 fn camera_center_cannot_leave_the_world_so_the_map_stays_on_screen() {
257 let max = Vec2::new(100.0, 50.0);
258 assert_eq!(
259 clamp_to_rect(Vec2::new(-5.0, 60.0), Vec2::ZERO, max),
260 Vec2::new(0.0, 50.0)
261 );
262 assert_eq!(
263 clamp_to_rect(Vec2::new(5.0, 6.0), Vec2::ZERO, max),
264 Vec2::new(5.0, 6.0)
265 );
266 }
267 }