Add a colonist panel with work assignments
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 22:27 UTC
- commit
- 4ee48e06408ba977bc25f0c30c84301c9ef333ab
- parent
- 2f6e9dbe99
- tree
- browse at this commit
9 files changed +387 -42
| CLAUDE.md | +1 -0 | |
| src/colonist.rs | +12 -2 | |
| src/jobs/mod.rs | +113 -35 | |
| src/jobs/tests.rs | +19 -0 | |
| src/main.rs | +2 -0 | |
| src/panel.rs | added | +235 -0 |
| src/selection.rs | +1 -0 | |
| src/ui.rs | +3 -3 | |
| tasks.md | +1 -2 |
CLAUDE.md +1 -0
| @@ -44,6 +44,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 44 | 44 | - `src/population.rs` `ColonyLog` (last 4 events, shown in the info panel). `starve`: `Starvation` ticks at zero hunger; after STARVATION_TICKS (1 day) the colonist dies (drops load, releases reservations). `arrivals` daily at 10:00: if `prosperous` (≤ MAX_COLONISTS, stored nutrition ≥ 1 per colonist, a free bed or enough wood for one) and a 60% roll, a newcomer (`spawn_colonist`, next unused name) appears at an `edge_tile` with an unordered MoveTo home. |
| 45 | 45 | - `src/family.rs` families. `Family(u32)` (shared by partners and their children), `Partner(Entity)`, `Child{born}` (CHILD_SCALE visual, grows up after CHILDHOOD_TICKS = 4 days in `grow_up`) on colonists. `bond` (hourly): unpartnered adults not of the same family within BOND_RANGE accumulate `Affinity` hours per pair; ≥ BOND_HOURS + BOND_CHANCE roll → partners with a new family id; dangling Partner removed when the partner dies. `Homes{by_family: family → Home{lo, hi}}` (wall-ring corners, interior HOME_INTERIOR 4×3, `bed_tiles` 2 beds top-left, `owner(tile)`). `plan_homes` (every 300 ticks, Decide): drops homes of extinct families; roofs finished homes (`roofs_needed`: interior enclosed exactly); plans one home per partnered family without one via `HomeContext::plan` (BFS from colony home, center ≥ HOME_MIN_DISTANCE, core open + 1-tile belt free of stockpile/farm/blueprints/homes) → wall/door/bed blueprints (door nearest colony home). `births` (daily 20:00): family with 2 living partners, built home (roofed, 2 beds), < MAX_CHILDREN, colony < MAX_COLONISTS and food ≥ colonists, BIRTH_CHANCE → child spawned inside home. Children: choose_jobs gives them only sleep/eat/wander; `find_bed_site` counts adults. Sleep: own home bed (adults), free roofed home floor (children), else communal beds (`Homes::owner` None) → campfire. `plan_shelter` ignores beds inside homes. Selection text shows partner / child days left. |
| 46 | 46 | - `src/mood.rs` `Mood(f32)` (default 0.6) eases hourly (1/MOOD_HOURS) toward `mood_target(MoodFactors)`: hunger, rest, last meal cooked/raw within a day, `Sick`, partner, built family home, grief (a `Losses` tick within 2 days; starvation and old age push losses). `Ate{colonist, kind}` message from run_jobs' Eat completion → `digest`: `LastMeal`, raw food rolls `poison_chance` (meat 15%, berries/veg 4%) → `Sick{until}` 12 h (work rate halved in run_jobs). `leave`: adults below LEAVE_MOOD for LEAVE_AFTER_HOURS (48, `Unhappy` hours) leave (despawn, drop load). `Age{born}` (1 day = 1 year; `assign_age` gives new adults 18–40, children 0); `old_age` daily: from OLD_AGE 60, death chance 3%/year over. Selection shows mood/age/sick. |
| 47 | +- `src/panel.rs` top-right colonist panel: roster of `ColonistButton`s (rebuilt when names/child/mood face change; click selects) and, for a selected adult, `WorkToggle` buttons per `WorkType::{Food, Build, Gather, Haul, Tame}` toggling the colonist's `WorkPriorities` (required on Colonist; red = disabled). choose_jobs' autonomous candidates are a `(WorkType, finder)` list filtered by `allows`; orders ignore priorities. | |
| 47 | 48 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 48 | 49 | |
| 49 | 50 | ## Gotchas |
src/colonist.rs +12 -2
| @@ -5,7 +5,7 @@ use bevy::prelude::*; | ||
| 5 | 5 | use crate::{ |
| 6 | 6 | food::Hunger, |
| 7 | 7 | items::{Carrying, ItemAssets}, |
| 8 | - jobs::{CurrentJob, IdleTimer}, | |
| 8 | + jobs::{CurrentJob, IdleTimer, WorkPriorities}, | |
| 9 | 9 | needs::{Rest, sleep_marker}, |
| 10 | 10 | pathfinding::find_path, |
| 11 | 11 | population::Starvation, |
| @@ -80,7 +80,17 @@ pub fn spawn_colonist( | ||
| 80 | 80 | |
| 81 | 81 | #[derive(Component, Debug)] |
| 82 | 82 | #[require( |
| 83 | - Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer, Carrying, Rest, Hunger, Starvation | |
| 83 | + Transform, | |
| 84 | + SimPos, | |
| 85 | + TilePos, | |
| 86 | + Path, | |
| 87 | + CurrentJob, | |
| 88 | + IdleTimer, | |
| 89 | + Carrying, | |
| 90 | + Rest, | |
| 91 | + Hunger, | |
| 92 | + Starvation, | |
| 93 | + WorkPriorities | |
| 84 | 94 | )] |
| 85 | 95 | pub struct Colonist { |
| 86 | 96 | pub name: String, |
src/jobs/mod.rs +113 -35
| @@ -75,6 +75,54 @@ const NEIGHBORS: [IVec2; 8] = [ | ||
| 75 | 75 | IVec2::new(-1, -1), |
| 76 | 76 | ]; |
| 77 | 77 | |
| 78 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 79 | +pub enum WorkType { | |
| 80 | + Food, | |
| 81 | + Build, | |
| 82 | + Gather, | |
| 83 | + Haul, | |
| 84 | + Tame, | |
| 85 | +} | |
| 86 | + | |
| 87 | +impl WorkType { | |
| 88 | + pub const ALL: [WorkType; 5] = [ | |
| 89 | + WorkType::Food, | |
| 90 | + WorkType::Build, | |
| 91 | + WorkType::Gather, | |
| 92 | + WorkType::Haul, | |
| 93 | + WorkType::Tame, | |
| 94 | + ]; | |
| 95 | + | |
| 96 | + pub fn label(self) -> &'static str { | |
| 97 | + match self { | |
| 98 | + WorkType::Food => "Food", | |
| 99 | + WorkType::Build => "Build", | |
| 100 | + WorkType::Gather => "Gather", | |
| 101 | + WorkType::Haul => "Haul", | |
| 102 | + WorkType::Tame => "Tame", | |
| 103 | + } | |
| 104 | + } | |
| 105 | +} | |
| 106 | + | |
| 107 | +#[derive(Component, Default, Debug, Clone, PartialEq, Eq)] | |
| 108 | +pub struct WorkPriorities { | |
| 109 | + disabled: Vec<WorkType>, | |
| 110 | +} | |
| 111 | + | |
| 112 | +impl WorkPriorities { | |
| 113 | + pub fn allows(&self, work: WorkType) -> bool { | |
| 114 | + !self.disabled.contains(&work) | |
| 115 | + } | |
| 116 | + | |
| 117 | + pub fn toggle(&mut self, work: WorkType) { | |
| 118 | + if self.allows(work) { | |
| 119 | + self.disabled.push(work); | |
| 120 | + } else { | |
| 121 | + self.disabled.retain(|w| *w != work); | |
| 122 | + } | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 78 | 126 | #[derive(Component, Default, Debug, Clone, PartialEq)] |
| 79 | 127 | pub struct CurrentJob(pub Option<Job>); |
| 80 | 128 | |
| @@ -880,6 +928,7 @@ fn choose_jobs( | ||
| 880 | 928 | &Hunger, |
| 881 | 929 | Option<&Family>, |
| 882 | 930 | Has<Child>, |
| 931 | + &WorkPriorities, | |
| 883 | 932 | ), |
| 884 | 933 | With<Colonist>, |
| 885 | 934 | >, |
| @@ -934,8 +983,19 @@ fn choose_jobs( | ||
| 934 | 983 | .map(|(_, _, pos)| pos.0) |
| 935 | 984 | .collect(); |
| 936 | 985 | let hour = hour_of_day(*tick); |
| 937 | - for (entity, tile, mut path, mut job, mut idle, mut carrying, rest, hunger, family, child) in | |
| 938 | - &mut colonists | |
| 986 | + for ( | |
| 987 | + entity, | |
| 988 | + tile, | |
| 989 | + mut path, | |
| 990 | + mut job, | |
| 991 | + mut idle, | |
| 992 | + mut carrying, | |
| 993 | + rest, | |
| 994 | + hunger, | |
| 995 | + family, | |
| 996 | + child, | |
| 997 | + priorities, | |
| 998 | + ) in &mut colonists | |
| 939 | 999 | { |
| 940 | 1000 | let exhausted = rest.0 < EXHAUSTED_REST; |
| 941 | 1001 | let starving = hunger.0 < STARVING && food_to_fetch(&reservations, &food_tiles); |
| @@ -1006,49 +1066,67 @@ fn choose_jobs( | ||
| 1006 | 1066 | planned: &planned, |
| 1007 | 1067 | blueprints: &blueprints, |
| 1008 | 1068 | }; |
| 1009 | - find_expansion(&map, &zones, &reservations, &snapshot, &blueprints) | |
| 1010 | - .or_else(|| find_refuel(&build, tile.0)) | |
| 1011 | - .or_else(|| dark.then(|| find_lighting(&build, tile.0)).flatten()) | |
| 1012 | - .or_else(|| { | |
| 1013 | - let cooking = | |
| 1014 | - wanted(ItemKind::Meal) && raw_food_source(&build, tile.0).is_some(); | |
| 1015 | - cooking | |
| 1016 | - .then(|| { | |
| 1017 | - find_cooking(&build, tile.0).or_else(|| find_campfire(&build, tile.0)) | |
| 1018 | - }) | |
| 1019 | - .flatten() | |
| 1020 | - }) | |
| 1021 | - .or_else(|| find_butchering(&map, &reservations, &snapshot, tile.0)) | |
| 1022 | - .or_else(|| find_work(&map, &reservations, tile.0, |_, k| k.is_food() && wanted(k))) | |
| 1023 | - .or_else(|| { | |
| 1024 | - (!designations.0.is_empty()) | |
| 1025 | - .then(|| { | |
| 1026 | - find_work(&map, &reservations, tile.0, |site, _| { | |
| 1027 | - designations.0.contains(&site) | |
| 1028 | - }) | |
| 1069 | + let cooking = || { | |
| 1070 | + (wanted(ItemKind::Meal) && raw_food_source(&build, tile.0).is_some()) | |
| 1071 | + .then(|| find_cooking(&build, tile.0).or_else(|| find_campfire(&build, tile.0))) | |
| 1072 | + .flatten() | |
| 1073 | + }; | |
| 1074 | + let designated = || { | |
| 1075 | + (!designations.0.is_empty()) | |
| 1076 | + .then(|| { | |
| 1077 | + find_work(&map, &reservations, tile.0, |site, _| { | |
| 1078 | + designations.0.contains(&site) | |
| 1029 | 1079 | }) |
| 1030 | - .flatten() | |
| 1031 | - }) | |
| 1032 | - .or_else(|| { | |
| 1080 | + }) | |
| 1081 | + .flatten() | |
| 1082 | + }; | |
| 1083 | + let candidates: [(WorkType, &dyn Fn() -> Option<Task>); 15] = [ | |
| 1084 | + (WorkType::Haul, &|| { | |
| 1085 | + find_expansion(&map, &zones, &reservations, &snapshot, &blueprints) | |
| 1086 | + }), | |
| 1087 | + (WorkType::Build, &|| find_refuel(&build, tile.0)), | |
| 1088 | + (WorkType::Build, &|| { | |
| 1089 | + dark.then(|| find_lighting(&build, tile.0)).flatten() | |
| 1090 | + }), | |
| 1091 | + (WorkType::Food, &cooking), | |
| 1092 | + (WorkType::Food, &|| { | |
| 1093 | + find_butchering(&map, &reservations, &snapshot, tile.0) | |
| 1094 | + }), | |
| 1095 | + (WorkType::Food, &|| { | |
| 1096 | + find_work(&map, &reservations, tile.0, |_, k| k.is_food() && wanted(k)) | |
| 1097 | + }), | |
| 1098 | + (WorkType::Gather, &designated), | |
| 1099 | + (WorkType::Food, &|| { | |
| 1033 | 1100 | wanted(ItemKind::Vegetables) |
| 1034 | 1101 | .then(|| find_sowing(&map, &zones, &reservations, tile.0)) |
| 1035 | 1102 | .flatten() |
| 1036 | - }) | |
| 1037 | - .or_else(|| find_bed_site(&build, adults, tile.0)) | |
| 1038 | - .or_else(|| find_blueprint_work(&build, tile.0)) | |
| 1039 | - .or_else(|| find_work(&map, &reservations, tile.0, |_, k| wanted(k))) | |
| 1040 | - .or_else(|| { | |
| 1103 | + }), | |
| 1104 | + (WorkType::Build, &|| find_bed_site(&build, adults, tile.0)), | |
| 1105 | + (WorkType::Build, &|| find_blueprint_work(&build, tile.0)), | |
| 1106 | + (WorkType::Gather, &|| { | |
| 1107 | + find_work(&map, &reservations, tile.0, |_, k| wanted(k)) | |
| 1108 | + }), | |
| 1109 | + (WorkType::Food, &|| { | |
| 1041 | 1110 | wanted(ItemKind::Meat) |
| 1042 | 1111 | .then(|| find_hunt(&map, &reservations, &wildlife, tile.0)) |
| 1043 | 1112 | .flatten() |
| 1044 | - }) | |
| 1045 | - .or_else(|| find_sowing(&map, &zones, &reservations, tile.0)) | |
| 1046 | - .or_else(|| { | |
| 1113 | + }), | |
| 1114 | + (WorkType::Food, &|| { | |
| 1115 | + find_sowing(&map, &zones, &reservations, tile.0) | |
| 1116 | + }), | |
| 1117 | + (WorkType::Tame, &|| { | |
| 1047 | 1118 | (!wanted(ItemKind::Meat)) |
| 1048 | 1119 | .then(|| find_taming(&map, &reservations, &wildlife, tile.0)) |
| 1049 | 1120 | .flatten() |
| 1050 | - }) | |
| 1051 | - .or_else(|| find_haul(&map, &zones, &reservations, &snapshot, tile.0)) | |
| 1121 | + }), | |
| 1122 | + (WorkType::Haul, &|| { | |
| 1123 | + find_haul(&map, &zones, &reservations, &snapshot, tile.0) | |
| 1124 | + }), | |
| 1125 | + ]; | |
| 1126 | + candidates | |
| 1127 | + .iter() | |
| 1128 | + .filter(|(work, _)| priorities.allows(*work)) | |
| 1129 | + .find_map(|(_, find)| find()) | |
| 1052 | 1130 | }; |
| 1053 | 1131 | if let Some(task) = task { |
| 1054 | 1132 | if let Task::Work { site, .. } = task |
src/jobs/tests.rs +19 -0
| @@ -1307,3 +1307,22 @@ fn hunters_slaughter_spare_tame_animals_first() { | ||
| 1307 | 1307 | let herd = [wild, tame(0, 10), tame(1, 11), tame(2, 12)]; |
| 1308 | 1308 | assert_eq!(pick(&herd), tame(0, 10).0); |
| 1309 | 1309 | } |
| 1310 | + | |
| 1311 | +#[test] | |
| 1312 | +fn colonists_skip_work_types_they_are_not_assigned() { | |
| 1313 | + let mut app = test_app(map_with_trees(&[UVec2::new(4, 2)])); | |
| 1314 | + set_goals(&mut app, &[(ItemKind::Wood, 50)]); | |
| 1315 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 2)); | |
| 1316 | + app.world_mut() | |
| 1317 | + .get_mut::<WorkPriorities>(colonist) | |
| 1318 | + .unwrap() | |
| 1319 | + .toggle(WorkType::Gather); | |
| 1320 | + run_ticks(&mut app, 60 * 10); | |
| 1321 | + assert_eq!(items_of(&mut app, ItemKind::Wood), 0); | |
| 1322 | + app.world_mut() | |
| 1323 | + .get_mut::<WorkPriorities>(colonist) | |
| 1324 | + .unwrap() | |
| 1325 | + .toggle(WorkType::Gather); | |
| 1326 | + run_ticks(&mut app, 60 * 15); | |
| 1327 | + assert!(items_of(&mut app, ItemKind::Wood) > 0); | |
| 1328 | +} | |
src/main.rs +2 -0
| @@ -14,6 +14,7 @@ mod light; | ||
| 14 | 14 | mod mood; |
| 15 | 15 | mod needs; |
| 16 | 16 | mod noise; |
| 17 | +mod panel; | |
| 17 | 18 | mod pathfinding; |
| 18 | 19 | mod paths; |
| 19 | 20 | mod plants; |
| @@ -70,6 +71,7 @@ fn main() { | ||
| 70 | 71 | family::FamilyPlugin, |
| 71 | 72 | designations::DesignationsPlugin, |
| 72 | 73 | mood::MoodPlugin, |
| 74 | + panel::PanelPlugin, | |
| 73 | 75 | plants::PlantsPlugin, |
| 74 | 76 | selection::SelectionPlugin, |
| 75 | 77 | zones::ZonesPlugin, |
src/panel.rs +235 -0
| @@ -0,0 +1,235 @@ | ||
| 1 | +use bevy::{ | |
| 2 | + picking::hover::Hovered, | |
| 3 | + prelude::*, | |
| 4 | + ui_widgets::{Activate, ActivateOnPress, Button}, | |
| 5 | +}; | |
| 6 | + | |
| 7 | +use crate::{ | |
| 8 | + colonist::Colonist, | |
| 9 | + family::Child, | |
| 10 | + jobs::{CurrentJob, WorkPriorities, WorkType}, | |
| 11 | + mood::Mood, | |
| 12 | + selection::Selection, | |
| 13 | + ui::{BUTTON_ACTIVE_COLOR, BUTTON_COLOR, BUTTON_HOVER_COLOR, HideableUi}, | |
| 14 | +}; | |
| 15 | + | |
| 16 | +pub struct PanelPlugin; | |
| 17 | + | |
| 18 | +impl Plugin for PanelPlugin { | |
| 19 | + fn build(&self, app: &mut App) { | |
| 20 | + app.add_systems(Startup, spawn_panel) | |
| 21 | + .add_systems( | |
| 22 | + Update, | |
| 23 | + (rebuild_roster, show_work_toggles, style_panel_buttons), | |
| 24 | + ) | |
| 25 | + .add_observer(on_panel_activated); | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +const PANEL_COLOR: Color = Color::srgba(0.08, 0.08, 0.1, 0.8); | |
| 30 | +const DISABLED_COLOR: Color = Color::srgba(0.35, 0.12, 0.12, 0.9); | |
| 31 | + | |
| 32 | +#[derive(Component)] | |
| 33 | +struct Roster; | |
| 34 | + | |
| 35 | +#[derive(Component)] | |
| 36 | +struct WorkRow; | |
| 37 | + | |
| 38 | +#[derive(Component, Clone, Copy)] | |
| 39 | +struct ColonistButton(Entity); | |
| 40 | + | |
| 41 | +#[derive(Component, Clone, Copy)] | |
| 42 | +struct WorkToggle(WorkType); | |
| 43 | + | |
| 44 | +fn small_button(label: String) -> impl Bundle { | |
| 45 | + ( | |
| 46 | + Button, | |
| 47 | + ActivateOnPress, | |
| 48 | + Hovered::default(), | |
| 49 | + Node { | |
| 50 | + padding: UiRect::axes(px(5), px(2)), | |
| 51 | + border_radius: BorderRadius::all(px(3)), | |
| 52 | + ..default() | |
| 53 | + }, | |
| 54 | + BackgroundColor(BUTTON_COLOR), | |
| 55 | + children![(Text::new(label), TextFont::from_font_size(11.0))], | |
| 56 | + ) | |
| 57 | +} | |
| 58 | + | |
| 59 | +fn spawn_panel(mut commands: Commands) { | |
| 60 | + let panel = commands | |
| 61 | + .spawn(( | |
| 62 | + HideableUi, | |
| 63 | + Node { | |
| 64 | + position_type: PositionType::Absolute, | |
| 65 | + top: px(8), | |
| 66 | + right: px(8), | |
| 67 | + width: px(170), | |
| 68 | + flex_direction: FlexDirection::Column, | |
| 69 | + row_gap: px(4), | |
| 70 | + padding: UiRect::all(px(6)), | |
| 71 | + border_radius: BorderRadius::all(px(6)), | |
| 72 | + ..default() | |
| 73 | + }, | |
| 74 | + BackgroundColor(PANEL_COLOR), | |
| 75 | + children![(Text::new("Colonists"), TextFont::from_font_size(12.0))], | |
| 76 | + )) | |
| 77 | + .id(); | |
| 78 | + commands.spawn(( | |
| 79 | + Roster, | |
| 80 | + Node { | |
| 81 | + flex_wrap: FlexWrap::Wrap, | |
| 82 | + column_gap: px(3), | |
| 83 | + row_gap: px(3), | |
| 84 | + ..default() | |
| 85 | + }, | |
| 86 | + ChildOf(panel), | |
| 87 | + )); | |
| 88 | + let row = commands | |
| 89 | + .spawn(( | |
| 90 | + WorkRow, | |
| 91 | + Node { | |
| 92 | + flex_wrap: FlexWrap::Wrap, | |
| 93 | + column_gap: px(3), | |
| 94 | + row_gap: px(3), | |
| 95 | + display: Display::None, | |
| 96 | + ..default() | |
| 97 | + }, | |
| 98 | + ChildOf(panel), | |
| 99 | + )) | |
| 100 | + .id(); | |
| 101 | + for work in WorkType::ALL { | |
| 102 | + commands.spawn(( | |
| 103 | + small_button(work.label().to_string()), | |
| 104 | + WorkToggle(work), | |
| 105 | + ChildOf(row), | |
| 106 | + )); | |
| 107 | + } | |
| 108 | +} | |
| 109 | + | |
| 110 | +pub fn roster_label(name: &str, child: bool, mood: Option<f32>) -> String { | |
| 111 | + let face = match mood { | |
| 112 | + Some(m) if m < 0.3 => " :(", | |
| 113 | + Some(m) if m > 0.7 => " :)", | |
| 114 | + _ => "", | |
| 115 | + }; | |
| 116 | + format!("{name}{}{face}", if child { " (child)" } else { "" }) | |
| 117 | +} | |
| 118 | + | |
| 119 | +#[allow(clippy::type_complexity)] | |
| 120 | +fn rebuild_roster( | |
| 121 | + mut commands: Commands, | |
| 122 | + roster: Single<Entity, With<Roster>>, | |
| 123 | + colonists: Query<(Entity, &Colonist, Has<Child>, Option<&Mood>), With<CurrentJob>>, | |
| 124 | + mut shown: Local<Vec<(Entity, String)>>, | |
| 125 | +) { | |
| 126 | + let mut wanted: Vec<(Entity, String)> = colonists | |
| 127 | + .iter() | |
| 128 | + .map(|(e, c, child, mood)| (e, roster_label(&c.name, child, mood.map(|m| m.0)))) | |
| 129 | + .collect(); | |
| 130 | + wanted.sort_by_key(|(e, _)| *e); | |
| 131 | + if *shown == wanted { | |
| 132 | + return; | |
| 133 | + } | |
| 134 | + commands.entity(*roster).despawn_related::<Children>(); | |
| 135 | + for (entity, label) in &wanted { | |
| 136 | + commands.spawn(( | |
| 137 | + small_button(label.clone()), | |
| 138 | + ColonistButton(*entity), | |
| 139 | + ChildOf(*roster), | |
| 140 | + )); | |
| 141 | + } | |
| 142 | + *shown = wanted; | |
| 143 | +} | |
| 144 | + | |
| 145 | +fn show_work_toggles( | |
| 146 | + selection: Res<Selection>, | |
| 147 | + adults: Query<(), (With<WorkPriorities>, Without<Child>)>, | |
| 148 | + mut row: Single<&mut Node, With<WorkRow>>, | |
| 149 | +) { | |
| 150 | + let visible = matches!(*selection, Selection::Colonist(e) if adults.contains(e)); | |
| 151 | + let display = if visible { | |
| 152 | + Display::Flex | |
| 153 | + } else { | |
| 154 | + Display::None | |
| 155 | + }; | |
| 156 | + if row.display != display { | |
| 157 | + row.display = display; | |
| 158 | + } | |
| 159 | +} | |
| 160 | + | |
| 161 | +#[allow(clippy::type_complexity)] | |
| 162 | +fn style_panel_buttons( | |
| 163 | + selection: Res<Selection>, | |
| 164 | + priorities: Query<&WorkPriorities>, | |
| 165 | + mut buttons: Query<( | |
| 166 | + &Hovered, | |
| 167 | + Option<&WorkToggle>, | |
| 168 | + Option<&ColonistButton>, | |
| 169 | + &mut BackgroundColor, | |
| 170 | + )>, | |
| 171 | +) { | |
| 172 | + let selected = match *selection { | |
| 173 | + Selection::Colonist(e) => Some(e), | |
| 174 | + _ => None, | |
| 175 | + }; | |
| 176 | + for (hovered, toggle, colonist, mut color) in &mut buttons { | |
| 177 | + let new = match (toggle, colonist) { | |
| 178 | + (Some(toggle), _) => { | |
| 179 | + let allowed = selected | |
| 180 | + .and_then(|e| priorities.get(e).ok()) | |
| 181 | + .is_none_or(|p| p.allows(toggle.0)); | |
| 182 | + if allowed { | |
| 183 | + BUTTON_ACTIVE_COLOR | |
| 184 | + } else { | |
| 185 | + DISABLED_COLOR | |
| 186 | + } | |
| 187 | + } | |
| 188 | + (None, Some(c)) if Some(c.0) == selected => BUTTON_ACTIVE_COLOR, | |
| 189 | + (None, Some(_)) if hovered.get() => BUTTON_HOVER_COLOR, | |
| 190 | + (None, Some(_)) => BUTTON_COLOR, | |
| 191 | + (None, None) => continue, | |
| 192 | + }; | |
| 193 | + color.set_if_neq(BackgroundColor(new)); | |
| 194 | + } | |
| 195 | +} | |
| 196 | + | |
| 197 | +fn on_panel_activated( | |
| 198 | + activate: On<Activate>, | |
| 199 | + toggles: Query<&WorkToggle>, | |
| 200 | + colonist_buttons: Query<&ColonistButton>, | |
| 201 | + mut selection: ResMut<Selection>, | |
| 202 | + mut priorities: Query<&mut WorkPriorities>, | |
| 203 | +) { | |
| 204 | + if let Ok(button) = colonist_buttons.get(activate.entity) { | |
| 205 | + *selection = Selection::Colonist(button.0); | |
| 206 | + } else if let Ok(toggle) = toggles.get(activate.entity) | |
| 207 | + && let Selection::Colonist(entity) = *selection | |
| 208 | + && let Ok(mut work) = priorities.get_mut(entity) | |
| 209 | + { | |
| 210 | + work.toggle(toggle.0); | |
| 211 | + } | |
| 212 | +} | |
| 213 | + | |
| 214 | +#[cfg(test)] | |
| 215 | +mod tests { | |
| 216 | + use super::*; | |
| 217 | + | |
| 218 | + #[test] | |
| 219 | + fn roster_labels_show_children_and_strong_moods() { | |
| 220 | + assert_eq!(roster_label("Ada", false, Some(0.5)), "Ada"); | |
| 221 | + assert_eq!(roster_label("Ivo", true, Some(0.8)), "Ivo (child) :)"); | |
| 222 | + assert_eq!(roster_label("Bram", false, Some(0.1)), "Bram :("); | |
| 223 | + } | |
| 224 | + | |
| 225 | + #[test] | |
| 226 | + fn work_types_can_be_switched_off_and_on() { | |
| 227 | + let mut work = WorkPriorities::default(); | |
| 228 | + assert!(work.allows(WorkType::Haul)); | |
| 229 | + work.toggle(WorkType::Haul); | |
| 230 | + assert!(!work.allows(WorkType::Haul)); | |
| 231 | + assert!(work.allows(WorkType::Food)); | |
| 232 | + work.toggle(WorkType::Haul); | |
| 233 | + assert!(work.allows(WorkType::Haul)); | |
| 234 | + } | |
| 235 | +} | |
src/selection.rs +1 -0
| @@ -236,6 +236,7 @@ fn spawn_tile_info(mut commands: Commands) { | ||
| 236 | 236 | position_type: PositionType::Absolute, |
| 237 | 237 | top: px(8), |
| 238 | 238 | left: px(8), |
| 239 | + max_width: percent(70), | |
| 239 | 240 | padding: UiRect::all(px(6)), |
| 240 | 241 | ..default() |
| 241 | 242 | }, |
src/ui.rs +3 -3
| @@ -53,9 +53,9 @@ const TOOLBAR: [Action; 14] = [ | ||
| 53 | 53 | Action::ToggleUi, |
| 54 | 54 | ]; |
| 55 | 55 | |
| 56 | -const BUTTON_COLOR: Color = Color::srgba(0.12, 0.12, 0.14, 0.9); | |
| 57 | -const BUTTON_HOVER_COLOR: Color = Color::srgba(0.22, 0.22, 0.26, 0.95); | |
| 58 | -const BUTTON_ACTIVE_COLOR: Color = Color::srgba(0.2, 0.45, 0.75, 0.95); | |
| 56 | +pub const BUTTON_COLOR: Color = Color::srgba(0.12, 0.12, 0.14, 0.9); | |
| 57 | +pub const BUTTON_HOVER_COLOR: Color = Color::srgba(0.22, 0.22, 0.26, 0.95); | |
| 58 | +pub const BUTTON_ACTIVE_COLOR: Color = Color::srgba(0.2, 0.45, 0.75, 0.95); | |
| 59 | 59 | const BUTTON_PRESSED_COLOR: Color = Color::srgba(0.3, 0.6, 0.9, 1.0); |
| 60 | 60 | const KEY_HINT_COLOR: Color = Color::srgb(0.65, 0.65, 0.7); |
| 61 | 61 | |
tasks.md +1 -2
| @@ -32,12 +32,11 @@ | ||
| 32 | 32 | - [x] Walls cast shadows, roofs keep daylight out; deconstruction (build tool mode, refunds half) |
| 33 | 33 | - [x] Animals flee, ranged hunting, carcasses butchered into meat, taming with breeding herds and slaughter of spare tame animals |
| 34 | 34 | - [x] Mood (needs, meals, partner, home, sickness, grief), food poisoning from raw food, unhappy colonists leave, ageing and death of old age |
| 35 | +- [x] Colonist panel: roster with mood faces (click to select) and per-colonist work type toggles | |
| 35 | 36 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 36 | 37 | |
| 37 | 38 | ## Planned |
| 38 | -- [ ] Per-colonist work priorities (busy colonies leave low-priority work such as hauling undone) | |
| 39 | 39 | - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority |
| 40 | 40 | - [ ] UI to view/override learned stock targets |
| 41 | 41 | - [ ] Sim performance: per-tile scans in `BuildContext::buildable` and site searches; wasm ticks still ~1 ms each with a full colony |
| 42 | 42 | - [ ] Smoother hill shading at close zoom (per-tile shading looks blocky) |
| 43 | -- [ ] Colonist panel / better info UI (info panel is plain text) | |