use bevy::{ picking::hover::Hovered, prelude::*, ui_widgets::{Activate, ActivateOnPress, Button}, }; use crate::{ colonist::Colonist, economy::{ColonyGoals, Stock}, family::{Child, Family, Partner}, jobs::{CurrentJob, WorkPriorities, WorkType}, mood::Mood, selection::Selection, threats::{HURT, Health}, ui::{BUTTON_ACTIVE_COLOR, BUTTON_COLOR, BUTTON_HOVER_COLOR, HideableUi}, zones::Zones, }; pub struct PanelPlugin; impl Plugin for PanelPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, spawn_panel) .add_systems( Update, ( expand_selected_household, rebuild_roster, scroll_roster, show_work_toggles, style_panel_buttons, show_storage, show_targets, ), ) .init_resource::() .add_observer(on_panel_activated); } } const PANEL_COLOR: Color = Color::srgba(0.08, 0.08, 0.1, 0.8); const DISABLED_COLOR: Color = Color::srgba(0.35, 0.12, 0.12, 0.9); #[derive(Component)] struct Roster; #[derive(Component)] struct WorkRow; #[derive(Component, Clone, Copy)] struct ColonistButton(Entity); #[derive(Component, Clone, Copy)] struct HouseholdButton(Option); #[derive(Resource, Default, Debug)] struct ExpandedHouseholds(std::collections::HashSet>); const ROSTER_MAX_HEIGHT: f32 = 320.0; const SCROLL_LINE: f32 = 20.0; #[derive(Component, Clone, Copy)] struct WorkToggle(WorkType); #[derive(Component)] struct StorageSection; #[derive(Component, Clone, Copy)] struct StorageToggle(Stock); #[derive(Component)] struct PriorityButton; #[derive(Component)] struct PriorityLabel; #[derive(Component)] struct TargetsToggle; #[derive(Component)] struct TargetsSection; #[derive(Component, Clone, Copy)] struct GoalText(Stock); #[derive(Component, Clone, Copy)] struct GoalButton { stock: Stock, delta: i32, } const GOAL_STEP: i32 = 10; fn wrap_row() -> Node { Node { flex_wrap: FlexWrap::Wrap, column_gap: px(3), row_gap: px(3), align_items: AlignItems::Center, ..default() } } fn small_button(label: String) -> impl Bundle { ( Button, ActivateOnPress, Hovered::default(), Node { padding: UiRect::axes(px(5), px(2)), border_radius: BorderRadius::all(px(3)), ..default() }, BackgroundColor(BUTTON_COLOR), children![(Text::new(label), TextFont::from_font_size(11.0))], ) } fn spawn_panel(mut commands: Commands) { let panel = commands .spawn(( HideableUi, Node { position_type: PositionType::Absolute, top: px(8), right: px(8), width: px(170), flex_direction: FlexDirection::Column, row_gap: px(4), padding: UiRect::all(px(6)), border_radius: BorderRadius::all(px(6)), ..default() }, BackgroundColor(PANEL_COLOR), children![(Text::new("Colonists"), TextFont::from_font_size(12.0))], )) .id(); commands.spawn(( Roster, Hovered::default(), ScrollPosition::default(), Node { flex_direction: FlexDirection::Column, row_gap: px(3), max_height: px(ROSTER_MAX_HEIGHT), overflow: Overflow::scroll_y(), ..default() }, ChildOf(panel), )); let row = commands .spawn(( WorkRow, Node { flex_wrap: FlexWrap::Wrap, column_gap: px(3), row_gap: px(3), display: Display::None, ..default() }, ChildOf(panel), )) .id(); for work in WorkType::ALL { commands.spawn(( small_button(work.label().to_string()), WorkToggle(work), ChildOf(row), )); } let storage = commands .spawn(( StorageSection, Node { display: Display::None, ..wrap_row() }, ChildOf(panel), children![( Text::new("Stockpile accepts:"), TextFont::from_font_size(11.0) )], )) .id(); for stock in Stock::ALL { commands.spawn(( small_button(stock.label().to_string()), StorageToggle(stock), ChildOf(storage), )); } let priority = commands .spawn(( small_button(String::new()), PriorityButton, ChildOf(storage), )) .id(); commands.spawn(( PriorityLabel, Text::new("Priority"), TextFont::from_font_size(11.0), ChildOf(priority), )); commands.spawn(( small_button("Stock targets".into()), TargetsToggle, ChildOf(panel), )); let targets = commands .spawn(( TargetsSection, Node { display: Display::None, flex_direction: FlexDirection::Column, row_gap: px(2), ..default() }, ChildOf(panel), )) .id(); for stock in Stock::ALL { let line = commands.spawn((wrap_row(), ChildOf(targets))).id(); commands.spawn(( GoalText(stock), Text::new(stock.label()), TextFont::from_font_size(11.0), Node { width: px(78), ..default() }, ChildOf(line), )); for (label, delta) in [("-", -GOAL_STEP), ("+", GOAL_STEP), ("auto", 0)] { commands.spawn(( small_button(label.into()), GoalButton { stock, delta }, ChildOf(line), )); } } } fn selected_stockpile(selection: &Selection, zones: &Zones) -> Option { match *selection { Selection::Tile(tile) if zones.is_stockpile(tile) => Some(tile), _ => None, } } fn show_storage( selection: Res, zones: Res, mut section: Single<&mut Node, With>, mut toggles: Query<(&StorageToggle, &mut BackgroundColor)>, mut label: Single<&mut Text, With>, ) { let tile = selected_stockpile(&selection, &zones); let display = if tile.is_some() { Display::Flex } else { Display::None }; if section.display != display { section.display = display; } let Some(tile) = tile else { return; }; let rule = zones.rule(tile); for (toggle, mut color) in &mut toggles { let accepted = !rule.refused.contains(&toggle.0); let new = if accepted { BUTTON_ACTIVE_COLOR } else { DISABLED_COLOR }; color.set_if_neq(BackgroundColor(new)); } let text = format!("Priority: {}", rule.priority.label()); if label.0 != text { label.0 = text; } } fn show_targets( goals: Res, section: Single<&Node, With>, mut texts: Query<(&GoalText, &mut Text)>, ) { if section.display == Display::None { return; } for (goal, mut text) in &mut texts { let marker = if goals.is_overridden(goal.0) { "*" } else { "" }; let line = format!("{} {}{marker}", goal.0.label(), goals.goal(goal.0)); if text.0 != line { text.0 = line; } } } pub fn roster_label(name: &str, child: bool, mood: Option) -> String { let face = match mood { Some(m) if m < 0.3 => " :(", Some(m) if m > 0.7 => " :)", _ => "", }; format!("{name}{}{face}", if child { " (child)" } else { "" }) } #[derive(Debug, Clone, PartialEq)] pub struct Member { pub entity: Entity, pub name: String, pub child: bool, pub partner: bool, pub mood: Option, pub hurt: bool, } #[derive(Debug, Clone, PartialEq)] pub struct HouseholdRow { pub family: Option, pub label: String, pub members: Vec<(Entity, String)>, } pub fn household_rows(members: &[(Option, Member)]) -> Vec { let mut groups: Vec<(Option, Vec<&Member>)> = Vec::new(); for (family, member) in members { match groups.iter_mut().find(|(f, _)| f == family) { Some((_, list)) => list.push(member), None => groups.push((*family, vec![member])), } } let mut rows: Vec = groups .into_iter() .map(|(family, mut list)| { list.sort_by_key(|m| (m.child, !m.partner, m.name.clone())); let heads: Vec<&str> = list .iter() .filter(|m| !m.child) .take(2) .map(|m| m.name.as_str()) .collect(); let title = match (family, heads.as_slice()) { (None, _) => "Others".to_string(), (_, []) => list[0].name.clone(), (_, names) => names.join(" & "), }; let sad = list.iter().any(|m| m.mood.is_some_and(|v| v < 0.3)); let hurt = list.iter().any(|m| m.hurt); let label = format!( "{title} ({}){}{}", list.len(), if sad { " :(" } else { "" }, if hurt { " hurt" } else { "" } ); let members = list .iter() .map(|m| { let label = roster_label(&m.name, m.child, m.mood); (m.entity, if m.hurt { label + " hurt" } else { label }) }) .collect(); HouseholdRow { family, label, members, } }) .collect(); rows.sort_by(|a, b| (a.family.is_none(), &a.label).cmp(&(b.family.is_none(), &b.label))); rows } #[allow(clippy::type_complexity)] fn rebuild_roster( mut commands: Commands, roster: Single>, expanded: Res, colonists: Query< ( Entity, &Colonist, Option<&Family>, Has, Has, Option<&Mood>, Option<&Health>, ), With, >, mut shown: Local>, ) { let members: Vec<(Option, Member)> = colonists .iter() .map(|(entity, c, family, child, partner, mood, health)| { let member = Member { entity, name: c.name.clone(), child, partner, mood: mood.map(|m| m.0), hurt: health.is_some_and(|h| h.0 < HURT), }; (family.map(|f| f.0), member) }) .collect(); let wanted: Vec<(HouseholdRow, bool)> = household_rows(&members) .into_iter() .map(|row| { let open = expanded.0.contains(&row.family); (row, open) }) .collect(); if *shown == wanted { return; } commands.entity(*roster).despawn_related::(); for (row, open) in &wanted { let marker = if *open { "-" } else { "+" }; commands.spawn(( small_button(format!("{marker} {}", row.label)), HouseholdButton(row.family), ChildOf(*roster), )); if !open { continue; } let list = commands .spawn(( Node { margin: UiRect::left(px(8)), ..wrap_row() }, ChildOf(*roster), )) .id(); for (entity, label) in &row.members { commands.spawn(( small_button(label.clone()), ColonistButton(*entity), ChildOf(list), )); } } *shown = wanted; } fn expand_selected_household( selection: Res, families: Query, With>, mut expanded: ResMut, ) { if !selection.is_changed() { return; } if let Selection::Colonist(entity) = *selection && let Ok(family) = families.get(entity) && !expanded.0.contains(&family.map(|f| f.0)) { expanded.0.insert(family.map(|f| f.0)); } } fn scroll_roster( scroll: Res, pixels_per_line: Res, roster: Single<(&Hovered, &mut ScrollPosition, &ComputedNode), With>, ) { let (hovered, mut position, computed) = roster.into_inner(); let lines = scroll.to_lines(&pixels_per_line).delta.y; if lines == 0.0 || !hovered.get() { return; } let max = ((computed.content_size().y - computed.size().y) * computed.inverse_scale_factor()) .max(0.0); position.y = (position.y - lines * SCROLL_LINE).clamp(0.0, max); } fn show_work_toggles( selection: Res, adults: Query<(), (With, Without)>, mut row: Single<&mut Node, With>, ) { let visible = matches!(*selection, Selection::Colonist(e) if adults.contains(e)); let display = if visible { Display::Flex } else { Display::None }; if row.display != display { row.display = display; } } #[allow(clippy::type_complexity)] fn style_panel_buttons( selection: Res, priorities: Query<&WorkPriorities>, mut buttons: Query<( &Hovered, Option<&WorkToggle>, Option<&ColonistButton>, Has, &mut BackgroundColor, )>, ) { let selected = match *selection { Selection::Colonist(e) => Some(e), _ => None, }; for (hovered, toggle, colonist, household, mut color) in &mut buttons { let new = match (toggle, colonist) { (Some(toggle), _) => { let allowed = selected .and_then(|e| priorities.get(e).ok()) .is_none_or(|p| p.allows(toggle.0)); if allowed { BUTTON_ACTIVE_COLOR } else { DISABLED_COLOR } } (None, Some(c)) if Some(c.0) == selected => BUTTON_ACTIVE_COLOR, (None, Some(_)) if hovered.get() => BUTTON_HOVER_COLOR, (None, Some(_)) => BUTTON_COLOR, (None, None) if household && hovered.get() => BUTTON_HOVER_COLOR, (None, None) if household => BUTTON_COLOR, (None, None) => continue, }; color.set_if_neq(BackgroundColor(new)); } } #[allow(clippy::too_many_arguments)] fn on_panel_activated( activate: On, toggles: Query<&WorkToggle>, colonist_buttons: Query<&ColonistButton>, storage: Query<(Option<&StorageToggle>, Has)>, goal_buttons: Query<&GoalButton>, targets_toggle: Query<(), With>, mut targets: Single<&mut Node, With>, mut selection: ResMut, mut zones: ResMut, mut goals: ResMut, mut priorities: Query<&mut WorkPriorities>, households: (Query<&HouseholdButton>, ResMut), ) { let (household_buttons, mut expanded) = households; if let Ok(button) = household_buttons.get(activate.entity) { if !expanded.0.remove(&button.0) { expanded.0.insert(button.0); } } else if targets_toggle.contains(activate.entity) { targets.display = match targets.display { Display::None => Display::Flex, _ => Display::None, }; } else if let Ok(button) = goal_buttons.get(activate.entity) { match button.delta { 0 => goals.reset(button.stock), delta => goals.adjust(button.stock, delta), } } else if let Ok((toggle, priority)) = storage.get(activate.entity) && (toggle.is_some() || priority) && let Some(tile) = selected_stockpile(&selection, &zones) { zones.update_region_rule(tile, |rule| match toggle { Some(toggle) => rule.toggle(toggle.0), None => rule.priority = rule.priority.next(), }); } else if let Ok(button) = colonist_buttons.get(activate.entity) { *selection = Selection::Colonist(button.0); } else if let Ok(toggle) = toggles.get(activate.entity) && let Selection::Colonist(entity) = *selection && let Ok(mut work) = priorities.get_mut(entity) { work.toggle(toggle.0); } } #[cfg(test)] mod tests { use super::*; #[test] fn roster_labels_show_children_and_strong_moods() { assert_eq!(roster_label("Ada", false, Some(0.5)), "Ada"); assert_eq!(roster_label("Ivo", true, Some(0.8)), "Ivo (child) :)"); assert_eq!(roster_label("Bram", false, Some(0.1)), "Bram :("); } #[test] fn work_types_can_be_switched_off_and_on() { let mut work = WorkPriorities::default(); assert!(work.allows(WorkType::Haul)); work.toggle(WorkType::Haul); assert!(!work.allows(WorkType::Haul)); assert!(work.allows(WorkType::Food)); work.toggle(WorkType::Haul); assert!(work.allows(WorkType::Haul)); } // Big colonies don't fit as a flat list of names: the roster groups colonists by household // (partners first, then children), names each household after its adults, and flags any // household with an unhappy or hurt member so it stands out while collapsed. #[test] fn roster_groups_colonists_by_household() { let e = |i: u32| Entity::from_raw_u32(i).unwrap(); let member = |i, name: &str, child, partner, mood, hurt| Member { entity: e(i), name: name.into(), child, partner, mood: Some(mood), hurt, }; let rows = household_rows(&[ (Some(1), member(1, "Bram", false, true, 0.5, false)), (Some(1), member(2, "Ivo", true, false, 0.2, false)), (Some(1), member(3, "Ada", false, true, 0.5, true)), (None, member(4, "Zeno", false, false, 0.5, false)), (Some(2), member(5, "Cleo", false, false, 0.8, false)), ]); let labels: Vec<&str> = rows.iter().map(|r| r.label.as_str()).collect(); assert_eq!(labels, ["Ada & Bram (3) :( hurt", "Cleo (1)", "Others (1)"]); assert_eq!(rows[0].members[2].1, "Ivo (child) :("); } }