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