use bevy::{ input::mouse::{AccumulatedMouseScroll, MouseScrollPixelsPerLine}, prelude::*, window::PrimaryWindow, }; use crate::{ actions::Action, world::WorldMap, worldgen::{WorldGenerated, spawn_tiles}, }; pub struct CameraPlugin; const DRAG_BUTTONS: [MouseButton; 2] = [MouseButton::Middle, MouseButton::Right]; impl Plugin for CameraPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, spawn_camera).add_systems( Update, ( center_on_spawn.run_if(on_message::), keyboard_pan, drag_pan, scroll_zoom, button_zoom, clamp_to_world, ) .chain(), ); } } #[derive(Component, Clone, Debug)] pub struct CameraController { pub pan_speed: f32, pub fast_multiplier: f32, pub zoom_per_line: f32, pub min_scale: f32, pub max_scale: f32, } impl Default for CameraController { fn default() -> Self { Self { pan_speed: 600.0, fast_multiplier: 3.0, zoom_per_line: 0.15, min_scale: 0.2, max_scale: 4.0, } } } const BUTTON_ZOOM_LINES: f32 = 2.0; impl CameraController { pub fn zoomed(&self, scale: f32, lines: f32) -> f32 { (scale * (1.0 - lines * self.zoom_per_line)).clamp(self.min_scale, self.max_scale) } } fn button_zoom( mut actions: MessageReader, camera: Single<(&mut Projection, &CameraController)>, ) { let (mut projection, controller) = camera.into_inner(); let projection = ortho(&mut projection); for action in actions.read() { let lines = match action { Action::ZoomIn => BUTTON_ZOOM_LINES, Action::ZoomOut => -BUTTON_ZOOM_LINES, _ => continue, }; projection.scale = controller.zoomed(projection.scale, lines); } } fn spawn_camera(mut commands: Commands, map: Res) { commands.spawn(( Camera2d, CameraController::default(), Transform::from_translation((map.world_size() / 2.0).extend(0.0)), )); } fn center_on_spawn(map: Res, mut camera: Single<&mut Transform, With>) { if let Some(&tile) = spawn_tiles(&map, 1).first() { camera.translation = WorldMap::tile_center(tile).extend(camera.translation.z); } } fn ortho(projection: &mut Projection) -> &mut OrthographicProjection { match projection { Projection::Orthographic(o) => o, _ => panic!("camera must use an orthographic projection"), } } fn keyboard_pan( keys: Res>, time: Res>, camera: Single<(&mut Transform, &mut Projection, &CameraController)>, ) { let (mut transform, mut projection, controller) = camera.into_inner(); let mut dir = Vec2::ZERO; for (key, d) in [ (KeyCode::KeyW, Vec2::Y), (KeyCode::ArrowUp, Vec2::Y), (KeyCode::KeyS, Vec2::NEG_Y), (KeyCode::ArrowDown, Vec2::NEG_Y), (KeyCode::KeyA, Vec2::NEG_X), (KeyCode::ArrowLeft, Vec2::NEG_X), (KeyCode::KeyD, Vec2::X), (KeyCode::ArrowRight, Vec2::X), ] { if keys.pressed(key) { dir += d; } } if dir == Vec2::ZERO { return; } let fast = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); let speed = controller.pan_speed * if fast { controller.fast_multiplier } else { 1.0 }; let scale = ortho(&mut projection).scale; transform.translation += (dir.normalize() * speed * scale * time.delta_secs()).extend(0.0); } fn drag_pan( buttons: Res>, window: Single<&Window, With>, camera: Single<(&mut Transform, &mut Projection), With>, mut last_cursor: Local>, ) { let cursor = window.cursor_position(); let previous = std::mem::replace(&mut *last_cursor, cursor); if !buttons.any_pressed(DRAG_BUTTONS) { return; } let (Some(cursor), Some(previous)) = (cursor, previous) else { return; }; let (mut transform, mut projection) = camera.into_inner(); let scale = ortho(&mut projection).scale; transform.translation += (screen_to_world_delta(cursor - previous) * -scale).extend(0.0); } fn scroll_zoom( scroll: Res, pixels_per_line: Res, window: Single<&Window, With>, camera: Single<( &Camera, &GlobalTransform, &mut Transform, &mut Projection, &CameraController, )>, ) { let lines = scroll.to_lines(&pixels_per_line).delta.y; if lines == 0.0 { return; } let (camera, global, mut transform, mut projection, controller) = camera.into_inner(); let projection = ortho(&mut projection); let old_scale = projection.scale; let new_scale = controller.zoomed(old_scale, lines); projection.scale = new_scale; let focus = window .cursor_position() .and_then(|cursor| camera.viewport_to_world_2d(global, cursor).ok()); if let Some(focus) = focus { let pos = zoom_towards( transform.translation.truncate(), focus, old_scale, new_scale, ); transform.translation = pos.extend(transform.translation.z); } } fn clamp_to_world(map: Res, mut camera: Single<&mut Transform, With>) { let clamped = clamp_to_rect(camera.translation.truncate(), Vec2::ZERO, map.world_size()); camera.translation = clamped.extend(camera.translation.z); } pub fn screen_to_world_delta(screen: Vec2) -> Vec2 { Vec2::new(screen.x, -screen.y) } pub fn zoom_towards(camera: Vec2, focus: Vec2, old_scale: f32, new_scale: f32) -> Vec2 { focus + (camera - focus) * (new_scale / old_scale) } pub fn clamp_to_rect(pos: Vec2, min: Vec2, max: Vec2) -> Vec2 { pos.clamp(min, max) } #[cfg(test)] mod tests { use super::*; fn world_point_at_offset(camera: Vec2, scale: f32, screen_offset: Vec2) -> Vec2 { camera + screen_offset * scale } #[test] fn zoom_buttons_zoom_in_steps_within_limits() { let controller = CameraController::default(); assert!(controller.zoomed(1.0, BUTTON_ZOOM_LINES) < 1.0); assert!(controller.zoomed(1.0, -BUTTON_ZOOM_LINES) > 1.0); assert_eq!( controller.zoomed(controller.min_scale, 10.0), controller.min_scale ); assert_eq!( controller.zoomed(controller.max_scale, -10.0), controller.max_scale ); } #[test] fn zooming_keeps_the_point_under_the_cursor_fixed() { let camera = Vec2::new(100.0, 50.0); let cursor_offset = Vec2::new(200.0, -120.0); let focus = world_point_at_offset(camera, 1.0, cursor_offset); let new_camera = zoom_towards(camera, focus, 1.0, 0.5); assert!(world_point_at_offset(new_camera, 0.5, cursor_offset).abs_diff_eq(focus, 1e-4)); } #[test] fn zooming_on_camera_center_does_not_move_camera() { let camera = Vec2::new(10.0, 20.0); assert_eq!(zoom_towards(camera, camera, 1.0, 3.0), camera); } #[test] fn dragging_moves_world_opposite_to_screen_y_because_screen_y_points_down() { assert_eq!( screen_to_world_delta(Vec2::new(3.0, 4.0)), Vec2::new(3.0, -4.0) ); } #[test] fn camera_center_cannot_leave_the_world_so_the_map_stays_on_screen() { let max = Vec2::new(100.0, 50.0); assert_eq!( clamp_to_rect(Vec2::new(-5.0, 60.0), Vec2::ZERO, max), Vec2::new(0.0, 50.0) ); assert_eq!( clamp_to_rect(Vec2::new(5.0, 6.0), Vec2::ZERO, max), Vec2::new(5.0, 6.0) ); } }