src/panel.rs 18.5 K · 633 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, Family, Partner},
11 jobs::{CurrentJob, WorkPriorities, WorkType},
12 mood::Mood,
13 selection::Selection,
14 threats::{HURT, Health},
15 ui::{BUTTON_ACTIVE_COLOR, BUTTON_COLOR, BUTTON_HOVER_COLOR, HideableUi},
16 zones::Zones,
17 };
18
19 pub struct PanelPlugin;
20
21 impl Plugin for PanelPlugin {
22 fn build(&self, app: &mut App) {
23 app.add_systems(Startup, spawn_panel)
24 .add_systems(
25 Update,
26 (
27 expand_selected_household,
28 rebuild_roster,
29 scroll_roster,
30 show_work_toggles,
31 style_panel_buttons,
32 show_storage,
33 show_targets,
34 ),
35 )
36 .init_resource::<ExpandedHouseholds>()
37 .add_observer(on_panel_activated);
38 }
39 }
40
41 const PANEL_COLOR: Color = Color::srgba(0.08, 0.08, 0.1, 0.8);
42 const DISABLED_COLOR: Color = Color::srgba(0.35, 0.12, 0.12, 0.9);
43
44 #[derive(Component)]
45 struct Roster;
46
47 #[derive(Component)]
48 struct WorkRow;
49
50 #[derive(Component, Clone, Copy)]
51 struct ColonistButton(Entity);
52
53 #[derive(Component, Clone, Copy)]
54 struct HouseholdButton(Option<u32>);
55
56 #[derive(Resource, Default, Debug)]
57 struct ExpandedHouseholds(std::collections::HashSet<Option<u32>>);
58
59 const ROSTER_MAX_HEIGHT: f32 = 320.0;
60 const SCROLL_LINE: f32 = 20.0;
61
62 #[derive(Component, Clone, Copy)]
63 struct WorkToggle(WorkType);
64
65 #[derive(Component)]
66 struct StorageSection;
67
68 #[derive(Component, Clone, Copy)]
69 struct StorageToggle(Stock);
70
71 #[derive(Component)]
72 struct PriorityButton;
73
74 #[derive(Component)]
75 struct PriorityLabel;
76
77 #[derive(Component)]
78 struct TargetsToggle;
79
80 #[derive(Component)]
81 struct TargetsSection;
82
83 #[derive(Component, Clone, Copy)]
84 struct GoalText(Stock);
85
86 #[derive(Component, Clone, Copy)]
87 struct GoalButton {
88 stock: Stock,
89 delta: i32,
90 }
91
92 const GOAL_STEP: i32 = 10;
93
94 fn wrap_row() -> Node {
95 Node {
96 flex_wrap: FlexWrap::Wrap,
97 column_gap: px(3),
98 row_gap: px(3),
99 align_items: AlignItems::Center,
100 ..default()
101 }
102 }
103
104 fn small_button(label: String) -> impl Bundle {
105 (
106 Button,
107 ActivateOnPress,
108 Hovered::default(),
109 Node {
110 padding: UiRect::axes(px(5), px(2)),
111 border_radius: BorderRadius::all(px(3)),
112 ..default()
113 },
114 BackgroundColor(BUTTON_COLOR),
115 children![(Text::new(label), TextFont::from_font_size(11.0))],
116 )
117 }
118
119 fn spawn_panel(mut commands: Commands) {
120 let panel = commands
121 .spawn((
122 HideableUi,
123 Node {
124 position_type: PositionType::Absolute,
125 top: px(8),
126 right: px(8),
127 width: px(170),
128 flex_direction: FlexDirection::Column,
129 row_gap: px(4),
130 padding: UiRect::all(px(6)),
131 border_radius: BorderRadius::all(px(6)),
132 ..default()
133 },
134 BackgroundColor(PANEL_COLOR),
135 children![(Text::new("Colonists"), TextFont::from_font_size(12.0))],
136 ))
137 .id();
138 commands.spawn((
139 Roster,
140 Hovered::default(),
141 ScrollPosition::default(),
142 Node {
143 flex_direction: FlexDirection::Column,
144 row_gap: px(3),
145 max_height: px(ROSTER_MAX_HEIGHT),
146 overflow: Overflow::scroll_y(),
147 ..default()
148 },
149 ChildOf(panel),
150 ));
151 let row = commands
152 .spawn((
153 WorkRow,
154 Node {
155 flex_wrap: FlexWrap::Wrap,
156 column_gap: px(3),
157 row_gap: px(3),
158 display: Display::None,
159 ..default()
160 },
161 ChildOf(panel),
162 ))
163 .id();
164 for work in WorkType::ALL {
165 commands.spawn((
166 small_button(work.label().to_string()),
167 WorkToggle(work),
168 ChildOf(row),
169 ));
170 }
171 let storage = commands
172 .spawn((
173 StorageSection,
174 Node {
175 display: Display::None,
176 ..wrap_row()
177 },
178 ChildOf(panel),
179 children![(
180 Text::new("Stockpile accepts:"),
181 TextFont::from_font_size(11.0)
182 )],
183 ))
184 .id();
185 for stock in Stock::ALL {
186 commands.spawn((
187 small_button(stock.label().to_string()),
188 StorageToggle(stock),
189 ChildOf(storage),
190 ));
191 }
192 let priority = commands
193 .spawn((
194 small_button(String::new()),
195 PriorityButton,
196 ChildOf(storage),
197 ))
198 .id();
199 commands.spawn((
200 PriorityLabel,
201 Text::new("Priority"),
202 TextFont::from_font_size(11.0),
203 ChildOf(priority),
204 ));
205 commands.spawn((
206 small_button("Stock targets".into()),
207 TargetsToggle,
208 ChildOf(panel),
209 ));
210 let targets = commands
211 .spawn((
212 TargetsSection,
213 Node {
214 display: Display::None,
215 flex_direction: FlexDirection::Column,
216 row_gap: px(2),
217 ..default()
218 },
219 ChildOf(panel),
220 ))
221 .id();
222 for stock in Stock::ALL {
223 let line = commands.spawn((wrap_row(), ChildOf(targets))).id();
224 commands.spawn((
225 GoalText(stock),
226 Text::new(stock.label()),
227 TextFont::from_font_size(11.0),
228 Node {
229 width: px(78),
230 ..default()
231 },
232 ChildOf(line),
233 ));
234 for (label, delta) in [("-", -GOAL_STEP), ("+", GOAL_STEP), ("auto", 0)] {
235 commands.spawn((
236 small_button(label.into()),
237 GoalButton { stock, delta },
238 ChildOf(line),
239 ));
240 }
241 }
242 }
243
244 fn selected_stockpile(selection: &Selection, zones: &Zones) -> Option<UVec2> {
245 match *selection {
246 Selection::Tile(tile) if zones.is_stockpile(tile) => Some(tile),
247 _ => None,
248 }
249 }
250
251 fn show_storage(
252 selection: Res<Selection>,
253 zones: Res<Zones>,
254 mut section: Single<&mut Node, With<StorageSection>>,
255 mut toggles: Query<(&StorageToggle, &mut BackgroundColor)>,
256 mut label: Single<&mut Text, With<PriorityLabel>>,
257 ) {
258 let tile = selected_stockpile(&selection, &zones);
259 let display = if tile.is_some() {
260 Display::Flex
261 } else {
262 Display::None
263 };
264 if section.display != display {
265 section.display = display;
266 }
267 let Some(tile) = tile else {
268 return;
269 };
270 let rule = zones.rule(tile);
271 for (toggle, mut color) in &mut toggles {
272 let accepted = !rule.refused.contains(&toggle.0);
273 let new = if accepted {
274 BUTTON_ACTIVE_COLOR
275 } else {
276 DISABLED_COLOR
277 };
278 color.set_if_neq(BackgroundColor(new));
279 }
280 let text = format!("Priority: {}", rule.priority.label());
281 if label.0 != text {
282 label.0 = text;
283 }
284 }
285
286 fn show_targets(
287 goals: Res<ColonyGoals>,
288 section: Single<&Node, With<TargetsSection>>,
289 mut texts: Query<(&GoalText, &mut Text)>,
290 ) {
291 if section.display == Display::None {
292 return;
293 }
294 for (goal, mut text) in &mut texts {
295 let marker = if goals.is_overridden(goal.0) { "*" } else { "" };
296 let line = format!("{} {}{marker}", goal.0.label(), goals.goal(goal.0));
297 if text.0 != line {
298 text.0 = line;
299 }
300 }
301 }
302
303 pub fn roster_label(name: &str, child: bool, mood: Option<f32>) -> String {
304 let face = match mood {
305 Some(m) if m < 0.3 => " :(",
306 Some(m) if m > 0.7 => " :)",
307 _ => "",
308 };
309 format!("{name}{}{face}", if child { " (child)" } else { "" })
310 }
311
312 #[derive(Debug, Clone, PartialEq)]
313 pub struct Member {
314 pub entity: Entity,
315 pub name: String,
316 pub child: bool,
317 pub partner: bool,
318 pub mood: Option<f32>,
319 pub hurt: bool,
320 }
321
322 #[derive(Debug, Clone, PartialEq)]
323 pub struct HouseholdRow {
324 pub family: Option<u32>,
325 pub label: String,
326 pub members: Vec<(Entity, String)>,
327 }
328
329 pub fn household_rows(members: &[(Option<u32>, Member)]) -> Vec<HouseholdRow> {
330 let mut groups: Vec<(Option<u32>, Vec<&Member>)> = Vec::new();
331 for (family, member) in members {
332 match groups.iter_mut().find(|(f, _)| f == family) {
333 Some((_, list)) => list.push(member),
334 None => groups.push((*family, vec![member])),
335 }
336 }
337 let mut rows: Vec<HouseholdRow> = groups
338 .into_iter()
339 .map(|(family, mut list)| {
340 list.sort_by_key(|m| (m.child, !m.partner, m.name.clone()));
341 let heads: Vec<&str> = list
342 .iter()
343 .filter(|m| !m.child)
344 .take(2)
345 .map(|m| m.name.as_str())
346 .collect();
347 let title = match (family, heads.as_slice()) {
348 (None, _) => "Others".to_string(),
349 (_, []) => list[0].name.clone(),
350 (_, names) => names.join(" & "),
351 };
352 let sad = list.iter().any(|m| m.mood.is_some_and(|v| v < 0.3));
353 let hurt = list.iter().any(|m| m.hurt);
354 let label = format!(
355 "{title} ({}){}{}",
356 list.len(),
357 if sad { " :(" } else { "" },
358 if hurt { " hurt" } else { "" }
359 );
360 let members = list
361 .iter()
362 .map(|m| {
363 let label = roster_label(&m.name, m.child, m.mood);
364 (m.entity, if m.hurt { label + " hurt" } else { label })
365 })
366 .collect();
367 HouseholdRow {
368 family,
369 label,
370 members,
371 }
372 })
373 .collect();
374 rows.sort_by(|a, b| (a.family.is_none(), &a.label).cmp(&(b.family.is_none(), &b.label)));
375 rows
376 }
377
378 #[allow(clippy::type_complexity)]
379 fn rebuild_roster(
380 mut commands: Commands,
381 roster: Single<Entity, With<Roster>>,
382 expanded: Res<ExpandedHouseholds>,
383 colonists: Query<
384 (
385 Entity,
386 &Colonist,
387 Option<&Family>,
388 Has<Child>,
389 Has<Partner>,
390 Option<&Mood>,
391 Option<&Health>,
392 ),
393 With<CurrentJob>,
394 >,
395 mut shown: Local<Vec<(HouseholdRow, bool)>>,
396 ) {
397 let members: Vec<(Option<u32>, Member)> = colonists
398 .iter()
399 .map(|(entity, c, family, child, partner, mood, health)| {
400 let member = Member {
401 entity,
402 name: c.name.clone(),
403 child,
404 partner,
405 mood: mood.map(|m| m.0),
406 hurt: health.is_some_and(|h| h.0 < HURT),
407 };
408 (family.map(|f| f.0), member)
409 })
410 .collect();
411 let wanted: Vec<(HouseholdRow, bool)> = household_rows(&members)
412 .into_iter()
413 .map(|row| {
414 let open = expanded.0.contains(&row.family);
415 (row, open)
416 })
417 .collect();
418 if *shown == wanted {
419 return;
420 }
421 commands.entity(*roster).despawn_related::<Children>();
422 for (row, open) in &wanted {
423 let marker = if *open { "-" } else { "+" };
424 commands.spawn((
425 small_button(format!("{marker} {}", row.label)),
426 HouseholdButton(row.family),
427 ChildOf(*roster),
428 ));
429 if !open {
430 continue;
431 }
432 let list = commands
433 .spawn((
434 Node {
435 margin: UiRect::left(px(8)),
436 ..wrap_row()
437 },
438 ChildOf(*roster),
439 ))
440 .id();
441 for (entity, label) in &row.members {
442 commands.spawn((
443 small_button(label.clone()),
444 ColonistButton(*entity),
445 ChildOf(list),
446 ));
447 }
448 }
449 *shown = wanted;
450 }
451
452 fn expand_selected_household(
453 selection: Res<Selection>,
454 families: Query<Option<&Family>, With<Colonist>>,
455 mut expanded: ResMut<ExpandedHouseholds>,
456 ) {
457 if !selection.is_changed() {
458 return;
459 }
460 if let Selection::Colonist(entity) = *selection
461 && let Ok(family) = families.get(entity)
462 && !expanded.0.contains(&family.map(|f| f.0))
463 {
464 expanded.0.insert(family.map(|f| f.0));
465 }
466 }
467
468 fn scroll_roster(
469 scroll: Res<bevy::input::mouse::AccumulatedMouseScroll>,
470 pixels_per_line: Res<bevy::input::mouse::MouseScrollPixelsPerLine>,
471 roster: Single<(&Hovered, &mut ScrollPosition, &ComputedNode), With<Roster>>,
472 ) {
473 let (hovered, mut position, computed) = roster.into_inner();
474 let lines = scroll.to_lines(&pixels_per_line).delta.y;
475 if lines == 0.0 || !hovered.get() {
476 return;
477 }
478 let max = ((computed.content_size().y - computed.size().y) * computed.inverse_scale_factor())
479 .max(0.0);
480 position.y = (position.y - lines * SCROLL_LINE).clamp(0.0, max);
481 }
482
483 fn show_work_toggles(
484 selection: Res<Selection>,
485 adults: Query<(), (With<WorkPriorities>, Without<Child>)>,
486 mut row: Single<&mut Node, With<WorkRow>>,
487 ) {
488 let visible = matches!(*selection, Selection::Colonist(e) if adults.contains(e));
489 let display = if visible {
490 Display::Flex
491 } else {
492 Display::None
493 };
494 if row.display != display {
495 row.display = display;
496 }
497 }
498
499 #[allow(clippy::type_complexity)]
500 fn style_panel_buttons(
501 selection: Res<Selection>,
502 priorities: Query<&WorkPriorities>,
503 mut buttons: Query<(
504 &Hovered,
505 Option<&WorkToggle>,
506 Option<&ColonistButton>,
507 Has<HouseholdButton>,
508 &mut BackgroundColor,
509 )>,
510 ) {
511 let selected = match *selection {
512 Selection::Colonist(e) => Some(e),
513 _ => None,
514 };
515 for (hovered, toggle, colonist, household, mut color) in &mut buttons {
516 let new = match (toggle, colonist) {
517 (Some(toggle), _) => {
518 let allowed = selected
519 .and_then(|e| priorities.get(e).ok())
520 .is_none_or(|p| p.allows(toggle.0));
521 if allowed {
522 BUTTON_ACTIVE_COLOR
523 } else {
524 DISABLED_COLOR
525 }
526 }
527 (None, Some(c)) if Some(c.0) == selected => BUTTON_ACTIVE_COLOR,
528 (None, Some(_)) if hovered.get() => BUTTON_HOVER_COLOR,
529 (None, Some(_)) => BUTTON_COLOR,
530 (None, None) if household && hovered.get() => BUTTON_HOVER_COLOR,
531 (None, None) if household => BUTTON_COLOR,
532 (None, None) => continue,
533 };
534 color.set_if_neq(BackgroundColor(new));
535 }
536 }
537
538 #[allow(clippy::too_many_arguments)]
539 fn on_panel_activated(
540 activate: On<Activate>,
541 toggles: Query<&WorkToggle>,
542 colonist_buttons: Query<&ColonistButton>,
543 storage: Query<(Option<&StorageToggle>, Has<PriorityButton>)>,
544 goal_buttons: Query<&GoalButton>,
545 targets_toggle: Query<(), With<TargetsToggle>>,
546 mut targets: Single<&mut Node, With<TargetsSection>>,
547 mut selection: ResMut<Selection>,
548 mut zones: ResMut<Zones>,
549 mut goals: ResMut<ColonyGoals>,
550 mut priorities: Query<&mut WorkPriorities>,
551 households: (Query<&HouseholdButton>, ResMut<ExpandedHouseholds>),
552 ) {
553 let (household_buttons, mut expanded) = households;
554 if let Ok(button) = household_buttons.get(activate.entity) {
555 if !expanded.0.remove(&button.0) {
556 expanded.0.insert(button.0);
557 }
558 } else if targets_toggle.contains(activate.entity) {
559 targets.display = match targets.display {
560 Display::None => Display::Flex,
561 _ => Display::None,
562 };
563 } else if let Ok(button) = goal_buttons.get(activate.entity) {
564 match button.delta {
565 0 => goals.reset(button.stock),
566 delta => goals.adjust(button.stock, delta),
567 }
568 } else if let Ok((toggle, priority)) = storage.get(activate.entity)
569 && (toggle.is_some() || priority)
570 && let Some(tile) = selected_stockpile(&selection, &zones)
571 {
572 zones.update_region_rule(tile, |rule| match toggle {
573 Some(toggle) => rule.toggle(toggle.0),
574 None => rule.priority = rule.priority.next(),
575 });
576 } else if let Ok(button) = colonist_buttons.get(activate.entity) {
577 *selection = Selection::Colonist(button.0);
578 } else if let Ok(toggle) = toggles.get(activate.entity)
579 && let Selection::Colonist(entity) = *selection
580 && let Ok(mut work) = priorities.get_mut(entity)
581 {
582 work.toggle(toggle.0);
583 }
584 }
585
586 #[cfg(test)]
587 mod tests {
588 use super::*;
589
590 #[test]
591 fn roster_labels_show_children_and_strong_moods() {
592 assert_eq!(roster_label("Ada", false, Some(0.5)), "Ada");
593 assert_eq!(roster_label("Ivo", true, Some(0.8)), "Ivo (child) :)");
594 assert_eq!(roster_label("Bram", false, Some(0.1)), "Bram :(");
595 }
596
597 #[test]
598 fn work_types_can_be_switched_off_and_on() {
599 let mut work = WorkPriorities::default();
600 assert!(work.allows(WorkType::Haul));
601 work.toggle(WorkType::Haul);
602 assert!(!work.allows(WorkType::Haul));
603 assert!(work.allows(WorkType::Food));
604 work.toggle(WorkType::Haul);
605 assert!(work.allows(WorkType::Haul));
606 }
607
608 // Big colonies don't fit as a flat list of names: the roster groups colonists by household
609 // (partners first, then children), names each household after its adults, and flags any
610 // household with an unhappy or hurt member so it stands out while collapsed.
611 #[test]
612 fn roster_groups_colonists_by_household() {
613 let e = |i: u32| Entity::from_raw_u32(i).unwrap();
614 let member = |i, name: &str, child, partner, mood, hurt| Member {
615 entity: e(i),
616 name: name.into(),
617 child,
618 partner,
619 mood: Some(mood),
620 hurt,
621 };
622 let rows = household_rows(&[
623 (Some(1), member(1, "Bram", false, true, 0.5, false)),
624 (Some(1), member(2, "Ivo", true, false, 0.2, false)),
625 (Some(1), member(3, "Ada", false, true, 0.5, true)),
626 (None, member(4, "Zeno", false, false, 0.5, false)),
627 (Some(2), member(5, "Cleo", false, false, 0.8, false)),
628 ]);
629 let labels: Vec<&str> = rows.iter().map(|r| r.label.as_str()).collect();
630 assert_eq!(labels, ["Ada & Bram (3) :( hurt", "Cleo (1)", "Others (1)"]);
631 assert_eq!(rows[0].members[2].1, "Ivo (child) :(");
632 }
633 }