use bevy::{ picking::{ hover::{HoverMap, Hovered}, pointer::PointerId, }, prelude::*, ui::Pressed, ui_widgets::{Activate, ActivateOnPress, Button}, }; use crate::{ actions::Action, building::BuildTool, designations::DesignateTool, sim::SimSpeed, zones::{ZoneKind, ZoneTool}, }; pub struct HudPlugin; impl Plugin for HudPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .add_systems(Startup, spawn_toolbar) .add_systems( Update, ( detect_pointer_over_ui, toggle_ui, apply_ui_visibility.run_if(resource_changed::), style_buttons, ), ) .add_observer(on_button_activated); } } const TOOLBAR: [Action; 16] = [ Action::TogglePause, Action::SetSpeed(SimSpeed::Normal), Action::SetSpeed(SimSpeed::Fast), Action::SetSpeed(SimSpeed::Fastest), Action::SetSpeed(SimSpeed::Max), Action::ToggleStockpileTool, Action::ToggleFarmTool, Action::CycleBuildTool, Action::ToggleDesignateTool, Action::Cancel, Action::NewWorld, Action::Save, Action::Load, Action::ZoomIn, Action::ZoomOut, Action::ToggleUi, ]; pub const BUTTON_COLOR: Color = Color::srgba(0.12, 0.12, 0.14, 0.9); pub const BUTTON_HOVER_COLOR: Color = Color::srgba(0.22, 0.22, 0.26, 0.95); pub const BUTTON_ACTIVE_COLOR: Color = Color::srgba(0.2, 0.45, 0.75, 0.95); const BUTTON_PRESSED_COLOR: Color = Color::srgba(0.3, 0.6, 0.9, 1.0); const KEY_HINT_COLOR: Color = Color::srgb(0.65, 0.65, 0.7); #[derive(Resource, Default, Debug, Clone, Copy, PartialEq)] pub struct PointerOverUi(pub bool); #[derive(Resource, Debug, Clone, Copy, PartialEq)] pub struct UiVisible(pub bool); impl Default for UiVisible { fn default() -> Self { Self(true) } } #[derive(Component)] pub struct HideableUi; #[derive(Component)] struct ShowUiButton; #[derive(Component, Debug, Clone, Copy)] pub struct ActionButton(pub Action); fn action_button(action: Action, label: &str) -> impl Bundle { ( Button, ActivateOnPress, Hovered::default(), ActionButton(action), Node { flex_direction: FlexDirection::Column, align_items: AlignItems::Center, min_width: px(36), padding: UiRect::axes(px(5), px(3)), border_radius: BorderRadius::all(px(4)), ..default() }, BackgroundColor(BUTTON_COLOR), children![ (Text::new(label), TextFont::from_font_size(12.0)), ( Text::new(action.key_label()), TextFont::from_font_size(10.0), TextColor(KEY_HINT_COLOR), ), ], ) } fn spawn_toolbar(mut commands: Commands) { let toolbar = commands .spawn(( HideableUi, Pickable::IGNORE, Node { position_type: PositionType::Absolute, bottom: px(8), width: percent(100), justify_content: JustifyContent::Center, ..default() }, )) .id(); let row = commands .spawn(( Node { column_gap: px(3), row_gap: px(3), flex_wrap: FlexWrap::Wrap, justify_content: JustifyContent::Center, max_width: percent(100), padding: UiRect::all(px(4)), border_radius: BorderRadius::all(px(6)), ..default() }, BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.4)), ChildOf(toolbar), )) .id(); for action in TOOLBAR { commands.spawn((action_button(action, action.label()), ChildOf(row))); } commands .spawn(( ShowUiButton, action_button(Action::ToggleUi, "Show UI"), Visibility::Hidden, )) .entry::() .and_modify(|mut node| { node.position_type = PositionType::Absolute; node.bottom = px(8); node.right = px(8); }); } fn on_button_activated( activate: On, buttons: Query<&ActionButton>, mut actions: MessageWriter, ) { if let Ok(button) = buttons.get(activate.entity) { actions.write(button.0); } } fn detect_pointer_over_ui( hover_map: Res, nodes: Query<(), With>, mut over_ui: ResMut, ) { let over = hover_map .get(&PointerId::Mouse) .is_some_and(|hits| hits.keys().any(|&entity| nodes.contains(entity))); over_ui.set_if_neq(PointerOverUi(over)); } fn toggle_ui(mut actions: MessageReader, mut visible: ResMut) { for action in actions.read() { if *action == Action::ToggleUi { visible.0 = !visible.0; } } } fn apply_ui_visibility( visible: Res, mut hideable: Query<&mut Visibility, (With, Without)>, mut show_button: Query<&mut Visibility, With>, ) { let (shown, hidden) = (Visibility::Inherited, Visibility::Hidden); for mut visibility in &mut hideable { *visibility = if visible.0 { shown } else { hidden }; } for mut visibility in &mut show_button { *visibility = if visible.0 { hidden } else { shown }; } } pub fn is_active( action: Action, paused: bool, speed: SimSpeed, tool: Option, building: bool, designating: bool, ) -> bool { match action { Action::CycleBuildTool => building, Action::ToggleDesignateTool => designating, Action::TogglePause => paused, Action::SetSpeed(s) => !paused && s == speed, Action::ToggleStockpileTool => tool == Some(ZoneKind::Stockpile), Action::ToggleFarmTool => tool == Some(ZoneKind::Farm), _ => false, } } fn style_buttons( time: Res>, speed: Res, tool: Res, build_tool: Res, designate_tool: Res, mut buttons: Query<(&ActionButton, &Hovered, Has, &mut BackgroundColor)>, ) { for (button, hovered, pressed, mut color) in &mut buttons { let active = is_active( button.0, time.is_paused(), *speed, tool.mode, build_tool.active(), designate_tool.active, ); let new = match (pressed && hovered.get(), active, hovered.get()) { (true, ..) => BUTTON_PRESSED_COLOR, (_, true, _) => BUTTON_ACTIVE_COLOR, (_, _, true) => BUTTON_HOVER_COLOR, _ => BUTTON_COLOR, }; color.set_if_neq(BackgroundColor(new)); } } #[cfg(test)] mod tests { use super::*; use crate::actions::KEYBINDS; #[test] fn toolbar_spawns_one_button_per_action_plus_restore_button() { let mut app = App::new(); app.add_systems(Startup, spawn_toolbar); app.update(); let world = app.world_mut(); let buttons = world.query::<&ActionButton>().iter(world).count(); assert_eq!(buttons, TOOLBAR.len() + 1); } #[test] fn every_keybind_has_a_toolbar_button() { for (key, action) in KEYBINDS { assert!(TOOLBAR.contains(&action), "{key:?} has no button"); } } #[test] fn active_buttons_reflect_simulation_state() { let normal = SimSpeed::Normal; assert!(is_active( Action::TogglePause, true, normal, None, false, false )); let fast = Action::SetSpeed(SimSpeed::Fast); assert!(is_active(fast, false, SimSpeed::Fast, None, false, false)); assert!(!is_active(fast, true, SimSpeed::Fast, None, false, false)); let farm = Some(ZoneKind::Farm); assert!(is_active( Action::ToggleFarmTool, false, normal, farm, false, false )); assert!(!is_active( Action::ToggleStockpileTool, false, normal, farm, false, false )); } #[test] fn toggling_hides_the_ui_and_shows_the_restore_button() { let mut app = App::new(); app.init_resource::() .add_message::() .add_systems( Update, ( toggle_ui, apply_ui_visibility.run_if(resource_changed::), ) .chain(), ); let panel = app .world_mut() .spawn((HideableUi, Visibility::Inherited)) .id(); let restore = app .world_mut() .spawn((ShowUiButton, Visibility::Hidden)) .id(); app.update(); app.world_mut().write_message(Action::ToggleUi); app.update(); assert_eq!( app.world().get::(panel), Some(&Visibility::Hidden) ); assert_eq!( app.world().get::(restore), Some(&Visibility::Inherited) ); } }