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