use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow}; use crate::{ camera::CameraController, colonist::{Colonist, MoveOrder, Path, TilePos}, sim::{SimSpeed, SimTick, format_clock}, world::{TILE_SIZE, WorldMap}, worldgen::{WorldGenerated, WorldSeed}, }; pub struct SelectionPlugin; impl Plugin for SelectionPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .add_systems(Startup, spawn_tile_info) .add_systems(Update, clear_selection.run_if(on_message::)) .add_systems( PostUpdate, ( update_cursor, update_selection, issue_move_orders, draw_highlights, update_tile_info, ) .chain() .after(TransformSystems::Propagate), ); } } #[derive(Resource, Default, Debug, PartialEq)] pub struct Cursor { pub screen: Option, pub world: Option, pub tile: Option, } #[derive(Resource, Default, Debug, PartialEq, Clone, Copy)] pub enum Selection { #[default] None, Tile(UVec2), Colonist(Entity), } #[derive(Component)] struct TileInfoText; const HOVER_COLOR: Color = Color::srgba(1.0, 1.0, 1.0, 0.6); const SELECT_COLOR: Color = Color::srgb(1.0, 0.85, 0.2); const PATH_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.7); const PICK_RADIUS: f32 = TILE_SIZE * 0.6; const CLICK_SLOP_PX: f32 = 4.0; fn update_cursor( map: Res, window: Single<&Window, With>, camera: Single<(&Camera, &GlobalTransform), With>, mut cursor: ResMut, ) { let (camera, global) = *camera; let screen = window.cursor_position(); let world = screen.and_then(|s| camera.viewport_to_world_2d(global, s).ok()); let tile = world.and_then(|w| map.tile_at(w)); cursor.set_if_neq(Cursor { screen, world, tile, }); } fn clear_selection(mut selection: ResMut) { *selection = Selection::None; } fn update_selection( buttons: Res>, keys: Res>, cursor: Res, colonists: Query<(Entity, &Transform), With>, mut selection: ResMut, ) { if keys.just_pressed(KeyCode::Escape) { selection.set_if_neq(Selection::None); } else if buttons.just_pressed(MouseButton::Left) { let picked = cursor.world.and_then(|world| { pick_nearest( world, colonists.iter().map(|(e, t)| (e, t.translation.truncate())), ) }); let new = match (picked, cursor.tile) { (Some(entity), _) => Selection::Colonist(entity), (None, Some(tile)) => Selection::Tile(tile), (None, None) => Selection::None, }; selection.set_if_neq(new); } } pub fn pick_nearest( point: Vec2, candidates: impl Iterator, ) -> Option { candidates .map(|(e, pos)| (e, pos.distance(point))) .filter(|&(_, d)| d <= PICK_RADIUS) .min_by(|a, b| a.1.total_cmp(&b.1)) .map(|(e, _)| e) } fn issue_move_orders( buttons: Res>, cursor: Res, selection: Res, mut orders: MessageWriter, mut pressed_at: Local>, ) { if buttons.just_pressed(MouseButton::Right) { *pressed_at = cursor.screen; } if !buttons.just_released(MouseButton::Right) { return; } let (Some(press), Some(release)) = (pressed_at.take(), cursor.screen) else { return; }; if let (true, Selection::Colonist(colonist), Some(goal)) = (is_click(press, release), *selection, cursor.tile) { orders.write(MoveOrder { colonist, goal }); } } pub fn is_click(press: Vec2, release: Vec2) -> bool { press.distance(release) <= CLICK_SLOP_PX } fn draw_highlights( mut gizmos: Gizmos, cursor: Res, selection: Res, colonists: Query<(&Transform, &Path), With>, ) { if let Some(tile) = cursor.tile { gizmos.rect_2d( WorldMap::tile_center(tile), Vec2::splat(TILE_SIZE), HOVER_COLOR, ); } match *selection { Selection::None => {} Selection::Tile(tile) => { gizmos.rect_2d( WorldMap::tile_center(tile), Vec2::splat(TILE_SIZE), SELECT_COLOR, ); } Selection::Colonist(entity) => { let Ok((transform, path)) = colonists.get(entity) else { return; }; let pos = transform.translation.truncate(); gizmos.circle_2d(pos, TILE_SIZE * 0.5, SELECT_COLOR); let points = path.0.iter().map(|&t| WorldMap::tile_center(t)); gizmos.linestrip_2d(std::iter::once(pos).chain(points), PATH_COLOR); } } } fn spawn_tile_info(mut commands: Commands) { commands.spawn(( TileInfoText, Text::default(), TextFont::from_font_size(14.0), Node { position_type: PositionType::Absolute, top: px(8), left: px(8), padding: UiRect::all(px(6)), ..default() }, BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.5)), )); } fn update_tile_info( map: Res, cursor: Res, selection: Res, colonists: Query<(&Colonist, &TilePos, &Path)>, time: Res>, tick: Res, speed: Res, seed: Res, mut text: Single<&mut Text, With>, ) { let selected = match *selection { Selection::None => "-".to_string(), Selection::Tile(tile) => describe_tile(&map, Some(tile)), Selection::Colonist(entity) => colonists .get(entity) .map(|(colonist, tile, path)| describe_colonist(colonist, tile.0, path.0.len())) .unwrap_or_else(|_| "-".into()), }; let clock = format_clock(*tick); let speed = describe_speed(*speed, time.is_paused()); let info = format!( "{clock} {speed}\nSeed: {} (R: new world)\nHover: {}\nSelected: {selected}", seed.0, describe_tile(&map, cursor.tile) ); if text.0 != info { text.0 = info; } } pub fn describe_tile(map: &WorldMap, tile: Option) -> String { match tile.and_then(|t| map.get(t.as_ivec2()).map(|terrain| (t, terrain))) { Some((t, terrain)) => format!("({}, {}) {terrain:?}", t.x, t.y), None => "-".into(), } } pub fn describe_speed(speed: SimSpeed, paused: bool) -> String { if paused { "PAUSED".into() } else { format!("{}x", speed.multiplier()) } } pub fn describe_colonist(colonist: &Colonist, tile: UVec2, steps_left: usize) -> String { let activity = match steps_left { 0 => "idle".to_string(), n => format!("moving, {n} steps left"), }; format!("{} at ({}, {}), {activity}", colonist.name, tile.x, tile.y) } #[cfg(test)] mod tests { use super::*; use crate::world::Terrain; fn selection_app(cursor: Cursor, selection: Selection) -> App { let mut app = App::new(); app.init_resource::>() .init_resource::>() .insert_resource(cursor) .insert_resource(selection) .add_message::() .add_systems(Update, (update_selection, issue_move_orders)); app } fn cursor_on_tile(tile: UVec2) -> Cursor { Cursor { screen: Some(Vec2::new(50.0, 50.0)), world: Some(WorldMap::tile_center(tile)), tile: Some(tile), } } fn press(app: &mut App, button: MouseButton) { app.world_mut() .resource_mut::>() .press(button); app.update(); } fn release(app: &mut App, button: MouseButton) { let mut input = app.world_mut().resource_mut::>(); input.clear(); input.release(button); app.update(); } fn selection(app: &App) -> Selection { *app.world().resource::() } fn sent_orders(app: &mut App) -> Vec<(Entity, UVec2)> { app.world_mut() .resource_mut::>() .drain() .map(|o| (o.colonist, o.goal)) .collect() } #[test] fn clicking_selects_the_hovered_tile() { let mut app = selection_app(cursor_on_tile(UVec2::new(3, 4)), Selection::None); press(&mut app, MouseButton::Left); assert_eq!(selection(&app), Selection::Tile(UVec2::new(3, 4))); } #[test] fn clicking_a_colonist_selects_it_instead_of_its_tile() { let tile = UVec2::new(3, 4); let mut app = selection_app(cursor_on_tile(tile), Selection::None); let colonist = app .world_mut() .spawn(( Colonist { name: "Ada".into() }, Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)), )) .id(); press(&mut app, MouseButton::Left); assert_eq!(selection(&app), Selection::Colonist(colonist)); } #[test] fn clicking_outside_the_world_clears_the_selection() { let mut app = selection_app(Cursor::default(), Selection::Tile(UVec2::new(3, 4))); press(&mut app, MouseButton::Left); assert_eq!(selection(&app), Selection::None); } #[test] fn escape_clears_the_selection() { let mut app = selection_app( cursor_on_tile(UVec2::ONE), Selection::Tile(UVec2::new(3, 4)), ); app.world_mut() .resource_mut::>() .press(KeyCode::Escape); app.update(); assert_eq!(selection(&app), Selection::None); } #[test] fn right_click_orders_selected_colonist_to_hovered_tile() { let colonist = Entity::from_raw_u32(7).unwrap(); let mut app = selection_app( cursor_on_tile(UVec2::new(5, 6)), Selection::Colonist(colonist), ); press(&mut app, MouseButton::Right); release(&mut app, MouseButton::Right); assert_eq!(sent_orders(&mut app), vec![(colonist, UVec2::new(5, 6))]); } #[test] fn right_drag_does_not_order_because_it_pans_the_camera() { let colonist = Entity::from_raw_u32(7).unwrap(); let mut app = selection_app( cursor_on_tile(UVec2::new(5, 6)), Selection::Colonist(colonist), ); press(&mut app, MouseButton::Right); app.world_mut().resource_mut::().screen = Some(Vec2::new(200.0, 50.0)); release(&mut app, MouseButton::Right); assert!(sent_orders(&mut app).is_empty()); } #[test] fn pick_nearest_ignores_far_candidates() { let a = Entity::from_raw_u32(1).unwrap(); let b = Entity::from_raw_u32(2).unwrap(); let candidates = [(a, Vec2::new(5.0, 0.0)), (b, Vec2::new(2.0, 0.0))]; assert_eq!(pick_nearest(Vec2::ZERO, candidates.into_iter()), Some(b)); assert_eq!( pick_nearest(Vec2::new(100.0, 0.0), candidates.into_iter()), None ); } #[test] fn describe_tile_shows_coordinates_and_terrain() { let map = WorldMap::filled(UVec2::new(4, 4), Terrain::Sand); assert_eq!(describe_tile(&map, Some(UVec2::new(1, 2))), "(1, 2) Sand"); assert_eq!(describe_tile(&map, None), "-"); } #[test] fn describe_speed_shows_multiplier_or_paused() { assert_eq!(describe_speed(SimSpeed::Fastest, false), "6x"); assert_eq!(describe_speed(SimSpeed::Fast, true), "PAUSED"); } #[test] fn describe_colonist_shows_activity() { let ada = Colonist { name: "Ada".into() }; assert_eq!( describe_colonist(&ada, UVec2::new(1, 2), 0), "Ada at (1, 2), idle" ); assert_eq!( describe_colonist(&ada, UVec2::new(1, 2), 3), "Ada at (1, 2), moving, 3 steps left" ); } }