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