src/ui.rs 9.2 K · 333 lines · raw · history

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