use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow}; use crate::{ actions::Action, animals::Animal, building::Blueprints, camera::CameraController, colonist::{Colonist, Path}, food::Hunger, items::{Carrying, Item, ItemKind, stock}, jobs::{COOK_WORK, ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind}, light::daylight, needs::Rest, sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day}, structures::{Fuel, Structure, StructureKind}, ui::{HideableUi, PointerOverUi}, world::{TILE_SIZE, TilePos, WorldMap}, worldgen::{WorldGenerated, WorldSeed}, zones::{ZoneKind, 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(fraction) = job.0.and_then(|j| progress_fraction(j.task)) && fraction > 0.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 * fraction; 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::Work { site, .. } | Task::ExpandStockpile { site, .. } | Task::Build { site, .. }, .. }) = job.0 { gizmos.rect_2d( WorldMap::tile_center(site), 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, &Rest, &Hunger, )>, items: Query<(&Item, &TilePos)>, structures: Query<(&Structure, &TilePos, Option<&Fuel>)>, animals: Query<(&Animal, &TilePos)>, blueprints: Res, 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, rest, hunger)| { describe_colonist(&ColonistView { name: &colonist.name, tile: tile.0, job: job.0, steps_left: path.0.len(), carrying: carrying.0, rest: rest.0, fed: hunger.0, }) }) .unwrap_or_else(|_| "-".into()), }; let clock = format!( "{} ({})", format_clock(*tick), time_of_day(hour_of_day(*tick)) ); let speed = describe_speed(*speed, time.is_paused()); let carried: Vec = colonists.iter().filter_map(|c| c.4.0).collect(); let stock_line = |food: bool| { ItemKind::ALL .iter() .filter(|kind| kind.is_food() == food) .map(|&kind| { let have = stock( items.iter().map(|(item, _)| item).chain(carried.iter()), kind, ); format!("{kind:?} {have}/{}", goals.goal(kind)) }) .collect::>() .join(" ") }; let materials = format!("{}\n{}", stock_line(false), stock_line(true)); let hovered_zone = match cursor.tile { Some(tile) if zones.is_stockpile(tile) => ", Stockpile", Some(tile) if zones.is_farm(tile) => ", Farm", _ => "", }; let hovered_items: String = items .iter() .filter(|(_, pos)| Some(pos.0) == cursor.tile) .map(|(item, _)| format!(", {:?} x{}", item.kind, item.count)) .collect(); let hovered_structure: String = structures .iter() .filter(|(_, pos, _)| Some(pos.0) == cursor.tile) .map(|(structure, _, fuel)| describe_structure(structure.kind, fuel.map(|f| f.0))) .chain( cursor .tile .and_then(|t| blueprints.get(t)) .map(|kind| format!(", planned {}", kind.name())), ) .chain( animals .iter() .filter(|(_, pos)| Some(pos.0) == cursor.tile) .map(|(animal, _)| format!(", {}", animal.kind.name())), ) .collect(); let tool_hint = match tool.mode { Some(ZoneKind::Stockpile) => "\nSTOCKPILE TOOL: drag to add, shift-drag to remove", Some(ZoneKind::Farm) => "\nFARM TOOL: drag to add, shift-drag to remove", None => "", }; let info = format!( "{clock} {speed}\nSeed: {}\n{materials}{tool_hint}\nHover: {}{hovered_zone}{hovered_items}{hovered_structure}\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 { 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 roof = if map.is_roofed(p) { ", Roofed" } else { "" }; let plant = map .plant(p) .map(|plant| match plant.ripening_ticks() { None => format!(", {plant:?}"), Some(_) if plant.is_ripe(map.growth(p)) => format!(", {plant:?} (ripe)"), Some(_) => format!(", {plant:?} ({:.0}% grown)", map.growth(p) * 100.0), }) .unwrap_or_default(); let height = map.height(p).unwrap_or(0.0); let wear = match map.wear(p) { w if w >= 1.0 => format!(", worn {w:.0}"), _ => String::new(), }; format!( "({}, {}) {terrain:?}{plant}{roof}, {height:.0} m{wear}", t.x, t.y ) } pub fn describe_speed(speed: SimSpeed, paused: bool) -> String { if paused { "PAUSED".into() } else { format!("{}x", speed.multiplier()) } } pub fn time_of_day(hour: f32) -> &'static str { match daylight(hour) { 1.0 => "day", 0.0 => "night", _ if hour < 12.0 => "dawn", _ => "dusk", } } pub fn describe_structure(kind: StructureKind, fuel: Option) -> String { match fuel { Some(fuel) => { let hours = fuel as f32 / TICKS_PER_HOUR as f32; format!(", {:?} ({hours:.1}h of fuel)", kind) } None => format!(", {kind:?}"), } } pub fn progress_fraction(task: Task) -> Option { match task { Task::Work { kind, progress, .. } => Some(progress as f32 / kind.work() as f32), Task::ExpandStockpile { progress, .. } => Some(progress as f32 / EXPAND_WORK as f32), Task::Build { kind, progress, .. } => Some(progress as f32 / kind.work() as f32), Task::Hunt { kind, progress, .. } => Some(progress as f32 / kind.hunt_work() as f32), Task::Cook { progress, .. } => Some(progress as f32 / COOK_WORK as f32), _ => None, } } fn percent(task: Task) -> u32 { (progress_fraction(task).unwrap_or(0.0) * 100.0).round() as u32 } pub struct ColonistView<'a> { pub name: &'a str, pub tile: UVec2, pub job: Option, pub steps_left: usize, pub carrying: Option, pub rest: f32, pub fed: f32, } pub fn describe_colonist(view: &ColonistView) -> String { let (tile, job, carrying) = (view.tile, view.job, view.carrying); let activity = match (job.map(|j| j.task), view.steps_left) { (None, 0) => "idle".to_string(), (None, _) => "wandering".to_string(), (Some(Task::MoveTo(_)), n) => format!("moving, {n} steps left"), ( Some(Task::Work { kind: WorkKind::Chop, .. }), n, ) if n > 0 => "walking to a tree".to_string(), ( Some(Task::Work { kind: WorkKind::Mine, .. }), n, ) if n > 0 => "walking to rock".to_string(), ( Some(Task::Work { kind: WorkKind::Harvest | WorkKind::Sow, .. }), n, ) if n > 0 => "walking to a plant".to_string(), (Some(task @ Task::Work { kind, .. }), _) => { let verb = match kind { WorkKind::Chop => "chopping a tree", WorkKind::Mine => "mining", WorkKind::Harvest => "harvesting", WorkKind::Sow => "sowing", }; format!("{verb}, {}%", percent(task)) } (Some(Task::ExpandStockpile { .. }), n) if n > 0 => { "going to expand the stockpile".to_string() } (Some(task @ Task::ExpandStockpile { .. }), _) => { format!("expanding the stockpile, {}%", percent(task)) } ( Some(Task::Haul { pickup: Some(_), .. }), _, ) => "fetching items to haul".to_string(), (Some(Task::Haul { pickup: None, .. }), _) => "hauling to the stockpile".to_string(), ( Some(Task::Build { kind, pickup: Some(_), .. }), _, ) => format!("fetching {:?} for a {}", kind.cost().0, kind.name()).to_lowercase(), (Some(Task::Build { kind, .. }), n) if n > 0 => { format!("carrying {:?} to build a {}", kind.cost().0, kind.name()).to_lowercase() } (Some(Task::Sleep { .. }), n) if n > 0 => "going to sleep".to_string(), (Some(Task::Sleep { .. }), _) => "sleeping".to_string(), ( Some(Task::Eat { pickup: Some(_), .. }), _, ) => "fetching food".to_string(), (Some(Task::Eat { .. }), _) => "eating".to_string(), (Some(task @ Task::Hunt { kind, progress, .. }), _) if progress > 0 => { format!("hunting a {}, {}%", kind.name(), percent(task)) } (Some(Task::Hunt { kind, .. }), _) => format!("stalking a {}", kind.name()), ( Some(Task::Cook { pickup: Some(_), .. }), _, ) => "fetching ingredients".to_string(), (Some(Task::Cook { .. }), n) if n > 0 => "carrying ingredients to the fire".to_string(), (Some(task @ Task::Cook { .. }), _) => format!("cooking a meal, {}%", percent(task)), (Some(task @ Task::Build { kind, .. }), _) => { format!("building a {}, {}%", kind.name(), percent(task)) } }; 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}, rest {:.0}%, food {:.0}%", view.name, tile.x, tile.y, view.rest * 100.0, view.fed * 100.0 ) } #[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 time_of_day_labels() { assert_eq!(time_of_day(12.0), "day"); assert_eq!(time_of_day(6.0), "dawn"); assert_eq!(time_of_day(19.0), "dusk"); assert_eq!(time_of_day(1.0), "night"); } #[test] fn describe_speed_shows_multiplier_or_paused() { assert_eq!(describe_speed(SimSpeed::Fastest, false), "6x"); assert_eq!(describe_speed(SimSpeed::Fast, true), "PAUSED"); } fn view(job: Option, steps_left: usize, carrying: Option) -> ColonistView<'static> { ColonistView { name: "Ada", tile: UVec2::new(1, 2), job, steps_left, carrying, rest: 1.0, fed: 0.5, } } #[test] fn describe_colonist_shows_activity_and_needs() { assert_eq!( describe_colonist(&view(None, 0, None)), "Ada at (1, 2), idle, rest 100%, food 50%" ); let ordered_move = Job { task: Task::MoveTo(UVec2::ZERO), ordered: true, }; assert_eq!( describe_colonist(&view(Some(ordered_move), 3, None)), "Ada at (1, 2), moving, 3 steps left (ordered), rest 100%, food 50%" ); let chop = Job { task: Task::Work { site: UVec2::ZERO, kind: WorkKind::Chop, progress: WorkKind::Chop.work() / 2, }, ordered: false, }; assert_eq!( describe_colonist(&view(Some(chop), 0, None)), "Ada at (1, 2), chopping a tree, 50%, rest 100%, food 50%" ); let eat = Job { task: Task::Eat { pickup: None, progress: 0, }, ordered: false, }; let meal = Item { kind: ItemKind::Meal, count: 1, }; assert_eq!( describe_colonist(&view(Some(eat), 0, Some(meal))), "Ada at (1, 2), eating, carrying 1 Meal, rest 100%, food 50%" ); } }