Detect rooms dynamically, share walls, remove bad doors, add room quality
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 15:56 UTC
- commit
- 05bc45bc6ac5c1d77e0182766c92f7adb649018c
- parent
- 76019b8ad5
- tree
- browse at this commit
11 files changed +534 -121
| CLAUDE.md | +2 -1 | |
| src/building.rs | +6 -1 | |
| src/family.rs | +90 -78 | |
| src/jobs/mod.rs | +11 -2 | |
| src/jobs/tests.rs | +1 -0 | |
| src/main.rs | +2 -1 | |
| src/mood.rs | +12 -22 | |
| src/pathfinding.rs | +7 -13 | |
| src/rooms.rs | added | +380 -0 |
| src/selection.rs | +22 -3 | |
| tasks.md | +1 -0 |
CLAUDE.md +2 -1
| @@ -42,7 +42,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 42 | 42 | - `src/paths.rs` traffic wear. `follow_path` adds 1 wear when a colonist (not an animal) enters a tile. `update_paths` (hourly): wear × WEAR_KEPT_PER_HOUR (0.98); Grass/Soil/Sand with wear ≥ PATH_WEAR (8), no plant, not farm → `Terrain::Path`; Path with wear < REGROW_WEAR → Grass. `plan_paving` (hourly) keeps up to PAVE_BATCH `BuildKind::Pave` blueprints on the busiest Path tiles with wear ≥ PAVE_WEAR (20); built with 1 stone → `Terrain::Paved`. Path cost 80, Paved 60 (= CHEAPEST_MOVE_COST, which A*'s heuristic uses; a test enforces no terrain is cheaper), so colonists prefer and reinforce them. Hover shows wear. Tuned from observed traffic of 3 colonists: busiest tiles reach ~15–30 wear by day 3–4. |
| 43 | 43 | - `src/economy.rs` learned stock targets. `Stock::{Wood, Stone, Iron, Gold, RawFood, Meals}` groups item kinds (raw foods are one group). `ColonyGoals` keeps `use_per_day` per stock (EMA, ~24 h memory; seeded with guesses), `record_use(kind, n)` is called by jobs for real consumption (eating, cooking input, build material — not spoilage), `learn_needs` (hourly) updates rates and goals = clamp(rate × buffer days (2 materials, 1.5 food), min, 300) + material for pending blueprints (`planned_needs`). `wanted(kind, stocks, on_the_way)` compares the whole group. Stock counts only ground items `in_colony` (Chebyshev ≤ `COLONY_RADIUS` 60 of home; colonist searches cover it, tested) plus carried ones — far stray stacks used to satisfy goals while unreachable (jobs, info panel and arrivals all use this). `ColonyGoals::fixed` for tests. |
| 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 = usize::MAX, i.e. uncapped; stored nutrition ≥ 1 per colonist, a free bed or enough wood for one) and a 60% roll, a newcomer (`spawn_colonist`, `free_name_index`; `colonist_name` numbers repeats past the 26 NAMES, e.g. "Ada 2") appears at an `edge_tile` with an unordered MoveTo home. |
| 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). New homes via `HomeContext::plan` (BFS from colony home, center ≥ HOME_MIN_DISTANCE, core open + 1-tile belt unclaimed AND walkable — homes used to be packed wall to wall with doors facing a neighbour's wall) → 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 (owned or vacant). Selection text shows partner / child days left. | |
| 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). New homes via `HomeContext::plan`: 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 | 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. |
| @@ -55,6 +55,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 55 | 55 | - Arrivals get a real `Path` home (up to ARRIVAL_ATTEMPTS edge tiles tried); a bare MoveTo job without a path finishes at once, which used to strand newcomers at the map edge. |
| 56 | 56 | - Hearths: `StructureKind::Hearth` (6 stone, fuel 24 h, `refuel_cost` 4 wood vs build cost, light 3.5, heat 22 → warms the home), planned in every owned built home. `Recipe::stations()` (Meal: Campfire or Hearth; Tools: Workbench); `find_crafting(ctx, from, recipe, own_home)` only allows a Hearth inside the cook's own home and prefers it. `find_refuel` covers hearths anywhere. `find_haul` leaves Meal stacks on home tiles (`Homes::is_home`) so family meals stay home. |
| 57 | 57 | - Growth: `prosperous` and births need GROWTH_FOOD_DAYS 3 × colonists of stored nutrition (1× let hearth cooking boom births to 120 then famine). `find_food` takes `ColonyReach` (nearest by distance in reach); a colonist whose food search fails skips food searches and starvation urgency for FOOD_RETRY_TICKS 60 (`GatherMemo.food` per entity) — starving colonists re-searching every tick made a famine cost ~4.8 ms/tick. |
| 58 | +- `src/rooms.rs` dynamic rooms. `detect_rooms(map, doors)`: 4-connected regions bounded by walls/doors/impassable terrain that don't touch the map edge, are MIN_ROOM_TILES 4..=MAX_ROOM_TILES 120 and touch ≥1 door. `Rooms` resource (`at(tile)`, `contains`, `Rooms::detect` for tests) recomputed by `update_rooms` every 60 ticks when (`shape_version`, door tiles) change; quality every 300 ticks: `room_quality` = 0.1 + 0.2·space(n/16) + 0.25·floor share + 0.15·roof share + 0.2 statue + 0.1 hearth − 0.2 if crowded (beds·3 > tiles). `roof_rooms`: rooms with a bed or hearth get Roof blueprints (replaces home roofs_needed). `remove_bad_doors`: doors with ≥3 blocked orthogonal sides (`bad_door`) → Deconstruct. Hover shows room size/quality. Mood: `ROOM_MOOD` 0.3 × quality of the family home's room (replaces statue/floor bonuses). | |
| 58 | 59 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 59 | 60 | |
| 60 | 61 | ## Performance notes |
src/building.rs +6 -1
| @@ -8,6 +8,7 @@ use crate::{ | ||
| 8 | 8 | colonist::Colonist, |
| 9 | 9 | family::Homes, |
| 10 | 10 | items::{Item, ItemKind}, |
| 11 | + rooms::Rooms, | |
| 11 | 12 | selection::{Cursor, update_cursor}, |
| 12 | 13 | sim::{SimSystems, SimTick}, |
| 13 | 14 | structures::{Structure, StructureKind}, |
| @@ -268,6 +269,7 @@ pub fn plan_shelter( | ||
| 268 | 269 | items: Query<&TilePos, With<Item>>, |
| 269 | 270 | mut blueprints: ResMut<Blueprints>, |
| 270 | 271 | homes: Res<Homes>, |
| 272 | + rooms: Res<Rooms>, | |
| 271 | 273 | ) { |
| 272 | 274 | let shelter_pending = blueprints |
| 273 | 275 | .0 |
| @@ -281,7 +283,10 @@ pub fn plan_shelter( | ||
| 281 | 283 | let beds: Vec<UVec2> = structures |
| 282 | 284 | .iter() |
| 283 | 285 | .filter(|(pos, kind)| { |
| 284 | - *kind == StructureKind::Bed && !map.is_roofed(pos.as_ivec2()) && !homes.is_home(*pos) | |
| 286 | + *kind == StructureKind::Bed | |
| 287 | + && !map.is_roofed(pos.as_ivec2()) | |
| 288 | + && !homes.is_home(*pos) | |
| 289 | + && !rooms.contains(*pos) | |
| 285 | 290 | }) |
| 286 | 291 | .map(|(pos, _)| *pos) |
| 287 | 292 | .collect(); |
src/family.rs +90 -78
| @@ -4,13 +4,14 @@ use std::collections::{HashMap, HashSet}; | ||
| 4 | 4 | use bevy::prelude::*; |
| 5 | 5 | |
| 6 | 6 | use crate::{ |
| 7 | - building::{Blueprints, BuildKind, enclosed_room}, | |
| 7 | + building::{Blueprints, BuildKind}, | |
| 8 | 8 | colonist::{Colonist, ColonistAssets, colonist_name, free_name_index, spawn_colonist}, |
| 9 | 9 | economy::in_colony, |
| 10 | 10 | items::{Item, ItemAssets}, |
| 11 | 11 | pathfinding::{ColonyReach, Reach, nearest_tile}, |
| 12 | 12 | population::{ColonyLog, GROWTH_FOOD_DAYS, MAX_COLONISTS, food_in_store}, |
| 13 | 13 | rng::SimRng, |
| 14 | + rooms::{Room, Rooms}, | |
| 14 | 15 | sim::{SimSystems, SimTick, TICKS_PER_HOUR}, |
| 15 | 16 | structures::{Structure, StructureKind}, |
| 16 | 17 | world::{Terrain, TilePos, WorldMap}, |
| @@ -49,7 +50,8 @@ const BIRTH_HOUR_TICK: u64 = 20 * TICKS_PER_HOUR; | ||
| 49 | 50 | const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR; |
| 50 | 51 | const HOME_PLAN_INTERVAL_TICKS: u64 = 300; |
| 51 | 52 | const HOME_SEARCH_TILES: usize = 6000; |
| 52 | -const HOME_CLEARANCE: i32 = 1; | |
| 53 | +const HOME_CANDIDATES: usize = 40; | |
| 54 | +const MIN_HOME_INTERIOR: UVec2 = UVec2::new(3, 2); | |
| 53 | 55 | |
| 54 | 56 | #[derive(Component, Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 55 | 57 | pub struct Family(pub u32); |
| @@ -136,18 +138,23 @@ impl Home { | ||
| 136 | 138 | UVec2::new(self.hi.x - 1, self.lo.y + 1) |
| 137 | 139 | } |
| 138 | 140 | |
| 139 | - pub fn blueprints(&self, toward: UVec2) -> HashMap<UVec2, BuildKind> { | |
| 140 | - let door = self | |
| 141 | - .door_sites() | |
| 141 | + pub fn door_site(&self, map: &WorldMap, toward: UVec2) -> Option<UVec2> { | |
| 142 | + self.door_sites() | |
| 143 | + .filter(|t| !map.is_wall(t.as_ivec2())) | |
| 144 | + .filter(|t| map.move_cost(self.outward(*t)).is_some()) | |
| 142 | 145 | .min_by(|a, b| { |
| 143 | 146 | a.as_vec2() |
| 144 | 147 | .distance_squared(toward.as_vec2()) |
| 145 | 148 | .total_cmp(&b.as_vec2().distance_squared(toward.as_vec2())) |
| 146 | 149 | .then((a.y, a.x).cmp(&(b.y, b.x))) |
| 147 | 150 | }) |
| 148 | - .unwrap_or(self.lo); | |
| 151 | + } | |
| 152 | + | |
| 153 | + pub fn blueprints(&self, map: &WorldMap, toward: UVec2) -> HashMap<UVec2, BuildKind> { | |
| 154 | + let door = self.door_site(map, toward).unwrap_or(self.lo); | |
| 149 | 155 | let mut plan: HashMap<UVec2, BuildKind> = self |
| 150 | 156 | .ring() |
| 157 | + .filter(|t| !map.is_wall(t.as_ivec2())) | |
| 151 | 158 | .map(|t| (t, BuildKind::Structure(StructureKind::Wall))) |
| 152 | 159 | .collect(); |
| 153 | 160 | plan.insert(door, BuildKind::Structure(StructureKind::Door)); |
| @@ -302,6 +309,7 @@ fn bond( | ||
| 302 | 309 | } |
| 303 | 310 | |
| 304 | 311 | pub struct HomeContext<'a> { |
| 312 | + pub rooms: &'a Rooms, | |
| 305 | 313 | pub map: &'a WorldMap, |
| 306 | 314 | pub zones: &'a Zones, |
| 307 | 315 | pub blueprints: &'a Blueprints, |
| @@ -326,26 +334,36 @@ impl HomeContext<'_> { | ||
| 326 | 334 | && !self.claimed(tile.as_uvec2()) |
| 327 | 335 | } |
| 328 | 336 | |
| 329 | - fn fits(&self, home: Home) -> bool { | |
| 330 | - let (lo, hi) = (home.lo.as_ivec2(), home.hi.as_ivec2()); | |
| 331 | - let (outer_lo, outer_hi) = (lo - HOME_CLEARANCE, hi + HOME_CLEARANCE); | |
| 332 | - (outer_lo.y..=outer_hi.y).all(|y| { | |
| 333 | - (outer_lo.x..=outer_hi.x).all(|x| { | |
| 334 | - let tile = IVec2::new(x, y); | |
| 335 | - let core = tile.cmpge(lo).all() && tile.cmple(hi).all(); | |
| 336 | - if core { | |
| 337 | - self.open(tile) | |
| 338 | - } else { | |
| 339 | - !self.map.in_bounds(tile) | |
| 340 | - || (!self.claimed(tile.as_uvec2()) && self.map.move_cost(tile).is_some()) | |
| 341 | - } | |
| 342 | - }) | |
| 343 | - }) | |
| 337 | + fn shared_walls(&self, home: Home) -> Option<usize> { | |
| 338 | + if !self.map.in_bounds(home.hi.as_ivec2()) { | |
| 339 | + return None; | |
| 340 | + } | |
| 341 | + let wall = BuildKind::Structure(StructureKind::Wall); | |
| 342 | + let mut shared = 0; | |
| 343 | + for tile in home.ring() { | |
| 344 | + if self.map.is_wall(tile.as_ivec2()) || self.blueprints.get(tile) == Some(wall) { | |
| 345 | + shared += 1; | |
| 346 | + } else if !self.open(tile.as_ivec2()) || self.rooms.contains(tile) { | |
| 347 | + return None; | |
| 348 | + } | |
| 349 | + } | |
| 350 | + let interior_open = home | |
| 351 | + .interior() | |
| 352 | + .all(|t| self.open(t.as_ivec2()) && !self.rooms.contains(t)); | |
| 353 | + let door = home.door_sites().any(|t| { | |
| 354 | + let out = home.outward(t); | |
| 355 | + !self.map.is_wall(t.as_ivec2()) | |
| 356 | + && self.blueprints.get(t).is_none() | |
| 357 | + && self.map.move_cost(out).is_some() | |
| 358 | + && !self.rooms.contains(out.as_uvec2()) | |
| 359 | + && self.blueprints.get(out.as_uvec2()) != Some(wall) | |
| 360 | + }); | |
| 361 | + (interior_open && door).then_some(shared) | |
| 344 | 362 | } |
| 345 | 363 | |
| 346 | 364 | pub fn plan(&self, colony_home: UVec2) -> Option<Home> { |
| 347 | 365 | let center = (HOME_INTERIOR / 2 + UVec2::ONE).as_ivec2(); |
| 348 | - let mut found = None; | |
| 366 | + let mut candidates: Vec<(usize, Home)> = Vec::new(); | |
| 349 | 367 | nearest_tile(self.map, colony_home, HOME_SEARCH_TILES, |p| { |
| 350 | 368 | let lo = p.as_ivec2() - center; |
| 351 | 369 | if lo.min_element() < 0 { |
| @@ -356,10 +374,16 @@ impl HomeContext<'_> { | ||
| 356 | 374 | if (middle - colony_home.as_ivec2()).abs().max_element() < HOME_MIN_DISTANCE { |
| 357 | 375 | return false; |
| 358 | 376 | } |
| 359 | - found = self.fits(home).then_some(home); | |
| 360 | - found.is_some() | |
| 361 | - })?; | |
| 362 | - found | |
| 377 | + if let Some(shared) = self.shared_walls(home) { | |
| 378 | + candidates.push((shared, home)); | |
| 379 | + } | |
| 380 | + candidates.len() >= HOME_CANDIDATES | |
| 381 | + }); | |
| 382 | + candidates | |
| 383 | + .into_iter() | |
| 384 | + .enumerate() | |
| 385 | + .max_by_key(|&(order, (shared, _))| (shared, std::cmp::Reverse(order))) | |
| 386 | + .map(|(_, (_, home))| home) | |
| 363 | 387 | } |
| 364 | 388 | } |
| 365 | 389 | |
| @@ -408,13 +432,15 @@ pub fn torn_down(map: &WorldMap, doors: &HashSet<UVec2>, home: &Home) -> bool { | ||
| 408 | 432 | intact * 2 < ring.len() |
| 409 | 433 | } |
| 410 | 434 | |
| 411 | -pub fn abandoned_home(map: &WorldMap, doors: &HashSet<UVec2>, bed: UVec2) -> Option<Home> { | |
| 412 | - let room = enclosed_room(map, doors, bed)?; | |
| 413 | - let lo = room.iter().fold(UVec2::MAX, |a, t| a.min(*t)); | |
| 414 | - let hi = room.iter().fold(UVec2::ZERO, |a, t| a.max(*t)); | |
| 435 | +pub fn home_of_room(room: &Room) -> Option<Home> { | |
| 436 | + let (lo, hi) = room.bounds(); | |
| 415 | 437 | let size = hi - lo + UVec2::ONE; |
| 416 | - (size == HOME_INTERIOR && room.len() == size.element_product() as usize && lo.min_element() > 0) | |
| 417 | - .then(|| Home::at(lo - UVec2::ONE)) | |
| 438 | + (room.is_rectangle() && size.cmpge(MIN_HOME_INTERIOR).all() && lo.min_element() > 0).then( | |
| 439 | + || Home { | |
| 440 | + lo: lo - UVec2::ONE, | |
| 441 | + hi: hi + UVec2::ONE, | |
| 442 | + }, | |
| 443 | + ) | |
| 418 | 444 | } |
| 419 | 445 | |
| 420 | 446 | pub fn surplus_beds(communal: &[UVec2], homeless_adults: usize, toward: UVec2) -> Vec<UVec2> { |
| @@ -426,20 +452,6 @@ pub fn surplus_beds(communal: &[UVec2], homeless_adults: usize, toward: UVec2) - | ||
| 426 | 452 | beds.split_off(homeless_adults.min(beds.len())) |
| 427 | 453 | } |
| 428 | 454 | |
| 429 | -pub fn roofs_needed(map: &WorldMap, doors: &HashSet<UVec2>, home: &Home) -> Vec<UVec2> { | |
| 430 | - let interior: Vec<UVec2> = home.interior().collect(); | |
| 431 | - let Some(room) = enclosed_room(map, doors, interior[0]) else { | |
| 432 | - return Vec::new(); | |
| 433 | - }; | |
| 434 | - if room.len() != interior.len() { | |
| 435 | - return Vec::new(); | |
| 436 | - } | |
| 437 | - interior | |
| 438 | - .into_iter() | |
| 439 | - .filter(|t| !map.is_roofed(t.as_ivec2())) | |
| 440 | - .collect() | |
| 441 | -} | |
| 442 | - | |
| 443 | 455 | #[allow(clippy::too_many_arguments)] |
| 444 | 456 | fn plan_homes( |
| 445 | 457 | tick: Res<SimTick>, |
| @@ -452,6 +464,7 @@ fn plan_homes( | ||
| 452 | 464 | families: Query<(Option<&Family>, Has<Partner>, Has<Child>), With<Colonist>>, |
| 453 | 465 | mut colony_reach: ResMut<ColonyReach>, |
| 454 | 466 | mut flood: Local<Option<((u64, UVec2, UVec2), Reach)>>, |
| 467 | + rooms: Res<Rooms>, | |
| 455 | 468 | ) { |
| 456 | 469 | if !tick.0.is_multiple_of(HOME_PLAN_INTERVAL_TICKS) { |
| 457 | 470 | return; |
| @@ -490,10 +503,13 @@ fn plan_homes( | ||
| 490 | 503 | let statues = of_kind(StructureKind::Statue); |
| 491 | 504 | let hearths = of_kind(StructureKind::Hearth); |
| 492 | 505 | let beds = of_kind(StructureKind::Bed); |
| 493 | - for &bed in &beds { | |
| 494 | - if !homes.is_home(bed) | |
| 495 | - && let Some(home) = abandoned_home(&map, &doors, bed) | |
| 496 | - && !homes.all().any(|h| h.ring().any(|t| home.contains(t))) | |
| 506 | + for room in &rooms.rooms { | |
| 507 | + let room_beds = beds.iter().filter(|b| room.tiles.contains(b)).count(); | |
| 508 | + if room_beds >= 2 | |
| 509 | + && let Some(home) = home_of_room(room) | |
| 510 | + && !homes | |
| 511 | + .all() | |
| 512 | + .any(|h| h.interior().any(|t| room.tiles.contains(&t))) | |
| 497 | 513 | { |
| 498 | 514 | homes.vacant.push(home); |
| 499 | 515 | } |
| @@ -546,11 +562,7 @@ fn plan_homes( | ||
| 546 | 562 | if blueprints.0.keys().any(|t| home.contains(*t)) { |
| 547 | 563 | continue; |
| 548 | 564 | } |
| 549 | - let roofs = roofs_needed(&map, &doors, home); | |
| 550 | - let built = roofs.is_empty() && home.interior().all(|t| map.is_roofed(t.as_ivec2())); | |
| 551 | - for tile in roofs { | |
| 552 | - blueprints.0.insert(tile, BuildKind::Roof); | |
| 553 | - } | |
| 565 | + let built = home.interior().all(|t| map.is_roofed(t.as_ivec2())); | |
| 554 | 566 | if built && owned.contains(home) { |
| 555 | 567 | if !hearths.iter().any(|&t| home.contains(t)) { |
| 556 | 568 | let hearth = BuildKind::Structure(StructureKind::Hearth); |
| @@ -586,6 +598,7 @@ fn plan_homes( | ||
| 586 | 598 | .chain(items.iter().map(|pos| pos.0)) |
| 587 | 599 | .collect(); |
| 588 | 600 | let ctx = HomeContext { |
| 601 | + rooms: &rooms, | |
| 589 | 602 | map: &map, |
| 590 | 603 | zones: &zones, |
| 591 | 604 | blueprints: &blueprints, |
| @@ -593,7 +606,8 @@ fn plan_homes( | ||
| 593 | 606 | occupied: &occupied, |
| 594 | 607 | }; |
| 595 | 608 | if let Some(home) = ctx.plan(colony_home) { |
| 596 | - blueprints.0.extend(home.blueprints(colony_home)); | |
| 609 | + let plan = home.blueprints(&map, colony_home); | |
| 610 | + blueprints.0.extend(plan); | |
| 597 | 611 | homes.by_family.insert(family, home); |
| 598 | 612 | } |
| 599 | 613 | } |
| @@ -759,6 +773,7 @@ mod tests { | ||
| 759 | 773 | |
| 760 | 774 | fn plan_on(map: &WorldMap, zones: &Zones, occupied: &HashSet<UVec2>) -> Home { |
| 761 | 775 | HomeContext { |
| 776 | + rooms: &Rooms::detect(map, &HashSet::new()), | |
| 762 | 777 | map, |
| 763 | 778 | zones, |
| 764 | 779 | blueprints: &Blueprints::default(), |
| @@ -796,7 +811,8 @@ mod tests { | ||
| 796 | 811 | #[test] |
| 797 | 812 | fn a_home_is_a_walled_room_with_a_door_and_two_beds() { |
| 798 | 813 | let home = Home::at(UVec2::new(10, 10)); |
| 799 | - let plan = home.blueprints(UVec2::new(12, 0)); | |
| 814 | + let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass); | |
| 815 | + let plan = home.blueprints(&map, UVec2::new(12, 0)); | |
| 800 | 816 | let count = |kind: StructureKind| { |
| 801 | 817 | plan.values() |
| 802 | 818 | .filter(|k| **k == BuildKind::Structure(kind)) |
| @@ -820,19 +836,6 @@ mod tests { | ||
| 820 | 836 | } |
| 821 | 837 | } |
| 822 | 838 | |
| 823 | - #[test] | |
| 824 | - fn finished_homes_get_a_roof() { | |
| 825 | - let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass); | |
| 826 | - let home = Home::at(UVec2::new(5, 5)); | |
| 827 | - let door = UVec2::new(7, 5); | |
| 828 | - for tile in home.ring().filter(|t| *t != door) { | |
| 829 | - map.set_wall(tile, true); | |
| 830 | - } | |
| 831 | - assert!(roofs_needed(&map, &HashSet::new(), &home).is_empty()); | |
| 832 | - let roofs = roofs_needed(&map, &HashSet::from([door]), &home); | |
| 833 | - assert_eq!(roofs.len(), (HOME_INTERIOR.x * HOME_INTERIOR.y) as usize); | |
| 834 | - } | |
| 835 | - | |
| 836 | 839 | // Gold has one use: a statue in each finished home, which lifts the family's mood. Planning |
| 837 | 840 | // it is what makes the colony want gold at all. Finished homes also get a hearth, where the |
| 838 | 841 | // family cooks (one campfire can't feed a big colony) and which heats the home, and a wooden |
| @@ -858,6 +861,7 @@ mod tests { | ||
| 858 | 861 | ..default() |
| 859 | 862 | }) |
| 860 | 863 | .init_resource::<ColonyReach>() |
| 864 | + .init_resource::<Rooms>() | |
| 861 | 865 | .add_systems(Update, plan_homes); |
| 862 | 866 | app.world_mut().spawn(( |
| 863 | 867 | Structure { |
| @@ -895,10 +899,10 @@ mod tests { | ||
| 895 | 899 | Reach::flood(map, from) |
| 896 | 900 | } |
| 897 | 901 | |
| 898 | - // New homes keep a walkable tile all around, so any door they get opens onto open ground. | |
| 899 | - // Homes used to be packed wall to wall and their doors ended up facing a neighbour's wall. | |
| 902 | + // Homes reuse walls that are already standing (a shared wall instead of two side by side) and | |
| 903 | + // only plan the missing ones; the door goes on a side that opens onto walkable ground. | |
| 900 | 904 | #[test] |
| 901 | - fn new_homes_never_touch_existing_walls() { | |
| 905 | + fn new_homes_share_existing_walls() { | |
| 902 | 906 | let mut map = WorldMap::filled(UVec2::splat(40), Terrain::Grass); |
| 903 | 907 | for y in 0..40 { |
| 904 | 908 | map.set_wall(UVec2::new(27, y), true); |
| @@ -906,11 +910,15 @@ mod tests { | ||
| 906 | 910 | let mut zones = Zones::default(); |
| 907 | 911 | zones.add_tiles(&map, [UVec2::new(20, 20)]); |
| 908 | 912 | let home = plan_on(&map, &zones, &HashSet::new()); |
| 909 | - for y in home.lo.y - 1..=home.hi.y + 1 { | |
| 910 | - for x in home.lo.x - 1..=home.hi.x + 1 { | |
| 911 | - assert!(!map.is_wall(IVec2::new(x as i32, y as i32)), "{home:?}"); | |
| 912 | - } | |
| 913 | - } | |
| 913 | + assert!(home.lo.x == 27 || home.hi.x == 27, "{home:?}"); | |
| 914 | + let plan = home.blueprints(&map, UVec2::new(20, 20)); | |
| 915 | + assert!(plan.keys().all(|t| t.x != 27)); | |
| 916 | + let door = plan | |
| 917 | + .iter() | |
| 918 | + .find(|(_, k)| **k == BuildKind::Structure(StructureKind::Door)) | |
| 919 | + .map(|(t, _)| *t) | |
| 920 | + .unwrap(); | |
| 921 | + assert!(map.move_cost(home.outward(door)).is_some()); | |
| 914 | 922 | } |
| 915 | 923 | |
| 916 | 924 | // A home whose only door faces a wall can't be entered; it gets a new door on a side that |
| @@ -965,6 +973,7 @@ mod tests { | ||
| 965 | 973 | (UVec2::new(12, 12), BuildKind::Deconstruct), |
| 966 | 974 | ]))) |
| 967 | 975 | .init_resource::<ColonyReach>() |
| 976 | + .init_resource::<Rooms>() | |
| 968 | 977 | .add_systems(Update, plan_homes); |
| 969 | 978 | app.update(); |
| 970 | 979 | let blueprints = app.world().resource::<Blueprints>(); |
| @@ -985,12 +994,14 @@ mod tests { | ||
| 985 | 994 | map.set_roof(tile, true); |
| 986 | 995 | } |
| 987 | 996 | let mut app = App::new(); |
| 997 | + let map_for_rooms = map.clone(); | |
| 988 | 998 | app.insert_resource(map) |
| 989 | 999 | .init_resource::<SimTick>() |
| 990 | 1000 | .init_resource::<Zones>() |
| 991 | 1001 | .init_resource::<Blueprints>() |
| 992 | 1002 | .init_resource::<Homes>() |
| 993 | 1003 | .init_resource::<ColonyReach>() |
| 1004 | + .insert_resource(Rooms::detect(&map_for_rooms, &HashSet::from([door]))) | |
| 994 | 1005 | .add_systems(Update, plan_homes); |
| 995 | 1006 | let world = app.world_mut(); |
| 996 | 1007 | let structure = |kind| Structure { kind }; |
| @@ -1062,6 +1073,7 @@ mod tests { | ||
| 1062 | 1073 | by_family: HashMap::from([(1, home)]), |
| 1063 | 1074 | ..default() |
| 1064 | 1075 | }) |
| 1076 | + .init_resource::<Rooms>() | |
| 1065 | 1077 | .add_systems(Update, plan_homes); |
| 1066 | 1078 | app.world_mut() |
| 1067 | 1079 | .spawn((Colonist { name: "Ada".into() }, Family(1))); |
src/jobs/mod.rs +11 -2
| @@ -1120,6 +1120,7 @@ pub fn flee_from(map: &WorldMap, from: UVec2, threat: UVec2) -> Option<Vec<UVec2 | ||
| 1120 | 1120 | } |
| 1121 | 1121 | |
| 1122 | 1122 | pub fn find_tending( |
| 1123 | + map: &WorldMap, | |
| 1123 | 1124 | patients: &[(Entity, UVec2)], |
| 1124 | 1125 | reservations: &Reservations, |
| 1125 | 1126 | reach: &ColonyReach, |
| @@ -1146,7 +1147,7 @@ pub fn find_tending( | ||
| 1146 | 1147 | .map(|(_, pos)| pos.0) |
| 1147 | 1148 | .collect(); |
| 1148 | 1149 | let pickup = (!herbs.is_empty()) |
| 1149 | - .then(|| reach.nearest_unbounded(from, &herbs)) | |
| 1150 | + .then(|| reach.nearest(map, from, SEARCH_TILES, &herbs)) | |
| 1150 | 1151 | .flatten(); |
| 1151 | 1152 | Some(Task::Tend { |
| 1152 | 1153 | patient, |
| @@ -1634,7 +1635,15 @@ fn choose_jobs( | ||
| 1634 | 1635 | }), |
| 1635 | 1636 | (WorkType::Build, None, &|| find_refuel(&build, tile.0)), |
| 1636 | 1637 | (WorkType::Doctor, Some(Skill::Medicine), &|| { |
| 1637 | - find_tending(&patients, &reservations, &reach, &snapshot, entity, tile.0) | |
| 1638 | + find_tending( | |
| 1639 | + &map, | |
| 1640 | + &patients, | |
| 1641 | + &reservations, | |
| 1642 | + &reach, | |
| 1643 | + &snapshot, | |
| 1644 | + entity, | |
| 1645 | + tile.0, | |
| 1646 | + ) | |
| 1638 | 1647 | }), |
| 1639 | 1648 | (WorkType::Build, None, &|| { |
| 1640 | 1649 | (dark || chilly) |
src/jobs/tests.rs +1 -0
| @@ -22,6 +22,7 @@ fn test_app(map: WorldMap) -> App { | ||
| 22 | 22 | .init_resource::<Reservations>() |
| 23 | 23 | .init_resource::<crate::family::Homes>() |
| 24 | 24 | .init_resource::<crate::pathfinding::ColonyReach>() |
| 25 | + .init_resource::<crate::rooms::Rooms>() | |
| 25 | 26 | .add_message::<crate::mood::Ate>() |
| 26 | 27 | .init_resource::<crate::designations::Designations>() |
| 27 | 28 | .init_resource::<ColonyGoals>() |
src/main.rs +2 -1
| @@ -21,6 +21,7 @@ mod paths; | ||
| 21 | 21 | mod plants; |
| 22 | 22 | mod population; |
| 23 | 23 | mod rng; |
| 24 | +mod rooms; | |
| 24 | 25 | mod save; |
| 25 | 26 | mod selection; |
| 26 | 27 | mod sim; |
| @@ -82,6 +83,6 @@ fn main() { | ||
| 82 | 83 | zones::ZonesPlugin, |
| 83 | 84 | ui::HudPlugin, |
| 84 | 85 | )) |
| 85 | - .add_plugins((save::SavePlugin, threats::ThreatsPlugin)) | |
| 86 | + .add_plugins((save::SavePlugin, threats::ThreatsPlugin, rooms::RoomsPlugin)) | |
| 86 | 87 | .run(); |
| 87 | 88 | } |
src/mood.rs +12 -22
| @@ -4,16 +4,16 @@ use serde::{Deserialize, Serialize}; | ||
| 4 | 4 | use crate::{ |
| 5 | 5 | climate::Warmth, |
| 6 | 6 | colonist::Colonist, |
| 7 | - family::{Child, Family, Homes, Partner, floored}, | |
| 7 | + family::{Child, Family, Homes, Partner}, | |
| 8 | 8 | food::{HUNGRY, Hunger}, |
| 9 | 9 | items::{Carrying, ItemAssets, ItemKind, ItemQuery, drop_items}, |
| 10 | 10 | jobs::Reservations, |
| 11 | 11 | needs::Rest, |
| 12 | 12 | population::ColonyLog, |
| 13 | 13 | rng::SimRng, |
| 14 | + rooms::Rooms, | |
| 14 | 15 | sim::{SimSystems, SimTick, TICKS_PER_HOUR}, |
| 15 | 16 | skills::Skills, |
| 16 | - structures::{Structure, StructureKind}, | |
| 17 | 17 | threats::{HURT, Health}, |
| 18 | 18 | world::{TilePos, WorldMap}, |
| 19 | 19 | worldgen::WorldGenerated, |
| @@ -39,6 +39,7 @@ pub const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR; | ||
| 39 | 39 | pub const SICK_TICKS: u64 = 12 * TICKS_PER_HOUR; |
| 40 | 40 | pub const MOOD_HOURS: f32 = 12.0; |
| 41 | 41 | pub const LEAVE_MOOD: f32 = 0.2; |
| 42 | +pub const ROOM_MOOD: f32 = 0.3; | |
| 42 | 43 | pub const LEAVE_AFTER_HOURS: u32 = 48; |
| 43 | 44 | pub const OLD_AGE: u64 = 60; |
| 44 | 45 | pub const OLD_AGE_DEATH_PER_YEAR: f32 = 0.03; |
| @@ -111,8 +112,7 @@ pub struct MoodFactors { | ||
| 111 | 112 | pub home: bool, |
| 112 | 113 | pub grieving: bool, |
| 113 | 114 | pub cold: bool, |
| 114 | - pub beauty: bool, | |
| 115 | - pub floored: bool, | |
| 115 | + pub room: f32, | |
| 116 | 116 | pub hurt: bool, |
| 117 | 117 | } |
| 118 | 118 | |
| @@ -140,12 +140,7 @@ pub fn mood_target(f: MoodFactors) -> f32 { | ||
| 140 | 140 | if f.cold { |
| 141 | 141 | mood -= 0.15; |
| 142 | 142 | } |
| 143 | - if f.beauty { | |
| 144 | - mood += 0.1; | |
| 145 | - } | |
| 146 | - if f.floored { | |
| 147 | - mood += 0.05; | |
| 148 | - } | |
| 143 | + mood += ROOM_MOOD * f.room; | |
| 149 | 144 | if f.hurt { |
| 150 | 145 | mood -= 0.1; |
| 151 | 146 | } |
| @@ -205,7 +200,7 @@ fn update_mood( | ||
| 205 | 200 | homes: Res<Homes>, |
| 206 | 201 | map: Res<WorldMap>, |
| 207 | 202 | losses: Res<Losses>, |
| 208 | - structures: Query<(&TilePos, &Structure), Without<Colonist>>, | |
| 203 | + rooms: Res<Rooms>, | |
| 209 | 204 | mut colonists: Query<( |
| 210 | 205 | Entity, |
| 211 | 206 | &mut Mood, |
| @@ -223,11 +218,6 @@ fn update_mood( | ||
| 223 | 218 | return; |
| 224 | 219 | } |
| 225 | 220 | let grieving = losses.0.iter().any(|&t| tick.0 < t + GRIEF_TICKS); |
| 226 | - let statues: Vec<UVec2> = structures | |
| 227 | - .iter() | |
| 228 | - .filter(|(_, s)| s.kind == StructureKind::Statue) | |
| 229 | - .map(|(pos, _)| pos.0) | |
| 230 | - .collect(); | |
| 231 | 221 | for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth, health) in |
| 232 | 222 | &mut colonists |
| 233 | 223 | { |
| @@ -237,8 +227,10 @@ fn update_mood( | ||
| 237 | 227 | } |
| 238 | 228 | let own_home = homes.of(family.copied()); |
| 239 | 229 | let home = own_home.is_some_and(|h| h.interior().all(|t| map.is_roofed(t.as_ivec2()))); |
| 240 | - let beauty = own_home.is_some_and(|h| statues.iter().any(|&t| h.contains(t))); | |
| 241 | - let floored = own_home.is_some_and(|h| h.interior().all(|t| floored(&map, t))); | |
| 230 | + let room = own_home | |
| 231 | + .and_then(|h| h.interior().next()) | |
| 232 | + .and_then(|t| rooms.at(t)) | |
| 233 | + .map_or(0.0, |room| room.quality); | |
| 242 | 234 | let target = mood_target(MoodFactors { |
| 243 | 235 | fed: hunger.0, |
| 244 | 236 | rest: rest.0, |
| @@ -250,8 +242,7 @@ fn update_mood( | ||
| 250 | 242 | home, |
| 251 | 243 | grieving, |
| 252 | 244 | cold: warmth.cold(), |
| 253 | - beauty, | |
| 254 | - floored, | |
| 245 | + room, | |
| 255 | 246 | hurt: health.0 < HURT, |
| 256 | 247 | }); |
| 257 | 248 | mood.0 += (target - mood.0) / MOOD_HOURS; |
| @@ -361,8 +352,7 @@ mod tests { | ||
| 361 | 352 | let settled = mood_target(MoodFactors { |
| 362 | 353 | partner: true, |
| 363 | 354 | home: true, |
| 364 | - beauty: true, | |
| 365 | - floored: true, | |
| 355 | + room: 1.0, | |
| 366 | 356 | last_meal_cooked: Some(true), |
| 367 | 357 | ..content() |
| 368 | 358 | }); |
src/pathfinding.rs +7 -13
| @@ -101,17 +101,6 @@ impl ColonyReach { | ||
| 101 | 101 | self.0.as_ref().is_none_or(|reach| reach.near(tile)) |
| 102 | 102 | } |
| 103 | 103 | |
| 104 | - pub fn nearest_unbounded(&self, from: UVec2, targets: &HashSet<UVec2>) -> Option<UVec2> { | |
| 105 | - targets | |
| 106 | - .iter() | |
| 107 | - .filter(|t| self.allows(**t)) | |
| 108 | - .min_by_key(|t| { | |
| 109 | - let d = t.as_ivec2() - from.as_ivec2(); | |
| 110 | - (d.length_squared(), t.y, t.x) | |
| 111 | - }) | |
| 112 | - .copied() | |
| 113 | - } | |
| 114 | - | |
| 115 | 104 | pub fn nearest( |
| 116 | 105 | &self, |
| 117 | 106 | map: &WorldMap, |
| @@ -122,7 +111,11 @@ impl ColonyReach { | ||
| 122 | 111 | match &self.0 { |
| 123 | 112 | Some(reach) if reach.contains(from) => targets |
| 124 | 113 | .iter() |
| 125 | - .filter(|t| reach.contains(**t)) | |
| 114 | + .filter(|t| { | |
| 115 | + let radius = search_reach(max_tiles) as i32; | |
| 116 | + reach.contains(**t) | |
| 117 | + && (t.as_ivec2() - from.as_ivec2()).abs().max_element() <= radius | |
| 118 | + }) | |
| 126 | 119 | .min_by_key(|t| { |
| 127 | 120 | let d = t.as_ivec2() - from.as_ivec2(); |
| 128 | 121 | (d.length_squared(), t.y, t.x) |
| @@ -336,11 +329,12 @@ mod tests { | ||
| 336 | 329 | let far = UVec2::new(5, 20); |
| 337 | 330 | let targets = HashSet::from([across, far]); |
| 338 | 331 | assert_eq!( |
| 339 | - reach.nearest(&map, UVec2::new(5, 5), 100, &targets), | |
| 332 | + reach.nearest(&map, UVec2::new(5, 5), 1000, &targets), | |
| 340 | 333 | Some(far) |
| 341 | 334 | ); |
| 342 | 335 | let elsewhere = UVec2::new(20, 5); |
| 343 | 336 | assert_eq!(reach.nearest(&map, elsewhere, 1000, &targets), Some(across)); |
| 337 | + assert_eq!(reach.nearest(&map, UVec2::new(5, 5), 100, &targets), None); | |
| 344 | 338 | } |
| 345 | 339 | |
| 346 | 340 | // Wandering picks random nearby goals; one walled off by a river or cliff made A* search the |
src/rooms.rs +380 -0
| @@ -0,0 +1,380 @@ | ||
| 1 | +use std::collections::{HashMap, HashSet, VecDeque}; | |
| 2 | + | |
| 3 | +use bevy::prelude::*; | |
| 4 | + | |
| 5 | +use crate::{ | |
| 6 | + building::{Blueprints, BuildKind, MAX_ROOM_TILES}, | |
| 7 | + colonist::Colonist, | |
| 8 | + sim::{SimSystems, SimTick}, | |
| 9 | + structures::{Structure, StructureKind}, | |
| 10 | + world::{Terrain, TilePos, WorldMap}, | |
| 11 | + worldgen::WorldGenerated, | |
| 12 | +}; | |
| 13 | + | |
| 14 | +pub struct RoomsPlugin; | |
| 15 | + | |
| 16 | +impl Plugin for RoomsPlugin { | |
| 17 | + fn build(&self, app: &mut App) { | |
| 18 | + app.init_resource::<Rooms>() | |
| 19 | + .add_systems(Update, clear_rooms.run_if(on_message::<WorldGenerated>)) | |
| 20 | + .add_systems( | |
| 21 | + FixedUpdate, | |
| 22 | + (update_rooms, (roof_rooms, remove_bad_doors)) | |
| 23 | + .chain() | |
| 24 | + .in_set(SimSystems::Decide), | |
| 25 | + ); | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +pub const MIN_ROOM_TILES: usize = 4; | |
| 30 | +const ROOM_CHECK_TICKS: u64 = 60; | |
| 31 | +const ROOM_PLAN_TICKS: u64 = 300; | |
| 32 | +const CROWDED_TILES_PER_BED: usize = 3; | |
| 33 | +const SIDES: [IVec2; 4] = [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y]; | |
| 34 | + | |
| 35 | +#[derive(Debug, Clone, PartialEq)] | |
| 36 | +pub struct Room { | |
| 37 | + pub tiles: Vec<UVec2>, | |
| 38 | + pub doors: Vec<UVec2>, | |
| 39 | + pub quality: f32, | |
| 40 | +} | |
| 41 | + | |
| 42 | +impl Room { | |
| 43 | + pub fn bounds(&self) -> (UVec2, UVec2) { | |
| 44 | + let lo = self.tiles.iter().fold(UVec2::MAX, |a, t| a.min(*t)); | |
| 45 | + let hi = self.tiles.iter().fold(UVec2::ZERO, |a, t| a.max(*t)); | |
| 46 | + (lo, hi) | |
| 47 | + } | |
| 48 | + | |
| 49 | + pub fn is_rectangle(&self) -> bool { | |
| 50 | + let (lo, hi) = self.bounds(); | |
| 51 | + (hi - lo + UVec2::ONE).element_product() as usize == self.tiles.len() | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +#[derive(Resource, Default, Debug)] | |
| 56 | +pub struct Rooms { | |
| 57 | + pub rooms: Vec<Room>, | |
| 58 | + by_tile: HashMap<UVec2, usize>, | |
| 59 | + shape: Option<(u64, Vec<UVec2>)>, | |
| 60 | +} | |
| 61 | + | |
| 62 | +impl Rooms { | |
| 63 | + pub fn at(&self, tile: UVec2) -> Option<&Room> { | |
| 64 | + self.by_tile.get(&tile).map(|&i| &self.rooms[i]) | |
| 65 | + } | |
| 66 | + | |
| 67 | + pub fn contains(&self, tile: UVec2) -> bool { | |
| 68 | + self.by_tile.contains_key(&tile) | |
| 69 | + } | |
| 70 | + | |
| 71 | + pub fn detect(map: &WorldMap, doors: &HashSet<UVec2>) -> Self { | |
| 72 | + let detected = detect_rooms(map, doors); | |
| 73 | + Self { | |
| 74 | + by_tile: detected | |
| 75 | + .iter() | |
| 76 | + .enumerate() | |
| 77 | + .flat_map(|(i, (tiles, _))| tiles.iter().map(move |t| (*t, i))) | |
| 78 | + .collect(), | |
| 79 | + rooms: detected | |
| 80 | + .into_iter() | |
| 81 | + .map(|(tiles, doors)| Room { | |
| 82 | + tiles, | |
| 83 | + doors, | |
| 84 | + quality: 0.0, | |
| 85 | + }) | |
| 86 | + .collect(), | |
| 87 | + shape: None, | |
| 88 | + } | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 92 | +fn clear_rooms(mut rooms: ResMut<Rooms>) { | |
| 93 | + *rooms = Rooms::default(); | |
| 94 | +} | |
| 95 | + | |
| 96 | +pub fn blocks_room(map: &WorldMap, doors: &HashSet<UVec2>, tile: IVec2) -> bool { | |
| 97 | + !map.in_bounds(tile) | |
| 98 | + || map.is_wall(tile) | |
| 99 | + || doors.contains(&tile.as_uvec2()) | |
| 100 | + || map.get(tile).is_none_or(|t| t.move_cost().is_none()) | |
| 101 | +} | |
| 102 | + | |
| 103 | +pub fn detect_rooms(map: &WorldMap, doors: &HashSet<UVec2>) -> Vec<(Vec<UVec2>, Vec<UVec2>)> { | |
| 104 | + let width = map.size.x; | |
| 105 | + let index = |p: UVec2| (p.y * width + p.x) as usize; | |
| 106 | + let mut seen = vec![false; map.size.element_product() as usize]; | |
| 107 | + let mut rooms = Vec::new(); | |
| 108 | + for start in map.positions() { | |
| 109 | + if seen[index(start)] || blocks_room(map, doors, start.as_ivec2()) { | |
| 110 | + continue; | |
| 111 | + } | |
| 112 | + seen[index(start)] = true; | |
| 113 | + let mut region = vec![start]; | |
| 114 | + let mut room_doors = HashSet::new(); | |
| 115 | + let mut outdoors = false; | |
| 116 | + let mut queue = VecDeque::from([start]); | |
| 117 | + while let Some(tile) = queue.pop_front() { | |
| 118 | + for side in SIDES { | |
| 119 | + let next = tile.as_ivec2() + side; | |
| 120 | + if !map.in_bounds(next) { | |
| 121 | + outdoors = true; | |
| 122 | + continue; | |
| 123 | + } | |
| 124 | + let next_u = next.as_uvec2(); | |
| 125 | + if doors.contains(&next_u) { | |
| 126 | + room_doors.insert(next_u); | |
| 127 | + } | |
| 128 | + if blocks_room(map, doors, next) || seen[index(next_u)] { | |
| 129 | + continue; | |
| 130 | + } | |
| 131 | + seen[index(next_u)] = true; | |
| 132 | + region.push(next_u); | |
| 133 | + queue.push_back(next_u); | |
| 134 | + } | |
| 135 | + } | |
| 136 | + let size = region.len(); | |
| 137 | + if !outdoors && size >= MIN_ROOM_TILES && size <= MAX_ROOM_TILES && !room_doors.is_empty() { | |
| 138 | + region.sort_by_key(|t| (t.y, t.x)); | |
| 139 | + let mut room_doors: Vec<UVec2> = room_doors.into_iter().collect(); | |
| 140 | + room_doors.sort_by_key(|t| (t.y, t.x)); | |
| 141 | + rooms.push((region, room_doors)); | |
| 142 | + } | |
| 143 | + } | |
| 144 | + rooms | |
| 145 | +} | |
| 146 | + | |
| 147 | +#[derive(Debug, Default, Clone, Copy)] | |
| 148 | +pub struct Furnishing { | |
| 149 | + pub beds: usize, | |
| 150 | + pub statue: bool, | |
| 151 | + pub hearth: bool, | |
| 152 | +} | |
| 153 | + | |
| 154 | +pub fn room_quality(map: &WorldMap, tiles: &[UVec2], furnishing: Furnishing) -> f32 { | |
| 155 | + let n = tiles.len().max(1) as f32; | |
| 156 | + let share = | |
| 157 | + |test: &dyn Fn(UVec2) -> bool| tiles.iter().filter(|t| test(**t)).count() as f32 / n; | |
| 158 | + let floor = share(&|t| map.get(t.as_ivec2()) == Some(Terrain::WoodFloor)); | |
| 159 | + let roofed = share(&|t| map.is_roofed(t.as_ivec2())); | |
| 160 | + let space = (n / 16.0).min(1.0); | |
| 161 | + let crowded = furnishing.beds * CROWDED_TILES_PER_BED > tiles.len(); | |
| 162 | + let quality = 0.1 | |
| 163 | + + 0.2 * space | |
| 164 | + + 0.25 * floor | |
| 165 | + + 0.15 * roofed | |
| 166 | + + if furnishing.statue { 0.2 } else { 0.0 } | |
| 167 | + + if furnishing.hearth { 0.1 } else { 0.0 } | |
| 168 | + - if crowded { 0.2 } else { 0.0 }; | |
| 169 | + quality.clamp(0.0, 1.0) | |
| 170 | +} | |
| 171 | + | |
| 172 | +#[allow(clippy::type_complexity)] | |
| 173 | +fn update_rooms( | |
| 174 | + tick: Res<SimTick>, | |
| 175 | + map: Res<WorldMap>, | |
| 176 | + structures: Query<(&TilePos, &Structure), Without<Colonist>>, | |
| 177 | + mut rooms: ResMut<Rooms>, | |
| 178 | +) { | |
| 179 | + if !tick.0.is_multiple_of(ROOM_CHECK_TICKS) { | |
| 180 | + return; | |
| 181 | + } | |
| 182 | + let mut doors: Vec<UVec2> = structures | |
| 183 | + .iter() | |
| 184 | + .filter(|(_, s)| s.kind == StructureKind::Door) | |
| 185 | + .map(|(pos, _)| pos.0) | |
| 186 | + .collect(); | |
| 187 | + doors.sort_by_key(|t| (t.y, t.x)); | |
| 188 | + let shape = (map.shape_version(), doors.clone()); | |
| 189 | + if rooms.shape.as_ref() != Some(&shape) { | |
| 190 | + let door_set: HashSet<UVec2> = doors.into_iter().collect(); | |
| 191 | + *rooms = Rooms::detect(&map, &door_set); | |
| 192 | + rooms.shape = Some(shape); | |
| 193 | + } else if !tick.0.is_multiple_of(ROOM_PLAN_TICKS) { | |
| 194 | + return; | |
| 195 | + } | |
| 196 | + let mut furnishing = vec![Furnishing::default(); rooms.rooms.len()]; | |
| 197 | + for (pos, structure) in &structures { | |
| 198 | + let Some(&i) = rooms.by_tile.get(&pos.0) else { | |
| 199 | + continue; | |
| 200 | + }; | |
| 201 | + match structure.kind { | |
| 202 | + StructureKind::Bed => furnishing[i].beds += 1, | |
| 203 | + StructureKind::Statue => furnishing[i].statue = true, | |
| 204 | + StructureKind::Hearth => furnishing[i].hearth = true, | |
| 205 | + _ => {} | |
| 206 | + } | |
| 207 | + } | |
| 208 | + for (room, furnishing) in rooms.rooms.iter_mut().zip(furnishing) { | |
| 209 | + room.quality = room_quality(&map, &room.tiles, furnishing); | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +fn roof_rooms( | |
| 214 | + tick: Res<SimTick>, | |
| 215 | + map: Res<WorldMap>, | |
| 216 | + rooms: Res<Rooms>, | |
| 217 | + structures: Query<(&TilePos, &Structure), Without<Colonist>>, | |
| 218 | + mut blueprints: ResMut<Blueprints>, | |
| 219 | +) { | |
| 220 | + if !tick.0.is_multiple_of(ROOM_PLAN_TICKS) { | |
| 221 | + return; | |
| 222 | + } | |
| 223 | + let lived_in: HashSet<*const Room> = structures | |
| 224 | + .iter() | |
| 225 | + .filter(|(_, s)| matches!(s.kind, StructureKind::Bed | StructureKind::Hearth)) | |
| 226 | + .filter_map(|(pos, _)| rooms.at(pos.0)) | |
| 227 | + .map(|room| room as *const Room) | |
| 228 | + .collect(); | |
| 229 | + for room in &rooms.rooms { | |
| 230 | + if !lived_in.contains(&(room as *const Room)) { | |
| 231 | + continue; | |
| 232 | + } | |
| 233 | + for &tile in &room.tiles { | |
| 234 | + if !map.is_roofed(tile.as_ivec2()) && blueprints.get(tile).is_none() { | |
| 235 | + blueprints.0.insert(tile, BuildKind::Roof); | |
| 236 | + } | |
| 237 | + } | |
| 238 | + } | |
| 239 | +} | |
| 240 | + | |
| 241 | +pub fn bad_door(map: &WorldMap, door: UVec2) -> bool { | |
| 242 | + let blocked = SIDES | |
| 243 | + .iter() | |
| 244 | + .filter(|side| { | |
| 245 | + let next = door.as_ivec2() + **side; | |
| 246 | + !map.in_bounds(next) || map.move_cost(next).is_none() | |
| 247 | + }) | |
| 248 | + .count(); | |
| 249 | + blocked >= 3 | |
| 250 | +} | |
| 251 | + | |
| 252 | +fn remove_bad_doors( | |
| 253 | + tick: Res<SimTick>, | |
| 254 | + map: Res<WorldMap>, | |
| 255 | + structures: Query<(&TilePos, &Structure), Without<Colonist>>, | |
| 256 | + mut blueprints: ResMut<Blueprints>, | |
| 257 | +) { | |
| 258 | + if !tick.0.is_multiple_of(ROOM_PLAN_TICKS) { | |
| 259 | + return; | |
| 260 | + } | |
| 261 | + for (pos, structure) in &structures { | |
| 262 | + if structure.kind == StructureKind::Door | |
| 263 | + && bad_door(&map, pos.0) | |
| 264 | + && blueprints.get(pos.0).is_none() | |
| 265 | + { | |
| 266 | + blueprints.0.insert(pos.0, BuildKind::Deconstruct); | |
| 267 | + } | |
| 268 | + } | |
| 269 | +} | |
| 270 | + | |
| 271 | +#[cfg(test)] | |
| 272 | +mod tests { | |
| 273 | + use super::*; | |
| 274 | + | |
| 275 | + fn walled(map: &mut WorldMap, lo: UVec2, hi: UVec2) { | |
| 276 | + for y in lo.y..=hi.y { | |
| 277 | + for x in lo.x..=hi.x { | |
| 278 | + if x == lo.x || x == hi.x || y == lo.y || y == hi.y { | |
| 279 | + map.set_wall(UVec2::new(x, y), true); | |
| 280 | + } | |
| 281 | + } | |
| 282 | + } | |
| 283 | + } | |
| 284 | + | |
| 285 | + // Rooms come from the map, not from plans: any enclosed space with a door that is big enough | |
| 286 | + // and small enough to roof counts, however and in whatever order it was built, and two rooms | |
| 287 | + // can share a wall. | |
| 288 | + #[test] | |
| 289 | + fn rooms_are_enclosed_spaces_with_a_door() { | |
| 290 | + let mut map = WorldMap::filled(UVec2::splat(30), Terrain::Grass); | |
| 291 | + walled(&mut map, UVec2::new(2, 2), UVec2::new(6, 5)); | |
| 292 | + walled(&mut map, UVec2::new(6, 2), UVec2::new(10, 5)); | |
| 293 | + walled(&mut map, UVec2::new(15, 15), UVec2::new(18, 18)); | |
| 294 | + let doors = [UVec2::new(4, 2), UVec2::new(8, 2)]; | |
| 295 | + for door in doors { | |
| 296 | + map.set_wall(door, false); | |
| 297 | + } | |
| 298 | + let rooms = detect_rooms(&map, &doors.into_iter().collect()); | |
| 299 | + assert_eq!(rooms.len(), 2); | |
| 300 | + assert!( | |
| 301 | + rooms | |
| 302 | + .iter() | |
| 303 | + .all(|(tiles, doors)| tiles.len() == 6 && doors.len() == 1) | |
| 304 | + ); | |
| 305 | + } | |
| 306 | + | |
| 307 | + #[test] | |
| 308 | + fn quality_rewards_floors_furniture_and_space() { | |
| 309 | + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass); | |
| 310 | + let tiles: Vec<UVec2> = (1..5) | |
| 311 | + .flat_map(|y| (1..5).map(move |x| UVec2::new(x, y))) | |
| 312 | + .collect(); | |
| 313 | + let bare = room_quality(&map, &tiles, Furnishing::default()); | |
| 314 | + for &t in &tiles { | |
| 315 | + map.set(t, Terrain::WoodFloor); | |
| 316 | + map.set_roof(t, true); | |
| 317 | + } | |
| 318 | + let furnished = Furnishing { | |
| 319 | + beds: 2, | |
| 320 | + statue: true, | |
| 321 | + hearth: true, | |
| 322 | + }; | |
| 323 | + let nice = room_quality(&map, &tiles, furnished); | |
| 324 | + assert!(nice > bare + 0.5, "{bare} {nice}"); | |
| 325 | + let packed = Furnishing { | |
| 326 | + beds: 6, | |
| 327 | + ..furnished | |
| 328 | + }; | |
| 329 | + assert!(room_quality(&map, &tiles, packed) < nice); | |
| 330 | + } | |
| 331 | + | |
| 332 | + // A door needs walls on two opposite sides and open ground on the others to be passable; one | |
| 333 | + // boxed in by walls on three sides leads nowhere and gets taken down. | |
| 334 | + #[test] | |
| 335 | + fn doors_walled_in_on_three_sides_are_bad() { | |
| 336 | + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass); | |
| 337 | + map.set_wall(UVec2::new(4, 5), true); | |
| 338 | + map.set_wall(UVec2::new(6, 5), true); | |
| 339 | + assert!(!bad_door(&map, UVec2::new(5, 5))); | |
| 340 | + map.set_wall(UVec2::new(5, 6), true); | |
| 341 | + assert!(bad_door(&map, UVec2::new(5, 5))); | |
| 342 | + } | |
| 343 | + | |
| 344 | + // A room built by hand, wall by wall, gets a roof once it has a bed in it; a door boxed in | |
| 345 | + // on three sides gets taken down. | |
| 346 | + #[test] | |
| 347 | + fn lived_in_rooms_are_roofed_and_bad_doors_removed() { | |
| 348 | + let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass); | |
| 349 | + walled(&mut map, UVec2::new(2, 2), UVec2::new(6, 5)); | |
| 350 | + let door = UVec2::new(4, 2); | |
| 351 | + map.set_wall(door, false); | |
| 352 | + let boxed = UVec2::new(12, 12); | |
| 353 | + for side in [IVec2::X, IVec2::NEG_X, IVec2::Y] { | |
| 354 | + map.set_wall((boxed.as_ivec2() + side).as_uvec2(), true); | |
| 355 | + } | |
| 356 | + let mut app = App::new(); | |
| 357 | + app.insert_resource(map) | |
| 358 | + .init_resource::<SimTick>() | |
| 359 | + .init_resource::<Rooms>() | |
| 360 | + .init_resource::<Blueprints>() | |
| 361 | + .add_systems(Update, (update_rooms, roof_rooms, remove_bad_doors).chain()); | |
| 362 | + let structure = |kind| Structure { kind }; | |
| 363 | + let world = app.world_mut(); | |
| 364 | + world.spawn((structure(StructureKind::Door), TilePos(door))); | |
| 365 | + world.spawn((structure(StructureKind::Door), TilePos(boxed))); | |
| 366 | + world.spawn((structure(StructureKind::Bed), TilePos(UVec2::new(3, 3)))); | |
| 367 | + app.update(); | |
| 368 | + let blueprints = app.world().resource::<Blueprints>(); | |
| 369 | + let roofs = blueprints | |
| 370 | + .0 | |
| 371 | + .values() | |
| 372 | + .filter(|k| **k == BuildKind::Roof) | |
| 373 | + .count(); | |
| 374 | + assert_eq!(roofs, 6); | |
| 375 | + assert_eq!(blueprints.get(boxed), Some(BuildKind::Deconstruct)); | |
| 376 | + assert_eq!(blueprints.get(door), None); | |
| 377 | + let rooms = app.world().resource::<Rooms>(); | |
| 378 | + assert!(rooms.at(UVec2::new(3, 3)).is_some_and(|r| r.quality > 0.0)); | |
| 379 | + } | |
| 380 | +} | |
src/selection.rs +22 -3
| @@ -17,6 +17,7 @@ use crate::{ | ||
| 17 | 17 | mood::{Age, Mood, Sick}, |
| 18 | 18 | needs::Rest, |
| 19 | 19 | population::ColonyLog, |
| 20 | + rooms::Rooms, | |
| 20 | 21 | sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day}, |
| 21 | 22 | skills::Skills, |
| 22 | 23 | structures::{Fuel, Structure, StructureKind}, |
| @@ -268,7 +269,12 @@ fn update_tile_info( | ||
| 268 | 269 | goals: Res<ColonyGoals>, |
| 269 | 270 | log: Res<ColonyLog>, |
| 270 | 271 | zones: Res<Zones>, |
| 271 | - tools: (Res<ZoneTool>, Res<BuildTool>, Res<DesignateTool>), | |
| 272 | + tools: ( | |
| 273 | + Res<ZoneTool>, | |
| 274 | + Res<BuildTool>, | |
| 275 | + Res<DesignateTool>, | |
| 276 | + Res<Rooms>, | |
| 277 | + ), | |
| 272 | 278 | clock: ( |
| 273 | 279 | Res<Time<Virtual>>, |
| 274 | 280 | Res<SimTick>, |
| @@ -391,11 +397,25 @@ fn update_tile_info( | ||
| 391 | 397 | .join(" ") |
| 392 | 398 | }; |
| 393 | 399 | let materials = format!("{}\n{}", stock_line(false), stock_line(true)); |
| 400 | + let (tool, build_tool, designate_tool, rooms) = tools; | |
| 401 | + let hovered_room = cursor | |
| 402 | + .tile | |
| 403 | + .and_then(|tile| rooms.at(tile)) | |
| 404 | + .map(|room| { | |
| 405 | + format!( | |
| 406 | + ", room ({} tiles, quality {:.0}%)", | |
| 407 | + room.tiles.len(), | |
| 408 | + room.quality * 100.0 | |
| 409 | + ) | |
| 410 | + }) | |
| 411 | + .unwrap_or_default(); | |
| 394 | 412 | let hovered_zone = match cursor.tile { |
| 395 | 413 | Some(tile) if zones.is_stockpile(tile) => ", Stockpile", |
| 396 | 414 | Some(tile) if zones.is_farm(tile) => ", Farm", |
| 397 | 415 | _ => "", |
| 398 | - }; | |
| 416 | + } | |
| 417 | + .to_string() | |
| 418 | + + &hovered_room; | |
| 399 | 419 | let hovered_items: String = items |
| 400 | 420 | .iter() |
| 401 | 421 | .filter(|(_, pos)| Some(pos.0) == cursor.tile) |
| @@ -425,7 +445,6 @@ fn update_tile_info( | ||
| 425 | 445 | ) |
| 426 | 446 | .collect(); |
| 427 | 447 | let events: String = log.0.iter().map(|line| format!("\n{line}")).collect(); |
| 428 | - let (tool, build_tool, designate_tool) = tools; | |
| 429 | 448 | let tool_hint = match (tool.mode, build_tool.mode) { |
| 430 | 449 | _ if designate_tool.active => { |
| 431 | 450 | "\nCHOP/MINE: drag over trees and rock, shift-drag to clear".to_string() |
tasks.md +1 -0
| @@ -52,6 +52,7 @@ | ||
| 52 | 52 | - [x] Family hearths: every home gets a hearth where the family cooks (meals stay home) and which heats the home; growth needs 3 days of food per colonist; failed food searches back off |
| 53 | 53 | - [x] Demolish tool (Q / toolbar): drag to have colonists take down structures and roofs; torn-down homes are forgotten |
| 54 | 54 | - [x] Medicine: healing herbs, Medicine skill and Doctor work; doctors tend the wounded (faster healing), the badly wounded rest in bed |
| 55 | +- [x] Dynamic rooms: detected from walls/doors (any build order), roofed when lived in, homes share walls and only plan missing ones, any rectangular room with 2 beds can become a home, boxed-in doors removed; room quality (space, floor, roof, statue, hearth, crowding) drives mood and shows on hover | |
| 55 | 56 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 56 | 57 | |
| 57 | 58 | ## Planned |