src/panel.rs 11.9 K · 437 lines · raw · history

1 use bevy::{
2 picking::hover::Hovered,
3 prelude::*,
4 ui_widgets::{Activate, ActivateOnPress, Button},
5 };
6
7 use crate::{
8 colonist::Colonist,
9 economy::{ColonyGoals, Stock},
10 family::Child,
11 jobs::{CurrentJob, WorkPriorities, WorkType},
12 mood::Mood,
13 selection::Selection,
14 ui::{BUTTON_ACTIVE_COLOR, BUTTON_COLOR, BUTTON_HOVER_COLOR, HideableUi},
15 zones::Zones,
16 };
17
18 pub struct PanelPlugin;
19
20 impl Plugin for PanelPlugin {
21 fn build(&self, app: &mut App) {
22 app.add_systems(Startup, spawn_panel)
23 .add_systems(
24 Update,
25 (
26 rebuild_roster,
27 show_work_toggles,
28 style_panel_buttons,
29 show_storage,
30 show_targets,
31 ),
32 )
33 .add_observer(on_panel_activated);
34 }
35 }
36
37 const PANEL_COLOR: Color = Color::srgba(0.08, 0.08, 0.1, 0.8);
38 const DISABLED_COLOR: Color = Color::srgba(0.35, 0.12, 0.12, 0.9);
39
40 #[derive(Component)]
41 struct Roster;
42
43 #[derive(Component)]
44 struct WorkRow;
45
46 #[derive(Component, Clone, Copy)]
47 struct ColonistButton(Entity);
48
49 #[derive(Component, Clone, Copy)]
50 struct WorkToggle(WorkType);
51
52 #[derive(Component)]
53 struct StorageSection;
54
55 #[derive(Component, Clone, Copy)]
56 struct StorageToggle(Stock);
57
58 #[derive(Component)]
59 struct PriorityButton;
60
61 #[derive(Component)]
62 struct PriorityLabel;
63
64 #[derive(Component)]
65 struct TargetsToggle;
66
67 #[derive(Component)]
68 struct TargetsSection;
69
70 #[derive(Component, Clone, Copy)]
71 struct GoalText(Stock);
72
73 #[derive(Component, Clone, Copy)]
74 struct GoalButton {
75 stock: Stock,
76 delta: i32,
77 }
78
79 const GOAL_STEP: i32 = 10;
80
81 fn wrap_row() -> Node {
82 Node {
83 flex_wrap: FlexWrap::Wrap,
84 column_gap: px(3),
85 row_gap: px(3),
86 align_items: AlignItems::Center,
87 ..default()
88 }
89 }
90
91 fn small_button(label: String) -> impl Bundle {
92 (
93 Button,
94 ActivateOnPress,
95 Hovered::default(),
96 Node {
97 padding: UiRect::axes(px(5), px(2)),
98 border_radius: BorderRadius::all(px(3)),
99 ..default()
100 },
101 BackgroundColor(BUTTON_COLOR),
102 children![(Text::new(label), TextFont::from_font_size(11.0))],
103 )
104 }
105
106 fn spawn_panel(mut commands: Commands) {
107 let panel = commands
108 .spawn((
109 HideableUi,
110 Node {
111 position_type: PositionType::Absolute,
112 top: px(8),
113 right: px(8),
114 width: px(170),
115 flex_direction: FlexDirection::Column,
116 row_gap: px(4),
117 padding: UiRect::all(px(6)),
118 border_radius: BorderRadius::all(px(6)),
119 ..default()
120 },
121 BackgroundColor(PANEL_COLOR),
122 children![(Text::new("Colonists"), TextFont::from_font_size(12.0))],
123 ))
124 .id();
125 commands.spawn((
126 Roster,
127 Node {
128 flex_wrap: FlexWrap::Wrap,
129 column_gap: px(3),
130 row_gap: px(3),
131 ..default()
132 },
133 ChildOf(panel),
134 ));
135 let row = commands
136 .spawn((
137 WorkRow,
138 Node {
139 flex_wrap: FlexWrap::Wrap,
140 column_gap: px(3),
141 row_gap: px(3),
142 display: Display::None,
143 ..default()
144 },
145 ChildOf(panel),
146 ))
147 .id();
148 for work in WorkType::ALL {
149 commands.spawn((
150 small_button(work.label().to_string()),
151 WorkToggle(work),
152 ChildOf(row),
153 ));
154 }
155 let storage = commands
156 .spawn((
157 StorageSection,
158 Node {
159 display: Display::None,
160 ..wrap_row()
161 },
162 ChildOf(panel),
163 children![(
164 Text::new("Stockpile accepts:"),
165 TextFont::from_font_size(11.0)
166 )],
167 ))
168 .id();
169 for stock in Stock::ALL {
170 commands.spawn((
171 small_button(stock.label().to_string()),
172 StorageToggle(stock),
173 ChildOf(storage),
174 ));
175 }
176 let priority = commands
177 .spawn((
178 small_button(String::new()),
179 PriorityButton,
180 ChildOf(storage),
181 ))
182 .id();
183 commands.spawn((
184 PriorityLabel,
185 Text::new("Priority"),
186 TextFont::from_font_size(11.0),
187 ChildOf(priority),
188 ));
189 commands.spawn((
190 small_button("Stock targets".into()),
191 TargetsToggle,
192 ChildOf(panel),
193 ));
194 let targets = commands
195 .spawn((
196 TargetsSection,
197 Node {
198 display: Display::None,
199 flex_direction: FlexDirection::Column,
200 row_gap: px(2),
201 ..default()
202 },
203 ChildOf(panel),
204 ))
205 .id();
206 for stock in Stock::ALL {
207 let line = commands.spawn((wrap_row(), ChildOf(targets))).id();
208 commands.spawn((
209 GoalText(stock),
210 Text::new(stock.label()),
211 TextFont::from_font_size(11.0),
212 Node {
213 width: px(78),
214 ..default()
215 },
216 ChildOf(line),
217 ));
218 for (label, delta) in [("-", -GOAL_STEP), ("+", GOAL_STEP), ("auto", 0)] {
219 commands.spawn((
220 small_button(label.into()),
221 GoalButton { stock, delta },
222 ChildOf(line),
223 ));
224 }
225 }
226 }
227
228 fn selected_stockpile(selection: &Selection, zones: &Zones) -> Option<UVec2> {
229 match *selection {
230 Selection::Tile(tile) if zones.is_stockpile(tile) => Some(tile),
231 _ => None,
232 }
233 }
234
235 fn show_storage(
236 selection: Res<Selection>,
237 zones: Res<Zones>,
238 mut section: Single<&mut Node, With<StorageSection>>,
239 mut toggles: Query<(&StorageToggle, &mut BackgroundColor)>,
240 mut label: Single<&mut Text, With<PriorityLabel>>,
241 ) {
242 let tile = selected_stockpile(&selection, &zones);
243 let display = if tile.is_some() {
244 Display::Flex
245 } else {
246 Display::None
247 };
248 if section.display != display {
249 section.display = display;
250 }
251 let Some(tile) = tile else {
252 return;
253 };
254 let rule = zones.rule(tile);
255 for (toggle, mut color) in &mut toggles {
256 let accepted = !rule.refused.contains(&toggle.0);
257 let new = if accepted {
258 BUTTON_ACTIVE_COLOR
259 } else {
260 DISABLED_COLOR
261 };
262 color.set_if_neq(BackgroundColor(new));
263 }
264 let text = format!("Priority: {}", rule.priority.label());
265 if label.0 != text {
266 label.0 = text;
267 }
268 }
269
270 fn show_targets(
271 goals: Res<ColonyGoals>,
272 section: Single<&Node, With<TargetsSection>>,
273 mut texts: Query<(&GoalText, &mut Text)>,
274 ) {
275 if section.display == Display::None {
276 return;
277 }
278 for (goal, mut text) in &mut texts {
279 let marker = if goals.is_overridden(goal.0) { "*" } else { "" };
280 let line = format!("{} {}{marker}", goal.0.label(), goals.goal(goal.0));
281 if text.0 != line {
282 text.0 = line;
283 }
284 }
285 }
286
287 pub fn roster_label(name: &str, child: bool, mood: Option<f32>) -> String {
288 let face = match mood {
289 Some(m) if m < 0.3 => " :(",
290 Some(m) if m > 0.7 => " :)",
291 _ => "",
292 };
293 format!("{name}{}{face}", if child { " (child)" } else { "" })
294 }
295
296 #[allow(clippy::type_complexity)]
297 fn rebuild_roster(
298 mut commands: Commands,
299 roster: Single<Entity, With<Roster>>,
300 colonists: Query<(Entity, &Colonist, Has<Child>, Option<&Mood>), With<CurrentJob>>,
301 mut shown: Local<Vec<(Entity, String)>>,
302 ) {
303 let mut wanted: Vec<(Entity, String)> = colonists
304 .iter()
305 .map(|(e, c, child, mood)| (e, roster_label(&c.name, child, mood.map(|m| m.0))))
306 .collect();
307 wanted.sort_by_key(|(e, _)| *e);
308 if *shown == wanted {
309 return;
310 }
311 commands.entity(*roster).despawn_related::<Children>();
312 for (entity, label) in &wanted {
313 commands.spawn((
314 small_button(label.clone()),
315 ColonistButton(*entity),
316 ChildOf(*roster),
317 ));
318 }
319 *shown = wanted;
320 }
321
322 fn show_work_toggles(
323 selection: Res<Selection>,
324 adults: Query<(), (With<WorkPriorities>, Without<Child>)>,
325 mut row: Single<&mut Node, With<WorkRow>>,
326 ) {
327 let visible = matches!(*selection, Selection::Colonist(e) if adults.contains(e));
328 let display = if visible {
329 Display::Flex
330 } else {
331 Display::None
332 };
333 if row.display != display {
334 row.display = display;
335 }
336 }
337
338 #[allow(clippy::type_complexity)]
339 fn style_panel_buttons(
340 selection: Res<Selection>,
341 priorities: Query<&WorkPriorities>,
342 mut buttons: Query<(
343 &Hovered,
344 Option<&WorkToggle>,
345 Option<&ColonistButton>,
346 &mut BackgroundColor,
347 )>,
348 ) {
349 let selected = match *selection {
350 Selection::Colonist(e) => Some(e),
351 _ => None,
352 };
353 for (hovered, toggle, colonist, mut color) in &mut buttons {
354 let new = match (toggle, colonist) {
355 (Some(toggle), _) => {
356 let allowed = selected
357 .and_then(|e| priorities.get(e).ok())
358 .is_none_or(|p| p.allows(toggle.0));
359 if allowed {
360 BUTTON_ACTIVE_COLOR
361 } else {
362 DISABLED_COLOR
363 }
364 }
365 (None, Some(c)) if Some(c.0) == selected => BUTTON_ACTIVE_COLOR,
366 (None, Some(_)) if hovered.get() => BUTTON_HOVER_COLOR,
367 (None, Some(_)) => BUTTON_COLOR,
368 (None, None) => continue,
369 };
370 color.set_if_neq(BackgroundColor(new));
371 }
372 }
373
374 #[allow(clippy::too_many_arguments)]
375 fn on_panel_activated(
376 activate: On<Activate>,
377 toggles: Query<&WorkToggle>,
378 colonist_buttons: Query<&ColonistButton>,
379 storage: Query<(Option<&StorageToggle>, Has<PriorityButton>)>,
380 goal_buttons: Query<&GoalButton>,
381 targets_toggle: Query<(), With<TargetsToggle>>,
382 mut targets: Single<&mut Node, With<TargetsSection>>,
383 mut selection: ResMut<Selection>,
384 mut zones: ResMut<Zones>,
385 mut goals: ResMut<ColonyGoals>,
386 mut priorities: Query<&mut WorkPriorities>,
387 ) {
388 if targets_toggle.contains(activate.entity) {
389 targets.display = match targets.display {
390 Display::None => Display::Flex,
391 _ => Display::None,
392 };
393 } else if let Ok(button) = goal_buttons.get(activate.entity) {
394 match button.delta {
395 0 => goals.reset(button.stock),
396 delta => goals.adjust(button.stock, delta),
397 }
398 } else if let Ok((toggle, priority)) = storage.get(activate.entity)
399 && (toggle.is_some() || priority)
400 && let Some(tile) = selected_stockpile(&selection, &zones)
401 {
402 zones.update_region_rule(tile, |rule| match toggle {
403 Some(toggle) => rule.toggle(toggle.0),
404 None => rule.priority = rule.priority.next(),
405 });
406 } else if let Ok(button) = colonist_buttons.get(activate.entity) {
407 *selection = Selection::Colonist(button.0);
408 } else if let Ok(toggle) = toggles.get(activate.entity)
409 && let Selection::Colonist(entity) = *selection
410 && let Ok(mut work) = priorities.get_mut(entity)
411 {
412 work.toggle(toggle.0);
413 }
414 }
415
416 #[cfg(test)]
417 mod tests {
418 use super::*;
419
420 #[test]
421 fn roster_labels_show_children_and_strong_moods() {
422 assert_eq!(roster_label("Ada", false, Some(0.5)), "Ada");
423 assert_eq!(roster_label("Ivo", true, Some(0.8)), "Ivo (child) :)");
424 assert_eq!(roster_label("Bram", false, Some(0.1)), "Bram :(");
425 }
426
427 #[test]
428 fn work_types_can_be_switched_off_and_on() {
429 let mut work = WorkPriorities::default();
430 assert!(work.allows(WorkType::Haul));
431 work.toggle(WorkType::Haul);
432 assert!(!work.allows(WorkType::Haul));
433 assert!(work.allows(WorkType::Food));
434 work.toggle(WorkType::Haul);
435 assert!(work.allows(WorkType::Haul));
436 }
437 }