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