Group the roster by household in a scrollable list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 18:36 UTC
commit
e47049d32a2517681255bb24641215519b0a0594
parent
b092f10569
tree
browse at this commit

4 files changed +213 -15

CLAUDE.md +1 -1

@@ -45,7 +45,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
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): homes of extinct families move to `Homes.vacant` (serde default; `all()`, `is_home()` cover owned + vacant; `owner()` owned only, so vacant beds count as communal for sleeping); `abandoned_home` adopts enclosed HOME_INTERIOR rooms around unowned beds as vacant (old saves); couples without a home take the vacant home nearest the colony before a new one is planned. Floods `reachable` from colony home (if walkable) and drops blueprints with no reachable tile in their 3×3 (sealed pockets inflated goals forever). Per home: `door_repair` (no door/planned door whose `outward` tile is reachable → Door on an empty door site, else Deconstruct the best wall; runs even with blueprints pending), else roofs (`roofs_needed`), hearth at `Home::hearth_tile` (hi−1), statue + `BuildKind::Floor` on unfloored interior tiles (`floored`; doesn't overwrite other blueprints) for owned built homes. `surplus_beds`: reachable communal beds beyond homeless adults → Deconstruct (nearest kept). Households (`Household{partners, children}` per family; `adults()` = max(partners,1)) size homes via `desired_interior` (solo 3×2, couple 4×3, +1 child 5×3, 2+ children 5×4). Couples get a vacant home (preferring ones big enough) or `plan_sized`; otherwise one solo adult per pass (no Partner, not Child, family None / parents' family / no home) gets a new `Family` (Commands) and a vacant home ≤ 4×3 or a planned solo room. Owned built homes smaller than desired get `HomeContext::expansion`: grow one side (width first, then height) where the new ring may reuse walls and the new strip is free; opened old wall/door tiles get Deconstruct, missing new ring tiles Wall; Homes entry updated at once. Owned built homes get Bed blueprints up to `adults()` at `bed_tiles_for`. New homes via `HomeContext::plan_sized`: BFS from colony home (center ≥ HOME_MIN_DISTANCE), first HOME_CANDIDATES fits, pick the one sharing most walls (`shared_walls`: ring tiles may be existing walls or planned wall blueprints, else open and not in a room; interior open and not in a room; some door site must open onto walkable, non-room ground). `Home::blueprints(map, toward)` plans only missing ring walls, door via `door_site` (non-wall, walkable outward), beds. Adoption: any rectangular room (`home_of_room`, interior ≥ MIN_HOME_INTERIOR 3×2) with ≥2 beds not already a home → vacant. `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 (owned or vacant). 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 47 - Storage rules (zones.rs): `Zones.rules` per stockpile tile `StorageRule{refused: Vec<Stock>, priority: StoragePriority::{Low, Normal, High}}` (absent = default), `accepts(tile, kind)`, `update_region_rule` applies to the 4-connected stockpile region; removing tiles drops rules. `find_storage` tries priorities High→Normal→Low, nearest accepting free tile with room; `find_haul` treats items as loose when their tile doesn't accept them or a higher-priority accepting tile with room exists (`storage_candidates`), so stock moves up the priorities and settles. `ColonyGoals` overrides (`adjust` ±, `reset`, `is_overridden`) win over learned goals.
48 -- `src/panel.rs` top-right colonist panel (also: stockpile section with accept toggles + priority cycle when a stockpile tile is selected; collapsible Stock targets with -/+/auto per Stock): 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.
48 +- `src/panel.rs` top-right colonist panel. Roster = scrollable column (max ROSTER_MAX_HEIGHT, `Overflow::scroll_y`, `scroll_roster` moves `ScrollPosition` while `Hovered`; camera `scroll_zoom` ignores the wheel over UI) of `HouseholdButton` headers from `household_rows` (grouped by Family, None → "Others"; label = adult names, count, " :(" / " hurt" flags) that toggle `ExpandedHouseholds`; expanded rows list `ColonistButton`s; selecting a colonist expands their household. (Also: stockpile section with accept toggles + priority cycle when a stockpile tile is selected; collapsible Stock targets with -/+/auto per Stock): 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.
49 49 - `src/climate.rs` seasons/temperature. Year = YEAR_DAYS 12 (4 seasons × SEASON_DAYS 3, starts Spring; independent of ageing's day=year). `outdoor_temperature(tick)` = seasonal cosine (MEAN 12, swing 14, coldest at COLDEST_YEAR_FRACTION) + day/night swing (coldest 04:00); `daily_mean(day)`, `growing_day` (mean ≥ 8), `sowing_season` (next SOW_AHEAD_DAYS growing; gates find_sowing), `cold_days_ahead` (non-growing days in the next FORESIGHT_DAYS 6 → `ColonyGoals::set_cold_days`, food/wood buffer days = max(normal, cold days)). `growth_factor(temp)` scales plant growth; Crop with growth < 1 dies below FROST_TEMP. `spoil_factor` slows spoilage in cold. `tile_temperature(map, outdoor, tile, fires)`: roof pulls toward 16 by ROOF_INSULATION 0.7, fires add `StructureKind::heat()` with light-radius falloff, blocked by walls. `Warmth(f32)` on colonists (required), `update_warmth` every 60 ticks; `cold()` < COLD_TEMP 5 → hunger ×1.5, rest recovery ×0.5, mood −0.15. choose_jobs: cold colonists MoveTo `find_warm_spot` (tiles ≥ WARM_TEMP near fires) before work; `chilly_soon` triggers find_lighting's campfire (torches only when dark). `snow_cover(tick)` → `LightMap::with_snow` (8 steps, triggers recompute) → tilemap `Layer::Snow` (z 0.2, white alpha overlay on unroofed land). Info panel shows season/day/temp (font has no ° glyph).
50 50 - Crafting (jobs): `Task::Craft{recipe: Recipe::{Meal, Tools}, pickup, station, progress}` (was Cook): recipe gives station kind (Campfire/Workbench), accepted input, input count, work, output; `find_crafting`, `input_source`. Tools: `WorkType::Craft` candidate when Tools wanted and TOOL_IRON iron available → craft at a workbench, else `find_workbench` (one, near home, autonomous Structure build like campfire/bed). `Task::Equip{pickup}` (before autonomous candidates) for adults without `Tool{wear}`; equip records Tools use (learned demand); `tool_rate` ×1.5 work; wear per working tick, removed at TOOL_LIFE_TICKS (2 days). Iron has min goal 6. `StructureKind::Statue` (5 gold): plan_homes adds one blueprint at `Home::statue_tile` once a home is built → gold demand via planned_needs; mood +0.1 (`beauty`) when the family home has one; +0.05 (`floored`) when its interior is all WoodFloor.
51 51 - `src/save.rs` save/load. Action::Save/Load (F5/F9). `snapshot(world) -> SaveGame` (serde_json; SAVE_VERSION): seed, tick, rng, `WorldMap::layers()` (`MapLayers`: RLE terrain/plants/walls/roofs as u8 + sparse growth/wear; heights regenerated by `generate(size, seed)` in `restore_map`), `Zones::save` (`ZonesSave`, UVec2-keyed maps become Vecs — serde_json needs string keys), blueprints Vec, Homes, Designations, ColonyGoals, log, losses, colonists (`ColonistSave`, partner as index), items, structures (+fuel), animals. Not saved: jobs, paths, reservations, Affinity, idle timers (colonists restart idle). Storage: web localStorage key `simulator-save` (~150 KB), native `simulator-save.json` in cwd. Load: `start_load` (PreUpdate, exclusive) parses, inserts WorldSeed + restored map, writes WorldGenerated (all reset systems run in Update), stores `PendingLoad`; `finish_load` (PostUpdate) despawns colonists/animals/items/structures and `apply`s the save via SystemState spawn helpers (`spawn_colonist`, `spawn_stack`, `spawn_structure` → Entity, `spawn_animal`). New persistent state = add to SaveGame + snapshot + apply (+ serde derives; bevy `serialize` feature for glam).

src/camera.rs +2 -1

@@ -152,6 +152,7 @@ fn drag_pan(
152 152 }
153 153
154 154 fn scroll_zoom(
155 + over_ui: Res<crate::ui::PointerOverUi>,
155 156 scroll: Res<AccumulatedMouseScroll>,
156 157 pixels_per_line: Res<MouseScrollPixelsPerLine>,
157 158 window: Single<&Window, With<PrimaryWindow>>,
@@ -164,7 +165,7 @@ fn scroll_zoom(
164 165 )>,
165 166 ) {
166 167 let lines = scroll.to_lines(&pixels_per_line).delta.y;
167 - if lines == 0.0 {
168 + if lines == 0.0 || over_ui.0 {
168 169 return;
169 170 }
170 171 let (camera, global, mut transform, mut projection, controller) = camera.into_inner();

src/panel.rs +209 -13

@@ -7,10 +7,11 @@ use bevy::{
7 7 use crate::{
8 8 colonist::Colonist,
9 9 economy::{ColonyGoals, Stock},
10 - family::Child,
10 + family::{Child, Family, Partner},
11 11 jobs::{CurrentJob, WorkPriorities, WorkType},
12 12 mood::Mood,
13 13 selection::Selection,
14 + threats::{HURT, Health},
14 15 ui::{BUTTON_ACTIVE_COLOR, BUTTON_COLOR, BUTTON_HOVER_COLOR, HideableUi},
15 16 zones::Zones,
16 17 };
@@ -23,13 +24,16 @@ impl Plugin for PanelPlugin {
23 24 .add_systems(
24 25 Update,
25 26 (
27 + expand_selected_household,
26 28 rebuild_roster,
29 + scroll_roster,
27 30 show_work_toggles,
28 31 style_panel_buttons,
29 32 show_storage,
30 33 show_targets,
31 34 ),
32 35 )
36 + .init_resource::<ExpandedHouseholds>()
33 37 .add_observer(on_panel_activated);
34 38 }
35 39 }
@@ -47,6 +51,15 @@ struct WorkRow;
47 51 struct ColonistButton(Entity);
48 52
49 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)]
50 63 struct WorkToggle(WorkType);
51 64
52 65 #[derive(Component)]
@@ -124,10 +137,13 @@ fn spawn_panel(mut commands: Commands) {
124 137 .id();
125 138 commands.spawn((
126 139 Roster,
140 + Hovered::default(),
141 + ScrollPosition::default(),
127 142 Node {
128 - flex_wrap: FlexWrap::Wrap,
129 - column_gap: px(3),
143 + flex_direction: FlexDirection::Column,
130 144 row_gap: px(3),
145 + max_height: px(ROSTER_MAX_HEIGHT),
146 + overflow: Overflow::scroll_y(),
131 147 ..default()
132 148 },
133 149 ChildOf(panel),
@@ -293,32 +309,177 @@ pub fn roster_label(name: &str, child: bool, mood: Option<f32>) -> String {
293 309 format!("{name}{}{face}", if child { " (child)" } else { "" })
294 310 }
295 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 +
296 378 #[allow(clippy::type_complexity)]
297 379 fn rebuild_roster(
298 380 mut commands: Commands,
299 381 roster: Single<Entity, With<Roster>>,
300 - colonists: Query<(Entity, &Colonist, Has<Child>, Option<&Mood>), With<CurrentJob>>,
301 - mut shown: Local<Vec<(Entity, String)>>,
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)>>,
302 396 ) {
303 - let mut wanted: Vec<(Entity, String)> = colonists
397 + let members: Vec<(Option<u32>, Member)> = colonists
304 398 .iter()
305 - .map(|(e, c, child, mood)| (e, roster_label(&c.name, child, mood.map(|m| m.0))))
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 + })
306 417 .collect();
307 - wanted.sort_by_key(|(e, _)| *e);
308 418 if *shown == wanted {
309 419 return;
310 420 }
311 421 commands.entity(*roster).despawn_related::<Children>();
312 - for (entity, label) in &wanted {
422 + for (row, open) in &wanted {
423 + let marker = if *open { "-" } else { "+" };
313 424 commands.spawn((
314 - small_button(label.clone()),
315 - ColonistButton(*entity),
425 + small_button(format!("{marker} {}", row.label)),
426 + HouseholdButton(row.family),
316 427 ChildOf(*roster),
317 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 + }
318 448 }
319 449 *shown = wanted;
320 450 }
321 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 +
322 483 fn show_work_toggles(
323 484 selection: Res<Selection>,
324 485 adults: Query<(), (With<WorkPriorities>, Without<Child>)>,
@@ -343,6 +504,7 @@ fn style_panel_buttons(
343 504 &Hovered,
344 505 Option<&WorkToggle>,
345 506 Option<&ColonistButton>,
507 + Has<HouseholdButton>,
346 508 &mut BackgroundColor,
347 509 )>,
348 510 ) {
@@ -350,7 +512,7 @@ fn style_panel_buttons(
350 512 Selection::Colonist(e) => Some(e),
351 513 _ => None,
352 514 };
353 - for (hovered, toggle, colonist, mut color) in &mut buttons {
515 + for (hovered, toggle, colonist, household, mut color) in &mut buttons {
354 516 let new = match (toggle, colonist) {
355 517 (Some(toggle), _) => {
356 518 let allowed = selected
@@ -365,6 +527,8 @@ fn style_panel_buttons(
365 527 (None, Some(c)) if Some(c.0) == selected => BUTTON_ACTIVE_COLOR,
366 528 (None, Some(_)) if hovered.get() => BUTTON_HOVER_COLOR,
367 529 (None, Some(_)) => BUTTON_COLOR,
530 + (None, None) if household && hovered.get() => BUTTON_HOVER_COLOR,
531 + (None, None) if household => BUTTON_COLOR,
368 532 (None, None) => continue,
369 533 };
370 534 color.set_if_neq(BackgroundColor(new));
@@ -384,8 +548,14 @@ fn on_panel_activated(
384 548 mut zones: ResMut<Zones>,
385 549 mut goals: ResMut<ColonyGoals>,
386 550 mut priorities: Query<&mut WorkPriorities>,
551 + households: (Query<&HouseholdButton>, ResMut<ExpandedHouseholds>),
387 552 ) {
388 - if targets_toggle.contains(activate.entity) {
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) {
389 559 targets.display = match targets.display {
390 560 Display::None => Display::Flex,
391 561 _ => Display::None,
@@ -434,4 +604,30 @@ mod tests {
434 604 work.toggle(WorkType::Haul);
435 605 assert!(work.allows(WorkType::Haul));
436 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 + }
437 633 }

tasks.md +1 -0

@@ -57,6 +57,7 @@
57 57 - [x] Colonists keep busy paths (paved or heavily worn) free when planning buildings
58 58 - [x] Optimization: wolves give up on unreachable prey, cached warm spots, cheaper heat checks
59 59 - [x] Bridges: colonists bridge busy wading spots and water on much shorter (or otherwise impossible) routes, over deep water from the shore outwards; Build tool Bridge mode
60 +- [x] Colonist panel for big colonies: households as collapsible, flagged rows in a scrollable list
60 61 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
61 62
62 63 ## Planned