| 1 |
use bevy::{ |
| 2 |
picking::{ |
| 3 |
hover::{HoverMap, Hovered}, |
| 4 |
pointer::PointerId, |
| 5 |
}, |
| 6 |
prelude::*, |
| 7 |
ui::Pressed, |
| 8 |
ui_widgets::{Activate, ActivateOnPress, Button}, |
| 9 |
}; |
| 10 |
|
| 11 |
use crate::{ |
| 12 |
actions::Action, |
| 13 |
building::{BuildMode, BuildTool}, |
| 14 |
designations::DesignateTool, |
| 15 |
sim::SimSpeed, |
| 16 |
zones::{ZoneKind, ZoneTool}, |
| 17 |
}; |
| 18 |
|
| 19 |
pub struct HudPlugin; |
| 20 |
|
| 21 |
impl Plugin for HudPlugin { |
| 22 |
fn build(&self, app: &mut App) { |
| 23 |
app.init_resource::<PointerOverUi>() |
| 24 |
.init_resource::<UiVisible>() |
| 25 |
.add_systems(Startup, spawn_toolbar) |
| 26 |
.add_systems( |
| 27 |
Update, |
| 28 |
( |
| 29 |
detect_pointer_over_ui, |
| 30 |
toggle_ui, |
| 31 |
apply_ui_visibility.run_if(resource_changed::<UiVisible>), |
| 32 |
style_buttons, |
| 33 |
), |
| 34 |
) |
| 35 |
.add_observer(on_button_activated); |
| 36 |
} |
| 37 |
} |
| 38 |
|
| 39 |
const TOOLBAR: [Action; 17] = [ |
| 40 |
Action::TogglePause, |
| 41 |
Action::SetSpeed(SimSpeed::Normal), |
| 42 |
Action::SetSpeed(SimSpeed::Fast), |
| 43 |
Action::SetSpeed(SimSpeed::Fastest), |
| 44 |
Action::SetSpeed(SimSpeed::Max), |
| 45 |
Action::ToggleStockpileTool, |
| 46 |
Action::ToggleFarmTool, |
| 47 |
Action::CycleBuildTool, |
| 48 |
Action::ToggleDesignateTool, |
| 49 |
Action::ToggleDemolishTool, |
| 50 |
Action::Cancel, |
| 51 |
Action::NewWorld, |
| 52 |
Action::Save, |
| 53 |
Action::Load, |
| 54 |
Action::ZoomIn, |
| 55 |
Action::ZoomOut, |
| 56 |
Action::ToggleUi, |
| 57 |
]; |
| 58 |
|
| 59 |
pub const BUTTON_COLOR: Color = Color::srgba(0.12, 0.12, 0.14, 0.9); |
| 60 |
pub const BUTTON_HOVER_COLOR: Color = Color::srgba(0.22, 0.22, 0.26, 0.95); |
| 61 |
pub const BUTTON_ACTIVE_COLOR: Color = Color::srgba(0.2, 0.45, 0.75, 0.95); |
| 62 |
const BUTTON_PRESSED_COLOR: Color = Color::srgba(0.3, 0.6, 0.9, 1.0); |
| 63 |
const KEY_HINT_COLOR: Color = Color::srgb(0.65, 0.65, 0.7); |
| 64 |
|
| 65 |
#[derive(Resource, Default, Debug, Clone, Copy, PartialEq)] |
| 66 |
pub struct PointerOverUi(pub bool); |
| 67 |
|
| 68 |
#[derive(Resource, Debug, Clone, Copy, PartialEq)] |
| 69 |
pub struct UiVisible(pub bool); |
| 70 |
|
| 71 |
impl Default for UiVisible { |
| 72 |
fn default() -> Self { |
| 73 |
Self(true) |
| 74 |
} |
| 75 |
} |
| 76 |
|
| 77 |
#[derive(Component)] |
| 78 |
pub struct HideableUi; |
| 79 |
|
| 80 |
#[derive(Component)] |
| 81 |
struct ShowUiButton; |
| 82 |
|
| 83 |
#[derive(Component, Debug, Clone, Copy)] |
| 84 |
pub struct ActionButton(pub Action); |
| 85 |
|
| 86 |
fn action_button(action: Action, label: &str) -> impl Bundle { |
| 87 |
( |
| 88 |
Button, |
| 89 |
ActivateOnPress, |
| 90 |
Hovered::default(), |
| 91 |
ActionButton(action), |
| 92 |
Node { |
| 93 |
flex_direction: FlexDirection::Column, |
| 94 |
align_items: AlignItems::Center, |
| 95 |
min_width: px(36), |
| 96 |
padding: UiRect::axes(px(5), px(3)), |
| 97 |
border_radius: BorderRadius::all(px(4)), |
| 98 |
..default() |
| 99 |
}, |
| 100 |
BackgroundColor(BUTTON_COLOR), |
| 101 |
children![ |
| 102 |
(Text::new(label), TextFont::from_font_size(12.0)), |
| 103 |
( |
| 104 |
Text::new(action.key_label()), |
| 105 |
TextFont::from_font_size(10.0), |
| 106 |
TextColor(KEY_HINT_COLOR), |
| 107 |
), |
| 108 |
], |
| 109 |
) |
| 110 |
} |
| 111 |
|
| 112 |
fn spawn_toolbar(mut commands: Commands) { |
| 113 |
let toolbar = commands |
| 114 |
.spawn(( |
| 115 |
HideableUi, |
| 116 |
Pickable::IGNORE, |
| 117 |
Node { |
| 118 |
position_type: PositionType::Absolute, |
| 119 |
bottom: px(8), |
| 120 |
width: percent(100), |
| 121 |
justify_content: JustifyContent::Center, |
| 122 |
..default() |
| 123 |
}, |
| 124 |
)) |
| 125 |
.id(); |
| 126 |
let row = commands |
| 127 |
.spawn(( |
| 128 |
Node { |
| 129 |
column_gap: px(3), |
| 130 |
row_gap: px(3), |
| 131 |
flex_wrap: FlexWrap::Wrap, |
| 132 |
justify_content: JustifyContent::Center, |
| 133 |
max_width: percent(100), |
| 134 |
padding: UiRect::all(px(4)), |
| 135 |
border_radius: BorderRadius::all(px(6)), |
| 136 |
..default() |
| 137 |
}, |
| 138 |
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.4)), |
| 139 |
ChildOf(toolbar), |
| 140 |
)) |
| 141 |
.id(); |
| 142 |
for action in TOOLBAR { |
| 143 |
commands.spawn((action_button(action, action.label()), ChildOf(row))); |
| 144 |
} |
| 145 |
commands |
| 146 |
.spawn(( |
| 147 |
ShowUiButton, |
| 148 |
action_button(Action::ToggleUi, "Show UI"), |
| 149 |
Visibility::Hidden, |
| 150 |
)) |
| 151 |
.entry::<Node>() |
| 152 |
.and_modify(|mut node| { |
| 153 |
node.position_type = PositionType::Absolute; |
| 154 |
node.bottom = px(8); |
| 155 |
node.right = px(8); |
| 156 |
}); |
| 157 |
} |
| 158 |
|
| 159 |
fn on_button_activated( |
| 160 |
activate: On<Activate>, |
| 161 |
buttons: Query<&ActionButton>, |
| 162 |
mut actions: MessageWriter<Action>, |
| 163 |
) { |
| 164 |
if let Ok(button) = buttons.get(activate.entity) { |
| 165 |
actions.write(button.0); |
| 166 |
} |
| 167 |
} |
| 168 |
|
| 169 |
fn detect_pointer_over_ui( |
| 170 |
hover_map: Res<HoverMap>, |
| 171 |
nodes: Query<(), With<Node>>, |
| 172 |
mut over_ui: ResMut<PointerOverUi>, |
| 173 |
) { |
| 174 |
let over = hover_map |
| 175 |
.get(&PointerId::Mouse) |
| 176 |
.is_some_and(|hits| hits.keys().any(|&entity| nodes.contains(entity))); |
| 177 |
over_ui.set_if_neq(PointerOverUi(over)); |
| 178 |
} |
| 179 |
|
| 180 |
fn toggle_ui(mut actions: MessageReader<Action>, mut visible: ResMut<UiVisible>) { |
| 181 |
for action in actions.read() { |
| 182 |
if *action == Action::ToggleUi { |
| 183 |
visible.0 = !visible.0; |
| 184 |
} |
| 185 |
} |
| 186 |
} |
| 187 |
|
| 188 |
fn apply_ui_visibility( |
| 189 |
visible: Res<UiVisible>, |
| 190 |
mut hideable: Query<&mut Visibility, (With<HideableUi>, Without<ShowUiButton>)>, |
| 191 |
mut show_button: Query<&mut Visibility, With<ShowUiButton>>, |
| 192 |
) { |
| 193 |
let (shown, hidden) = (Visibility::Inherited, Visibility::Hidden); |
| 194 |
for mut visibility in &mut hideable { |
| 195 |
*visibility = if visible.0 { shown } else { hidden }; |
| 196 |
} |
| 197 |
for mut visibility in &mut show_button { |
| 198 |
*visibility = if visible.0 { hidden } else { shown }; |
| 199 |
} |
| 200 |
} |
| 201 |
|
| 202 |
pub fn is_active( |
| 203 |
action: Action, |
| 204 |
paused: bool, |
| 205 |
speed: SimSpeed, |
| 206 |
tool: Option<ZoneKind>, |
| 207 |
build_mode: Option<BuildMode>, |
| 208 |
designating: bool, |
| 209 |
) -> bool { |
| 210 |
let demolishing = build_mode == Some(BuildMode::Deconstruct); |
| 211 |
match action { |
| 212 |
Action::CycleBuildTool => build_mode.is_some() && !demolishing, |
| 213 |
Action::ToggleDemolishTool => demolishing, |
| 214 |
Action::ToggleDesignateTool => designating, |
| 215 |
Action::TogglePause => paused, |
| 216 |
Action::SetSpeed(s) => !paused && s == speed, |
| 217 |
Action::ToggleStockpileTool => tool == Some(ZoneKind::Stockpile), |
| 218 |
Action::ToggleFarmTool => tool == Some(ZoneKind::Farm), |
| 219 |
_ => false, |
| 220 |
} |
| 221 |
} |
| 222 |
|
| 223 |
fn style_buttons( |
| 224 |
time: Res<Time<Virtual>>, |
| 225 |
speed: Res<SimSpeed>, |
| 226 |
tool: Res<ZoneTool>, |
| 227 |
build_tool: Res<BuildTool>, |
| 228 |
designate_tool: Res<DesignateTool>, |
| 229 |
mut buttons: Query<(&ActionButton, &Hovered, Has<Pressed>, &mut BackgroundColor)>, |
| 230 |
) { |
| 231 |
for (button, hovered, pressed, mut color) in &mut buttons { |
| 232 |
let active = is_active( |
| 233 |
button.0, |
| 234 |
time.is_paused(), |
| 235 |
*speed, |
| 236 |
tool.mode, |
| 237 |
build_tool.mode, |
| 238 |
designate_tool.active, |
| 239 |
); |
| 240 |
let new = match (pressed && hovered.get(), active, hovered.get()) { |
| 241 |
(true, ..) => BUTTON_PRESSED_COLOR, |
| 242 |
(_, true, _) => BUTTON_ACTIVE_COLOR, |
| 243 |
(_, _, true) => BUTTON_HOVER_COLOR, |
| 244 |
_ => BUTTON_COLOR, |
| 245 |
}; |
| 246 |
color.set_if_neq(BackgroundColor(new)); |
| 247 |
} |
| 248 |
} |
| 249 |
|
| 250 |
#[cfg(test)] |
| 251 |
mod tests { |
| 252 |
use super::*; |
| 253 |
use crate::actions::KEYBINDS; |
| 254 |
|
| 255 |
#[test] |
| 256 |
fn toolbar_spawns_one_button_per_action_plus_restore_button() { |
| 257 |
let mut app = App::new(); |
| 258 |
app.add_systems(Startup, spawn_toolbar); |
| 259 |
app.update(); |
| 260 |
let world = app.world_mut(); |
| 261 |
let buttons = world.query::<&ActionButton>().iter(world).count(); |
| 262 |
assert_eq!(buttons, TOOLBAR.len() + 1); |
| 263 |
} |
| 264 |
|
| 265 |
#[test] |
| 266 |
fn every_keybind_has_a_toolbar_button() { |
| 267 |
for (key, action) in KEYBINDS { |
| 268 |
assert!(TOOLBAR.contains(&action), "{key:?} has no button"); |
| 269 |
} |
| 270 |
} |
| 271 |
|
| 272 |
#[test] |
| 273 |
fn active_buttons_reflect_simulation_state() { |
| 274 |
let normal = SimSpeed::Normal; |
| 275 |
assert!(is_active( |
| 276 |
Action::TogglePause, |
| 277 |
true, |
| 278 |
normal, |
| 279 |
None, |
| 280 |
None, |
| 281 |
false |
| 282 |
)); |
| 283 |
let fast = Action::SetSpeed(SimSpeed::Fast); |
| 284 |
assert!(is_active(fast, false, SimSpeed::Fast, None, None, false)); |
| 285 |
assert!(!is_active(fast, true, SimSpeed::Fast, None, None, false)); |
| 286 |
let farm = Some(ZoneKind::Farm); |
| 287 |
assert!(is_active( |
| 288 |
Action::ToggleFarmTool, |
| 289 |
false, |
| 290 |
normal, |
| 291 |
farm, |
| 292 |
None, |
| 293 |
false |
| 294 |
)); |
| 295 |
assert!(!is_active( |
| 296 |
Action::ToggleStockpileTool, |
| 297 |
false, |
| 298 |
normal, |
| 299 |
farm, |
| 300 |
None, |
| 301 |
false |
| 302 |
)); |
| 303 |
} |
| 304 |
|
| 305 |
#[test] |
| 306 |
fn toggling_hides_the_ui_and_shows_the_restore_button() { |
| 307 |
let mut app = App::new(); |
| 308 |
app.init_resource::<UiVisible>() |
| 309 |
.add_message::<Action>() |
| 310 |
.add_systems( |
| 311 |
Update, |
| 312 |
( |
| 313 |
toggle_ui, |
| 314 |
apply_ui_visibility.run_if(resource_changed::<UiVisible>), |
| 315 |
) |
| 316 |
.chain(), |
| 317 |
); |
| 318 |
let panel = app |
| 319 |
.world_mut() |
| 320 |
.spawn((HideableUi, Visibility::Inherited)) |
| 321 |
.id(); |
| 322 |
let restore = app |
| 323 |
.world_mut() |
| 324 |
.spawn((ShowUiButton, Visibility::Hidden)) |
| 325 |
.id(); |
| 326 |
app.update(); |
| 327 |
app.world_mut().write_message(Action::ToggleUi); |
| 328 |
app.update(); |
| 329 |
assert_eq!( |
| 330 |
app.world().get::<Visibility>(panel), |
| 331 |
Some(&Visibility::Hidden) |
| 332 |
); |
| 333 |
assert_eq!( |
| 334 |
app.world().get::<Visibility>(restore), |
| 335 |
Some(&Visibility::Inherited) |
| 336 |
); |
| 337 |
} |
| 338 |
} |