use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow}; use crate::{ actions::Action, camera::CameraController, colonist::{Colonist, Path}, items::{Carrying, Item, ItemKind, stock}, jobs::{CHOP_TICKS, ColonyGoals, CurrentJob, Job, Order, Task}, sim::{SimSpeed, SimTick, format_clock}, ui::{HideableUi, PointerOverUi}, world::{TILE_SIZE, TilePos, WorldMap}, worldgen::{WorldGenerated, WorldSeed}, zones::{ZoneTool, Zones}, }; 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.run_if(|tool: Res| !tool.active), 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 PROGRESS_COLOR: Color = Color::srgb(0.4, 0.9, 0.4); const PROGRESS_BACK_COLOR: Color = Color::srgba(0.0, 0.0, 0.0, 0.6); 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; pub fn update_cursor( map: Res, window: Single<&Window, With>, camera: Single<(&Camera, &GlobalTransform), With>, over_ui: Res, mut cursor: ResMut, ) { let (camera, global) = *camera; let screen = window.cursor_position(); let world = screen .filter(|_| !over_ui.0) .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( mut actions: MessageReader, buttons: Res>, over_ui: Res, cursor: Res, colonists: Query<(Entity, &Transform), With>, mut selection: ResMut, ) { if actions.read().any(|a| *a == Action::Cancel) { selection.set_if_neq(Selection::None); } else if buttons.just_pressed(MouseButton::Left) && !over_ui.0 { 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, over_ui: Res, mut orders: MessageWriter, mut pressed_at: Local>, ) { if buttons.just_pressed(MouseButton::Right) { *pressed_at = cursor.screen.filter(|_| !over_ui.0); } 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(tile)) = (is_click(press, release), *selection, cursor.tile) { orders.write(Order { colonist, tile }); } } 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<(Entity, &Transform, &Path, &CurrentJob), With>, ) { for (_, transform, _, job) in &colonists { if let Some(Job { task: Task::Chop { progress, .. }, .. }) = job.0 && progress > 0 { let left = transform.translation.truncate() + Vec2::new(-TILE_SIZE * 0.4, TILE_SIZE * 0.6); let full = TILE_SIZE * 0.8; let done = full * progress as f32 / CHOP_TICKS as f32; gizmos.line_2d(left, left + Vec2::X * full, PROGRESS_BACK_COLOR); gizmos.line_2d(left, left + Vec2::X * done, PROGRESS_COLOR); } } 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, job)) = colonists.get(entity) else { return; }; if let Some(Job { task: Task::Chop { tree, .. }, .. }) = job.0 { gizmos.rect_2d( WorldMap::tile_center(tree), Vec2::splat(TILE_SIZE), PATH_COLOR, ); } 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, HideableUi, 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, &CurrentJob, &Carrying)>, items: Query<(&Item, &TilePos)>, goals: Res, zones: Res, tool: Res, 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, job, carrying)| { describe_colonist(colonist, tile.0, job.0, path.0.len(), carrying.0) }) .unwrap_or_else(|_| "-".into()), }; let clock = format_clock(*tick); let speed = describe_speed(*speed, time.is_paused()); let carried: Vec = colonists.iter().filter_map(|c| c.4.0).collect(); let wood = stock( items.iter().map(|(item, _)| item).chain(carried.iter()), ItemKind::Wood, ); let stored = stock( items .iter() .filter(|(_, pos)| zones.is_stockpile(pos.0)) .map(|(item, _)| item), ItemKind::Wood, ); let hovered_zone = match cursor.tile { Some(tile) if zones.is_stockpile(tile) => ", Stockpile", _ => "", }; let hovered_items: String = items .iter() .filter(|(_, pos)| Some(pos.0) == cursor.tile) .map(|(item, _)| format!(", {:?} x{}", item.kind, item.count)) .collect(); let tool_hint = if tool.active { "\nSTOCKPILE TOOL: drag to add, shift-drag to remove" } else { "" }; let info = format!( "{clock} {speed}\nSeed: {}\nWood: {wood} / {} ({stored} stored){tool_hint}\nHover: {}{hovered_zone}{hovered_items}\nSelected: {selected}", seed.0, goals.wood, describe_tile(&map, cursor.tile) ); if text.0 != info { text.0 = info; } } pub fn describe_tile(map: &WorldMap, tile: Option) -> String { let Some((t, terrain)) = tile.and_then(|t| map.get(t.as_ivec2()).map(|terrain| (t, terrain))) else { return "-".into(); }; let p = t.as_ivec2(); let plant = map .plant(p) .map(|plant| format!(", {plant:?}")) .unwrap_or_default(); let height = map.height(p).unwrap_or(0.0); format!("({}, {}) {terrain:?}{plant}, {height:.0} m", t.x, t.y) } 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, job: Option, steps_left: usize, carrying: Option, ) -> String { let activity = match (job.map(|j| j.task), steps_left) { (None, 0) => "idle".to_string(), (None, _) => "wandering".to_string(), (Some(Task::MoveTo(_)), n) => format!("moving, {n} steps left"), (Some(Task::Chop { .. }), n) if n > 0 => "walking to a tree".to_string(), (Some(Task::Chop { progress, .. }), _) => { format!("chopping a tree, {}%", progress * 100 / CHOP_TICKS) } ( Some(Task::Haul { pickup: Some(_), .. }), _, ) => "fetching items to haul".to_string(), (Some(Task::Haul { pickup: None, .. }), _) => "hauling to the stockpile".to_string(), }; let load = carrying .map(|item| format!(", carrying {} {:?}", item.count, item.kind)) .unwrap_or_default(); let ordered = if job.is_some_and(|j| j.ordered) { " (ordered)" } else { "" }; format!( "{} at ({}, {}), {activity}{ordered}{load}", 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_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.tile)) .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 cancel_clears_the_selection() { let mut app = selection_app( cursor_on_tile(UVec2::ONE), Selection::Tile(UVec2::new(3, 4)), ); app.world_mut().write_message(Action::Cancel); app.update(); assert_eq!(selection(&app), Selection::None); } #[test] fn clicks_on_the_ui_do_not_reach_the_map() { let colonist = Entity::from_raw_u32(7).unwrap(); let mut app = selection_app( cursor_on_tile(UVec2::new(5, 6)), Selection::Colonist(colonist), ); app.insert_resource(PointerOverUi(true)); press(&mut app, MouseButton::Left); assert_eq!(selection(&app), Selection::Colonist(colonist)); press(&mut app, MouseButton::Right); release(&mut app, MouseButton::Right); assert!(sent_orders(&mut app).is_empty()); } #[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_terrain_plant_and_height() { let mut map = WorldMap::filled(UVec2::new(4, 4), Terrain::Sand); map.set_height(UVec2::new(1, 2), 12.4); assert_eq!( describe_tile(&map, Some(UVec2::new(1, 2))), "(1, 2) Sand, 12 m" ); map.set_plant(UVec2::new(1, 2), Some(crate::world::Plant::Tree)); assert_eq!( describe_tile(&map, Some(UVec2::new(1, 2))), "(1, 2) Sand, Tree, 12 m" ); 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() }; let tile = UVec2::new(1, 2); assert_eq!( describe_colonist(&ada, tile, None, 0, None), "Ada at (1, 2), idle" ); let ordered_move = Job { task: Task::MoveTo(UVec2::ZERO), ordered: true, }; assert_eq!( describe_colonist(&ada, tile, Some(ordered_move), 3, None), "Ada at (1, 2), moving, 3 steps left (ordered)" ); let chop = Job { task: Task::Chop { tree: UVec2::ZERO, progress: CHOP_TICKS / 2, }, ordered: false, }; assert_eq!( describe_colonist(&ada, tile, Some(chop), 0, None), "Ada at (1, 2), chopping a tree, 50%" ); let haul = Job { task: Task::Haul { pickup: None, to: UVec2::ZERO, }, ordered: false, }; let wood = Item { kind: ItemKind::Wood, count: 10, }; assert_eq!( describe_colonist(&ada, tile, Some(haul), 4, Some(wood)), "Ada at (1, 2), hauling to the stockpile, carrying 10 Wood" ); } }