src/camera.rs 6.4 K · 208 lines · raw · history

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