Add family hearths; tie growth to stored food; back off failed food searches
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 14:35 UTC
- commit
- 944828980207162e7270402048c1ef9f865c5f64
- parent
- 28fbce3a75
- tree
- browse at this commit
10 files changed +206 -39
| CLAUDE.md | +4 -2 | |
| src/building.rs | +4 -2 | |
| src/family.rs | +19 -5 | |
| src/food.rs | +24 -5 | |
| src/jobs/mod.rs | +55 -20 | |
| src/jobs/tests.rs | +57 -0 | |
| src/population.rs | +7 -4 | |
| src/selection.rs | +5 -1 | |
| src/structures.rs | +30 -0 | |
| tasks.md | +1 -0 |
CLAUDE.md +4 -2
| @@ -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`), 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, 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. | |
| 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. |
| @@ -52,6 +52,8 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 52 | 52 | - `src/skills.rs` `Skills{xp: [f32; 8]}` (required on Colonist, default = level 5 everywhere so tests keep old speeds; `assign_age` gives adults `Skills::random` (levels 1–7 + one talent at 10), children level 0). `Skill::{Mining, Woodcutting, Construction, Farming, Cooking, Hunting, Crafting, Taming}`, `Skill::of(task)`; level = √(xp/600) ≤ 20; `speed(level)` = 0.5 + 0.1·level. run_jobs: rate (after light/sick/tool) × speed via `WorkCarry` fractional accumulator; +1 xp per tick the task's `Task::progress()` advances. choose_jobs candidates are `(WorkType, Option<Skill>, finder)`; candidates whose skill is < POOR_LEVEL 3 run after all others (cooking, crafting, blueprints, bed sites, hunting, taming, plain sowing; urgent food/fire work has no skill). Selection shows top 3 skills; saved in ColonistSave (serde default). |
| 53 | 53 | - `src/threats.rs` wolves and health. `AnimalKind::Wolf` (`is_predator`, population 3, `migration_ticks` 24 h vs 2 h, not tameable, meat 12) spawns with `Predator{target, fed_until, cooldown}`. `Health(f32)` required on Colonist and Animal. `prowl` (Decide, every 30 ticks): unfed wolves `pick_prey` (nearest within PROWL_RANGE 24, colonists cost +COLONIST_DETOUR 6) and path adjacent; `bite` (Act): adjacent, cooldown 60 → colonist −0.25 (log on first bite; death drops load, releases, Losses, log), animal −0.5 (despawn); a kill feeds the wolf FED_TICKS 12 h. `heal` (every 60 ticks): 0.3/day, ×3 asleep in a bed. Wild prey flees wolves too (wander threats include predators); wolves skip flee/wander while targeting. choose_jobs: `nearest_wolf`; a wolf within DANGER_RANGE 6 makes colonists ready and interrupts autonomous jobs (except wolf hunts/MoveTo); fighters (adult, health ≥ INJURED 0.5, Food work allowed, not exhausted/hungry) Hunt a reachable (`ColonyReach::allows_near`) unreserved wolf in danger range or within DEFEND_RANGE 15 of colony home (first candidate); others in danger flee (`flee_from`, 10 tiles away). Mood −0.1 below HURT 0.75; selection shows health; saved in ColonistSave. |
| 54 | 54 | - 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. |
| 55 | +- 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. | |
| 56 | +- 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. | |
| 55 | 57 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 56 | 58 | |
| 57 | 59 | ## Performance notes |
| @@ -63,7 +65,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 63 | 65 | - Fixed schedules (FixedFirst..FixedLast) use `SingleThreadedExecutor`: with hundreds of tiny ticks per frame the multi-threaded executor spent most time parking/handing off between threads (5.6× faster natively; wasm was already single-threaded). |
| 64 | 66 | - A* (`astar_limited`) reuses thread-local buffers with generation stamps (no 800 KB clear per call); `find_nearby_path(.., WANDER_SEARCH 800)` caps wander searches. Failed wander attempts (animals, colonists) still restart the idle timer and failed flees wait FLEE_RETRY_SECS — retrying every tick for boxed-in animals made late games ~15× slower per tick. |
| 65 | 67 | - plan_homes caches its reach flood by (`shape_version`, colony home, size); `WorldMap::set` bumps `shape_version` when walkability changes. `GatherMemo` also memoises forage (food mask) and haul failures (HAUL_RETRY_TICKS 120, reset when Zones change — checked before choose_jobs' early return). choose_jobs builds `occupied` lazily; run_jobs only collects mover positions while someone builds a wall. |
| 66 | -- 20-colonist save natively: ~17–20 µs per tick, ~23 game days per 10 s. Uncapped, that save grows to ~40–46 colonists (births vs old age at 60) at ~36 µs per tick, ~12 game days per 10 s. | |
| 68 | +- 20-colonist save natively: ~17–20 µs per tick, ~23 game days per 10 s. Uncapped with hearths, that save settles at ~70–90 colonists (births vs old age at 60, growth food rule) at ~75 µs per tick, ~6 game days per 10 s. | |
| 67 | 69 | - `ColonyReach::nearest(map, from, max_tiles, targets)`: when `from` is inside the colony reach, picks the nearest reachable target by distance (no flood); else falls back to `nearest_of`. Used by find_haul, find_storage (orders pass an empty reach), material_source, input_source. Cooking/toolmaking check stations before searching inputs; find_blueprint_work caches material lookups per call, has a reach precheck and a failure memo (reset when Blueprints change). Stock totals are one pass over items; the starving food-tile set is built lazily. |
| 68 | 70 | - `ColonyLog::push` also logs at debug level; run benchmarks with `RUST_LOG=info,simulator=debug` to see colony events. |
| 69 | 71 | - Browser timings are only valid while the pane is visible: a hidden/popped-out-behind pane throttles rAF to ~1 Hz (check `tabs_context`); otherwise measure natively (temporary Update system logging SimTick per 10 s real time). |
src/building.rs +4 -2
| @@ -68,7 +68,7 @@ impl BuildKind { | ||
| 68 | 68 | BuildKind::Roof => (ItemKind::Wood, 1), |
| 69 | 69 | BuildKind::Pave => (ItemKind::Stone, 1), |
| 70 | 70 | BuildKind::Floor => (ItemKind::Wood, 1), |
| 71 | - BuildKind::Refuel(kind) => kind.cost(), | |
| 71 | + BuildKind::Refuel(kind) => kind.refuel_cost(), | |
| 72 | 72 | BuildKind::Deconstruct => (ItemKind::Wood, 0), |
| 73 | 73 | } |
| 74 | 74 | } |
| @@ -82,6 +82,7 @@ impl BuildKind { | ||
| 82 | 82 | BuildKind::Structure(StructureKind::Door) => 300, |
| 83 | 83 | BuildKind::Structure(StructureKind::Workbench) => 600, |
| 84 | 84 | BuildKind::Structure(StructureKind::Statue) => 900, |
| 85 | + BuildKind::Structure(StructureKind::Hearth) => 400, | |
| 85 | 86 | BuildKind::Roof => 120, |
| 86 | 87 | BuildKind::Pave => 120, |
| 87 | 88 | BuildKind::Floor => 90, |
| @@ -315,7 +316,7 @@ pub enum BuildMode { | ||
| 315 | 316 | Deconstruct, |
| 316 | 317 | } |
| 317 | 318 | |
| 318 | -pub const BUILD_TOOL_CYCLE: [BuildMode; 9] = [ | |
| 319 | +pub const BUILD_TOOL_CYCLE: [BuildMode; 10] = [ | |
| 319 | 320 | BuildMode::Place(StructureKind::Wall), |
| 320 | 321 | BuildMode::Place(StructureKind::Door), |
| 321 | 322 | BuildMode::Place(StructureKind::Campfire), |
| @@ -323,6 +324,7 @@ pub const BUILD_TOOL_CYCLE: [BuildMode; 9] = [ | ||
| 323 | 324 | BuildMode::Place(StructureKind::Bed), |
| 324 | 325 | BuildMode::Place(StructureKind::Workbench), |
| 325 | 326 | BuildMode::Place(StructureKind::Statue), |
| 327 | + BuildMode::Place(StructureKind::Hearth), | |
| 326 | 328 | BuildMode::Floor, |
| 327 | 329 | BuildMode::Deconstruct, |
| 328 | 330 | ]; |
src/family.rs +19 -5
| @@ -9,7 +9,7 @@ use crate::{ | ||
| 9 | 9 | economy::in_colony, |
| 10 | 10 | items::{Item, ItemAssets}, |
| 11 | 11 | pathfinding::{ColonyReach, Reach, nearest_tile}, |
| 12 | - population::{ColonyLog, MAX_COLONISTS, food_in_store}, | |
| 12 | + population::{ColonyLog, GROWTH_FOOD_DAYS, MAX_COLONISTS, food_in_store}, | |
| 13 | 13 | rng::SimRng, |
| 14 | 14 | sim::{SimSystems, SimTick, TICKS_PER_HOUR}, |
| 15 | 15 | structures::{Structure, StructureKind}, |
| @@ -128,6 +128,10 @@ impl Home { | ||
| 128 | 128 | self.ring().filter(move |t| !home.is_corner(*t)) |
| 129 | 129 | } |
| 130 | 130 | |
| 131 | + pub fn hearth_tile(&self) -> UVec2 { | |
| 132 | + self.hi - UVec2::ONE | |
| 133 | + } | |
| 134 | + | |
| 131 | 135 | pub fn statue_tile(&self) -> UVec2 { |
| 132 | 136 | UVec2::new(self.hi.x - 1, self.lo.y + 1) |
| 133 | 137 | } |
| @@ -469,6 +473,7 @@ fn plan_homes( | ||
| 469 | 473 | }; |
| 470 | 474 | let doors = of_kind(StructureKind::Door); |
| 471 | 475 | let statues = of_kind(StructureKind::Statue); |
| 476 | + let hearths = of_kind(StructureKind::Hearth); | |
| 472 | 477 | let beds = of_kind(StructureKind::Bed); |
| 473 | 478 | for &bed in &beds { |
| 474 | 479 | if !homes.is_home(bed) |
| @@ -532,6 +537,10 @@ fn plan_homes( | ||
| 532 | 537 | blueprints.0.insert(tile, BuildKind::Roof); |
| 533 | 538 | } |
| 534 | 539 | if built && owned.contains(home) { |
| 540 | + if !hearths.iter().any(|&t| home.contains(t)) { | |
| 541 | + let hearth = BuildKind::Structure(StructureKind::Hearth); | |
| 542 | + blueprints.0.insert(home.hearth_tile(), hearth); | |
| 543 | + } | |
| 535 | 544 | if !statues.iter().any(|&t| home.contains(t)) { |
| 536 | 545 | let statue = BuildKind::Structure(StructureKind::Statue); |
| 537 | 546 | blueprints.0.insert(home.statue_tile(), statue); |
| @@ -621,7 +630,7 @@ fn births( | ||
| 621 | 630 | .filter(|(pos, s)| s.kind == StructureKind::Bed && home.contains(pos.0)) |
| 622 | 631 | .count(); |
| 623 | 632 | let built = home.interior().all(|t| map.is_roofed(t.as_ivec2())); |
| 624 | - let room = count + born < MAX_COLONISTS && food >= (count + born) as f32; | |
| 633 | + let room = count + born < MAX_COLONISTS && food >= (count + born) as f32 * GROWTH_FOOD_DAYS; | |
| 625 | 634 | if parents.len() < 2 || children >= MAX_CHILDREN || beds < 2 || !built || !room { |
| 626 | 635 | continue; |
| 627 | 636 | } |
| @@ -810,8 +819,9 @@ mod tests { | ||
| 810 | 819 | } |
| 811 | 820 | |
| 812 | 821 | // Gold has one use: a statue in each finished home, which lifts the family's mood. Planning |
| 813 | - // it is what makes the colony want gold at all. Finished homes also get a wooden floor (a | |
| 814 | - // smaller mood boost), built under beds and statues alike. | |
| 822 | + // it is what makes the colony want gold at all. Finished homes also get a hearth, where the | |
| 823 | + // family cooks (one campfire can't feed a big colony) and which heats the home, and a wooden | |
| 824 | + // floor (a smaller mood boost), built under beds, statue and hearth alike. | |
| 815 | 825 | #[test] |
| 816 | 826 | fn finished_homes_get_a_statue_and_floor() { |
| 817 | 827 | let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass); |
| @@ -852,7 +862,11 @@ mod tests { | ||
| 852 | 862 | .interior() |
| 853 | 863 | .filter(|t| blueprints.get(*t) == Some(BuildKind::Floor)) |
| 854 | 864 | .count(); |
| 855 | - assert_eq!(floors, home.interior().count() - 1); | |
| 865 | + assert_eq!(floors, home.interior().count() - 2); | |
| 866 | + assert_eq!( | |
| 867 | + blueprints.get(home.hearth_tile()), | |
| 868 | + Some(BuildKind::Structure(StructureKind::Hearth)) | |
| 869 | + ); | |
| 856 | 870 | } |
| 857 | 871 | |
| 858 | 872 | fn walled(map: &mut WorldMap, home: Home, door: UVec2) -> HashSet<UVec2> { |
src/food.rs +24 -5
| @@ -8,7 +8,7 @@ use crate::{ | ||
| 8 | 8 | items::{Carrying, Item, ItemKind}, |
| 9 | 9 | jobs::{CurrentJob, Reservations, Target}, |
| 10 | 10 | needs::is_sleeping, |
| 11 | - pathfinding::nearest_of, | |
| 11 | + pathfinding::ColonyReach, | |
| 12 | 12 | sim::{SimSystems, SimTick, TICKS_PER_HOUR}, |
| 13 | 13 | world::{TilePos, WorldMap}, |
| 14 | 14 | }; |
| @@ -76,6 +76,7 @@ pub fn portion(kind: ItemKind, fed: f32) -> u32 { | ||
| 76 | 76 | pub fn find_food( |
| 77 | 77 | map: &WorldMap, |
| 78 | 78 | reservations: &Reservations, |
| 79 | + reach: &ColonyReach, | |
| 79 | 80 | items: &[(Item, TilePos)], |
| 80 | 81 | from: UVec2, |
| 81 | 82 | ) -> Option<UVec2> { |
| @@ -86,7 +87,7 @@ pub fn find_food( | ||
| 86 | 87 | .map(|(_, pos)| pos.0) |
| 87 | 88 | .collect() |
| 88 | 89 | }; |
| 89 | - let search = |tiles: HashSet<UVec2>| nearest_of(map, from, FOOD_SEARCH_TILES, &tiles); | |
| 90 | + let search = |tiles: HashSet<UVec2>| reach.nearest(map, from, FOOD_SEARCH_TILES, &tiles); | |
| 90 | 91 | search(tiles(|k| k == ItemKind::Meal)).or_else(|| search(tiles(ItemKind::is_raw_food))) |
| 91 | 92 | } |
| 92 | 93 | |
| @@ -216,15 +217,33 @@ mod tests { | ||
| 216 | 217 | food(ItemKind::Wood, 0), |
| 217 | 218 | ]; |
| 218 | 219 | assert_eq!( |
| 219 | - find_food(&map, &reservations, &items, UVec2::ZERO), | |
| 220 | + find_food( | |
| 221 | + &map, | |
| 222 | + &reservations, | |
| 223 | + &ColonyReach::default(), | |
| 224 | + &items, | |
| 225 | + UVec2::ZERO | |
| 226 | + ), | |
| 220 | 227 | Some(UVec2::new(15, 0)) |
| 221 | 228 | ); |
| 222 | 229 | assert_eq!( |
| 223 | - find_food(&map, &reservations, &items[..1], UVec2::ZERO), | |
| 230 | + find_food( | |
| 231 | + &map, | |
| 232 | + &reservations, | |
| 233 | + &ColonyReach::default(), | |
| 234 | + &items[..1], | |
| 235 | + UVec2::ZERO | |
| 236 | + ), | |
| 224 | 237 | Some(UVec2::new(1, 0)) |
| 225 | 238 | ); |
| 226 | 239 | assert_eq!( |
| 227 | - find_food(&map, &reservations, &items[2..], UVec2::ZERO), | |
| 240 | + find_food( | |
| 241 | + &map, | |
| 242 | + &reservations, | |
| 243 | + &ColonyReach::default(), | |
| 244 | + &items[2..], | |
| 245 | + UVec2::ZERO | |
| 246 | + ), | |
| 228 | 247 | None |
| 229 | 248 | ); |
| 230 | 249 | } |
src/jobs/mod.rs +55 -20
| @@ -14,7 +14,7 @@ use crate::{ | ||
| 14 | 14 | colonist::{Colonist, Path, repath}, |
| 15 | 15 | designations::Designations, |
| 16 | 16 | economy::{ColonyGoals, in_colony}, |
| 17 | - family::{Child, Family, Homes}, | |
| 17 | + family::{Child, Family, Home, Homes}, | |
| 18 | 18 | food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food}, |
| 19 | 19 | items::{ |
| 20 | 20 | CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, STACK_LIMIT, drop_items, |
| @@ -209,10 +209,10 @@ pub enum Recipe { | ||
| 209 | 209 | } |
| 210 | 210 | |
| 211 | 211 | impl Recipe { |
| 212 | - pub fn station(self) -> StructureKind { | |
| 212 | + pub fn stations(self) -> &'static [StructureKind] { | |
| 213 | 213 | match self { |
| 214 | - Recipe::Meal => StructureKind::Campfire, | |
| 215 | - Recipe::Tools => StructureKind::Workbench, | |
| 214 | + Recipe::Meal => &[StructureKind::Campfire, StructureKind::Hearth], | |
| 215 | + Recipe::Tools => &[StructureKind::Workbench], | |
| 216 | 216 | } |
| 217 | 217 | } |
| 218 | 218 | |
| @@ -529,6 +529,7 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) { | ||
| 529 | 529 | |
| 530 | 530 | const GATHER_RETRY_TICKS: u64 = 300; |
| 531 | 531 | const HAUL_RETRY_TICKS: u64 = 120; |
| 532 | +const FOOD_RETRY_TICKS: u64 = 60; | |
| 532 | 533 | |
| 533 | 534 | #[derive(Default, Clone, Copy)] |
| 534 | 535 | pub struct Memo { |
| @@ -548,6 +549,7 @@ pub struct GatherMemo { | ||
| 548 | 549 | forage: Memo, |
| 549 | 550 | haul: Memo, |
| 550 | 551 | build: Memo, |
| 552 | + food: HashMap<Entity, u64>, | |
| 551 | 553 | } |
| 552 | 554 | |
| 553 | 555 | pub fn find_work( |
| @@ -639,6 +641,7 @@ pub fn find_haul( | ||
| 639 | 641 | zones: &Zones, |
| 640 | 642 | reservations: &Reservations, |
| 641 | 643 | reach: &ColonyReach, |
| 644 | + homes: &Homes, | |
| 642 | 645 | items: &[(Item, TilePos)], |
| 643 | 646 | from: UVec2, |
| 644 | 647 | ) -> Option<Task> { |
| @@ -662,6 +665,7 @@ pub fn find_haul( | ||
| 662 | 665 | .filter(|(item, pos)| { |
| 663 | 666 | reservations.is_free(Target::Pickup(pos.0)) |
| 664 | 667 | && reach.allows(pos.0) |
| 668 | + && !(item.kind == ItemKind::Meal && homes.is_home(pos.0)) | |
| 665 | 669 | && misplaced(item, pos.0, &mut best) |
| 666 | 670 | && *room.entry(item.kind).or_insert_with(|| { |
| 667 | 671 | storage_candidates(zones, reservations, &stacks, item.kind) |
| @@ -1023,21 +1027,34 @@ pub fn tool_source( | ||
| 1023 | 1027 | nearest_of(map, from, SEARCH_TILES, &tools) |
| 1024 | 1028 | } |
| 1025 | 1029 | |
| 1026 | -pub fn find_crafting(ctx: &BuildContext, from: UVec2, recipe: Recipe) -> Option<Task> { | |
| 1030 | +pub fn find_crafting( | |
| 1031 | + ctx: &BuildContext, | |
| 1032 | + from: UVec2, | |
| 1033 | + recipe: Recipe, | |
| 1034 | + home: Option<&Home>, | |
| 1035 | +) -> Option<Task> { | |
| 1036 | + let usable = |pos: UVec2, kind: StructureKind| { | |
| 1037 | + kind != StructureKind::Hearth || home.is_some_and(|h| h.contains(pos)) | |
| 1038 | + }; | |
| 1027 | 1039 | let station = ctx |
| 1028 | 1040 | .structures |
| 1029 | 1041 | .iter() |
| 1030 | 1042 | .filter(|&&(pos, kind, fuel)| { |
| 1031 | - kind == recipe.station() | |
| 1043 | + recipe.stations().contains(&kind) | |
| 1044 | + && usable(pos, kind) | |
| 1032 | 1045 | && fuel >= COOK_MIN_FUEL |
| 1033 | 1046 | && ctx.reservations.is_free(Target::Station(pos)) |
| 1034 | 1047 | }) |
| 1035 | - .map(|&(pos, ..)| pos) | |
| 1036 | - .min_by(|a, b| { | |
| 1037 | - a.as_vec2() | |
| 1038 | - .distance_squared(from.as_vec2()) | |
| 1039 | - .total_cmp(&b.as_vec2().distance_squared(from.as_vec2())) | |
| 1040 | - })?; | |
| 1048 | + .min_by_key(|&&(pos, kind, _)| { | |
| 1049 | + let d = pos.as_ivec2() - from.as_ivec2(); | |
| 1050 | + ( | |
| 1051 | + kind != StructureKind::Hearth, | |
| 1052 | + d.length_squared(), | |
| 1053 | + pos.y, | |
| 1054 | + pos.x, | |
| 1055 | + ) | |
| 1056 | + }) | |
| 1057 | + .map(|&(pos, ..)| pos)?; | |
| 1041 | 1058 | let pickup = input_source(ctx, from, recipe)?; |
| 1042 | 1059 | Some(Task::Craft { |
| 1043 | 1060 | recipe, |
| @@ -1120,7 +1137,8 @@ pub fn find_refuel(ctx: &BuildContext, from: UVec2) -> Option<Task> { | ||
| 1120 | 1137 | .filter(|&&(pos, kind, fuel)| { |
| 1121 | 1138 | kind.fuel_ticks() |
| 1122 | 1139 | .is_some_and(|full| fuel < full / REFUEL_BELOW_SHARE) |
| 1123 | - && pos.as_vec2().distance(home.as_vec2()) <= HOME_RADIUS * 2.0 | |
| 1140 | + && (kind == StructureKind::Hearth | |
| 1141 | + || pos.as_vec2().distance(home.as_vec2()) <= HOME_RADIUS * 2.0) | |
| 1124 | 1142 | && ctx.reservations.is_free(Target::Site(pos)) |
| 1125 | 1143 | }) |
| 1126 | 1144 | .min_by_key(|(.., fuel)| *fuel)?; |
| @@ -1260,6 +1278,7 @@ fn choose_jobs( | ||
| 1260 | 1278 | if blueprints.is_changed() { |
| 1261 | 1279 | gather_memo.build = Memo::default(); |
| 1262 | 1280 | } |
| 1281 | + gather_memo.food.retain(|_, until| tick.0 < *until); | |
| 1263 | 1282 | let wolves: Vec<(Entity, UVec2)> = animals |
| 1264 | 1283 | .iter() |
| 1265 | 1284 | .filter(|(_, _, animal, _)| animal.kind.is_predator()) |
| @@ -1351,7 +1370,11 @@ fn choose_jobs( | ||
| 1351 | 1370 | ) in &mut colonists |
| 1352 | 1371 | { |
| 1353 | 1372 | let exhausted = rest.0 < EXHAUSTED_REST; |
| 1354 | - let starving = hunger.0 < STARVING && { | |
| 1373 | + let skip_food = gather_memo | |
| 1374 | + .food | |
| 1375 | + .get(&entity) | |
| 1376 | + .is_some_and(|&until| tick.0 < until); | |
| 1377 | + let starving = hunger.0 < STARVING && !skip_food && { | |
| 1355 | 1378 | let tiles = food_tiles.get_or_insert_with(|| { |
| 1356 | 1379 | items |
| 1357 | 1380 | .iter() |
| @@ -1400,9 +1423,12 @@ fn choose_jobs( | ||
| 1400 | 1423 | family: family.copied(), |
| 1401 | 1424 | child, |
| 1402 | 1425 | }; |
| 1403 | - let food = wants_food(hunger.0) | |
| 1404 | - .then(|| find_food(&map, &reservations, &snapshot, tile.0)) | |
| 1426 | + let food = (wants_food(hunger.0) && !skip_food) | |
| 1427 | + .then(|| find_food(&map, &reservations, &reach, &snapshot, tile.0)) | |
| 1405 | 1428 | .flatten(); |
| 1429 | + if wants_food(hunger.0) && !skip_food && food.is_none() { | |
| 1430 | + gather_memo.food.insert(entity, tick.0 + FOOD_RETRY_TICKS); | |
| 1431 | + } | |
| 1406 | 1432 | let fighter = !child && health.0 >= INJURED && priorities.allows(WorkType::Food); |
| 1407 | 1433 | let free_to_fight = fighter && !exhausted && !wants_food(hunger.0); |
| 1408 | 1434 | let defend = |
| @@ -1488,11 +1514,12 @@ fn choose_jobs( | ||
| 1488 | 1514 | occupied, |
| 1489 | 1515 | }; |
| 1490 | 1516 | let has_input = |recipe| input_source(&build, tile.0, recipe).is_some(); |
| 1517 | + let own_home = homes.of(family.copied()); | |
| 1491 | 1518 | let cooking = || { |
| 1492 | 1519 | if !wanted(ItemKind::Meal) { |
| 1493 | 1520 | return None; |
| 1494 | 1521 | } |
| 1495 | - find_crafting(&build, tile.0, Recipe::Meal).or_else(|| { | |
| 1522 | + find_crafting(&build, tile.0, Recipe::Meal, own_home).or_else(|| { | |
| 1496 | 1523 | (build.campfire_needed(zones.home(tile.0)) && has_input(Recipe::Meal)) |
| 1497 | 1524 | .then(|| find_campfire(&build, tile.0)) |
| 1498 | 1525 | .flatten() |
| @@ -1502,7 +1529,7 @@ fn choose_jobs( | ||
| 1502 | 1529 | if !wanted(ItemKind::Tools) { |
| 1503 | 1530 | return None; |
| 1504 | 1531 | } |
| 1505 | - find_crafting(&build, tile.0, Recipe::Tools).or_else(|| { | |
| 1532 | + find_crafting(&build, tile.0, Recipe::Tools, None).or_else(|| { | |
| 1506 | 1533 | (build.count(StructureKind::Workbench) == 0 && has_input(Recipe::Tools)) |
| 1507 | 1534 | .then(|| find_workbench(&build, tile.0)) |
| 1508 | 1535 | .flatten() |
| @@ -1603,7 +1630,15 @@ fn choose_jobs( | ||
| 1603 | 1630 | if skip_haul { |
| 1604 | 1631 | return None; |
| 1605 | 1632 | } |
| 1606 | - let haul = find_haul(&map, &zones, &reservations, &reach, &snapshot, tile.0); | |
| 1633 | + let haul = find_haul( | |
| 1634 | + &map, | |
| 1635 | + &zones, | |
| 1636 | + &reservations, | |
| 1637 | + &reach, | |
| 1638 | + &homes, | |
| 1639 | + &snapshot, | |
| 1640 | + tile.0, | |
| 1641 | + ); | |
| 1607 | 1642 | haul_failed.set(haul.is_none()); |
| 1608 | 1643 | haul |
| 1609 | 1644 | }), |
| @@ -1814,7 +1849,7 @@ fn run_jobs( | ||
| 1814 | 1849 | let input = recipe.input(); |
| 1815 | 1850 | let fire = structures |
| 1816 | 1851 | .iter() |
| 1817 | - .any(|(_, pos, s, _)| pos.0 == station && s.kind == recipe.station()); | |
| 1852 | + .any(|(_, pos, s, _)| pos.0 == station && recipe.stations().contains(&s.kind)); | |
| 1818 | 1853 | let ours = reservations.holder(Target::Station(station)) == Some(entity); |
| 1819 | 1854 | if !fire || !ours { |
| 1820 | 1855 | Step::Done |
src/jobs/tests.rs +57 -0
| @@ -1554,3 +1554,60 @@ fn adults_fight_wolves_and_children_flee() { | ||
| 1554 | 1554 | let route = &app.world().get::<Path>(child).unwrap().0; |
| 1555 | 1555 | assert!(route.back().is_some_and(|end| end.x > 26)); |
| 1556 | 1556 | } |
| 1557 | + | |
| 1558 | +// Families cook at the hearth in their own home (another family's hearth is off limits, even if | |
| 1559 | +// it is closer), and the meals they make stay in the home instead of being hauled away. | |
| 1560 | +#[test] | |
| 1561 | +fn families_cook_at_their_own_hearth_and_keep_the_meals() { | |
| 1562 | + let mut app = test_app(WorldMap::filled(UVec2::splat(30), Terrain::Grass)); | |
| 1563 | + set_goals(&mut app, &[(ItemKind::Meal, 5)]); | |
| 1564 | + let own = crate::family::Home::at(UVec2::new(15, 3)); | |
| 1565 | + let other = crate::family::Home::at(UVec2::new(3, 3)); | |
| 1566 | + app.world_mut() | |
| 1567 | + .resource_mut::<crate::family::Homes>() | |
| 1568 | + .by_family | |
| 1569 | + .extend([(1, own), (2, other)]); | |
| 1570 | + for home in [own, other] { | |
| 1571 | + app.world_mut().spawn(( | |
| 1572 | + Structure { | |
| 1573 | + kind: StructureKind::Hearth, | |
| 1574 | + }, | |
| 1575 | + TilePos(home.hearth_tile()), | |
| 1576 | + )); | |
| 1577 | + } | |
| 1578 | + app.world_mut() | |
| 1579 | + .spawn((Item::new(ItemKind::Berries, 20), TilePos(UVec2::new(9, 9)))); | |
| 1580 | + let cook = spawn_colonist(&mut app, UVec2::new(9, 6)); | |
| 1581 | + app.world_mut() | |
| 1582 | + .entity_mut(cook) | |
| 1583 | + .insert(crate::family::Family(1)); | |
| 1584 | + let mut first = None; | |
| 1585 | + for _ in 0..600 { | |
| 1586 | + run_ticks(&mut app, 1); | |
| 1587 | + if let Some(job) = job(&app, cook) | |
| 1588 | + && !matches!(job.task, Task::ExpandStockpile { .. } | Task::Haul { .. }) | |
| 1589 | + { | |
| 1590 | + first = Some(job.task); | |
| 1591 | + break; | |
| 1592 | + } | |
| 1593 | + } | |
| 1594 | + assert!( | |
| 1595 | + matches!(first, Some(Task::Craft { station, .. }) if station == own.hearth_tile()), | |
| 1596 | + "{first:?}" | |
| 1597 | + ); | |
| 1598 | + let meal_home = UVec2::new(16, 4); | |
| 1599 | + app.world_mut() | |
| 1600 | + .spawn((Item::new(ItemKind::Meal, 1), TilePos(meal_home))); | |
| 1601 | + let snapshot: Vec<(Item, TilePos)> = vec![(Item::new(ItemKind::Meal, 1), TilePos(meal_home))]; | |
| 1602 | + let world = app.world(); | |
| 1603 | + let haul = find_haul( | |
| 1604 | + world.resource::<WorldMap>(), | |
| 1605 | + world.resource::<Zones>(), | |
| 1606 | + world.resource::<Reservations>(), | |
| 1607 | + &crate::pathfinding::ColonyReach::default(), | |
| 1608 | + world.resource::<crate::family::Homes>(), | |
| 1609 | + &snapshot, | |
| 1610 | + UVec2::new(9, 6), | |
| 1611 | + ); | |
| 1612 | + assert_eq!(haul, None); | |
| 1613 | +} | |
src/population.rs +7 -4
| @@ -34,6 +34,7 @@ impl Plugin for PopulationPlugin { | ||
| 34 | 34 | |
| 35 | 35 | pub const STARVATION_TICKS: u32 = 24 * TICKS_PER_HOUR as u32; |
| 36 | 36 | pub const MAX_COLONISTS: usize = usize::MAX; |
| 37 | +pub const GROWTH_FOOD_DAYS: f32 = 3.0; | |
| 37 | 38 | pub const ARRIVAL_CHANCE: f32 = 0.6; |
| 38 | 39 | const ARRIVAL_ATTEMPTS: usize = 10; |
| 39 | 40 | const ARRIVAL_HOUR_TICK: u64 = 4 * TICKS_PER_HOUR; |
| @@ -101,7 +102,7 @@ pub fn food_in_store<'a>(items: impl Iterator<Item = &'a Item>) -> f32 { | ||
| 101 | 102 | |
| 102 | 103 | pub fn prosperous(colonists: usize, food: f32, free_beds: usize, wood: u32) -> bool { |
| 103 | 104 | colonists < MAX_COLONISTS |
| 104 | - && food >= colonists.max(1) as f32 | |
| 105 | + && food >= colonists.max(1) as f32 * GROWTH_FOOD_DAYS | |
| 105 | 106 | && (free_beds > 0 || wood >= StructureKind::Bed.cost().1) |
| 106 | 107 | } |
| 107 | 108 | |
| @@ -193,11 +194,13 @@ mod tests { | ||
| 193 | 194 | use super::*; |
| 194 | 195 | use crate::world::Terrain; |
| 195 | 196 | |
| 197 | + // Growth needs a few days of food in store per colonist. With only a day's worth, plentiful | |
| 198 | + // cooking at family hearths let births boom to 120 colonists, followed by famine. | |
| 196 | 199 | #[test] |
| 197 | 200 | fn colonies_grow_only_when_they_can_feed_and_house_newcomers() { |
| 198 | - assert!(prosperous(3, 3.5, 1, 0)); | |
| 199 | - assert!(prosperous(3, 3.5, 0, 20)); | |
| 200 | - assert!(!prosperous(3, 2.0, 1, 50)); | |
| 201 | + assert!(prosperous(3, 9.5, 1, 0)); | |
| 202 | + assert!(prosperous(3, 9.5, 0, 20)); | |
| 203 | + assert!(!prosperous(3, 5.0, 1, 50)); | |
| 201 | 204 | assert!(!prosperous(3, 10.0, 0, 5)); |
| 202 | 205 | assert!(!prosperous(MAX_COLONISTS, 100.0, 5, 100)); |
| 203 | 206 | } |
src/selection.rs +5 -1
| @@ -634,7 +634,11 @@ pub fn describe_colonist(view: &ColonistView) -> String { | ||
| 634 | 634 | _, |
| 635 | 635 | ) => "fetching ingredients".to_string(), |
| 636 | 636 | (Some(Task::Craft { recipe, .. }), n) if n > 0 => { |
| 637 | - format!("carrying ingredients to the {}", recipe.station().name()) | |
| 637 | + let station = match recipe { | |
| 638 | + Recipe::Meal => "fire", | |
| 639 | + Recipe::Tools => "workbench", | |
| 640 | + }; | |
| 641 | + format!("carrying ingredients to the {station}") | |
| 638 | 642 | } |
| 639 | 643 | ( |
| 640 | 644 | Some( |
src/structures.rs +30 -0
| @@ -33,6 +33,7 @@ pub enum StructureKind { | ||
| 33 | 33 | Door, |
| 34 | 34 | Workbench, |
| 35 | 35 | Statue, |
| 36 | + Hearth, | |
| 36 | 37 | } |
| 37 | 38 | |
| 38 | 39 | impl StructureKind { |
| @@ -45,6 +46,14 @@ impl StructureKind { | ||
| 45 | 46 | StructureKind::Door => (ItemKind::Wood, 5), |
| 46 | 47 | StructureKind::Workbench => (ItemKind::Wood, 20), |
| 47 | 48 | StructureKind::Statue => (ItemKind::Gold, 5), |
| 49 | + StructureKind::Hearth => (ItemKind::Stone, 6), | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + pub fn refuel_cost(self) -> (ItemKind, u32) { | |
| 54 | + match self { | |
| 55 | + StructureKind::Hearth => (ItemKind::Wood, 4), | |
| 56 | + _ => self.cost(), | |
| 48 | 57 | } |
| 49 | 58 | } |
| 50 | 59 | |
| @@ -52,6 +61,7 @@ impl StructureKind { | ||
| 52 | 61 | let hours = match self { |
| 53 | 62 | StructureKind::Campfire => 12, |
| 54 | 63 | StructureKind::Torch => 8, |
| 64 | + StructureKind::Hearth => 24, | |
| 55 | 65 | _ => return None, |
| 56 | 66 | }; |
| 57 | 67 | Some(hours * TICKS_PER_HOUR as u32) |
| @@ -61,6 +71,7 @@ impl StructureKind { | ||
| 61 | 71 | match self { |
| 62 | 72 | StructureKind::Campfire => Some((7.0, Vec3::new(1.1, 0.8, 0.5))), |
| 63 | 73 | StructureKind::Torch => Some((4.0, Vec3::new(1.0, 0.8, 0.55))), |
| 74 | + StructureKind::Hearth => Some((3.5, Vec3::new(1.0, 0.75, 0.45))), | |
| 64 | 75 | _ => None, |
| 65 | 76 | } |
| 66 | 77 | } |
| @@ -69,6 +80,7 @@ impl StructureKind { | ||
| 69 | 80 | match self { |
| 70 | 81 | StructureKind::Campfire => 20.0, |
| 71 | 82 | StructureKind::Torch => 4.0, |
| 83 | + StructureKind::Hearth => 22.0, | |
| 72 | 84 | _ => 0.0, |
| 73 | 85 | } |
| 74 | 86 | } |
| @@ -86,6 +98,7 @@ impl StructureKind { | ||
| 86 | 98 | StructureKind::Door => "door", |
| 87 | 99 | StructureKind::Workbench => "workbench", |
| 88 | 100 | StructureKind::Statue => "statue", |
| 101 | + StructureKind::Hearth => "hearth", | |
| 89 | 102 | } |
| 90 | 103 | } |
| 91 | 104 | } |
| @@ -137,6 +150,7 @@ pub struct StructureAssets { | ||
| 137 | 150 | bench_leg: Part, |
| 138 | 151 | anvil: Part, |
| 139 | 152 | plinth: Part, |
| 153 | + hearth_mouth: Part, | |
| 140 | 154 | gold: Part, |
| 141 | 155 | } |
| 142 | 156 | |
| @@ -230,6 +244,10 @@ fn create_structure_assets( | ||
| 230 | 244 | Rectangle::new(TILE_SIZE * 0.32, TILE_SIZE * 0.18).into(), |
| 231 | 245 | Color::srgb_u8(70, 74, 82), |
| 232 | 246 | ), |
| 247 | + hearth_mouth: part( | |
| 248 | + Rectangle::new(TILE_SIZE * 0.6, TILE_SIZE * 0.5).into(), | |
| 249 | + Color::srgb_u8(40, 30, 26), | |
| 250 | + ), | |
| 233 | 251 | plinth: part( |
| 234 | 252 | Rectangle::new(TILE_SIZE * 0.6, TILE_SIZE * 0.22).into(), |
| 235 | 253 | Color::srgb_u8(150, 146, 140), |
| @@ -394,6 +412,18 @@ pub fn spawn_structure( | ||
| 394 | 412 | c.spawn(part_bundle(&assets.bench, at(0.0, 0.05, 0.01))); |
| 395 | 413 | c.spawn(part_bundle(&assets.anvil, at(0.15, 0.2, 0.02))); |
| 396 | 414 | }), |
| 415 | + StructureKind::Hearth => entity.with_children(|c| { | |
| 416 | + c.spawn(part_bundle(&assets.wall, at(0.0, 0.0, 0.0))); | |
| 417 | + c.spawn(part_bundle(&assets.hearth_mouth, at(0.0, -0.1, 0.01))); | |
| 418 | + c.spawn(part_bundle( | |
| 419 | + &assets.flame, | |
| 420 | + at(0.0, -0.08, 0.02).with_scale(Vec3::splat(0.7)), | |
| 421 | + )); | |
| 422 | + c.spawn(part_bundle( | |
| 423 | + &assets.flame_core, | |
| 424 | + at(0.0, -0.12, 0.03).with_scale(Vec3::splat(0.7)), | |
| 425 | + )); | |
| 426 | + }), | |
| 397 | 427 | StructureKind::Statue => entity.with_children(|c| { |
| 398 | 428 | c.spawn(part_bundle(&assets.plinth, at(0.0, -0.35, 0.0))); |
| 399 | 429 | c.spawn(part_bundle(&assets.gold, at(0.0, 0.05, 0.01))); |
tasks.md +1 -0
| @@ -49,6 +49,7 @@ | ||
| 49 | 49 | - [x] Wolves: predators hunt animals and colonists; health, bites, healing; adults fight wolves near the colony, children and the hurt flee; newcomers now walk to the colony instead of stranding at the edge |
| 50 | 50 | - [x] Optimization pass: single-threaded fixed schedules, A* scratch buffers, capped wander searches, no retry-every-tick for boxed-in animals, cached reach, more search memos; native SIM_BENCH mode |
| 51 | 51 | - [x] Colonist cap removed (numbered names past the list); job search pass for 40+ colonists (reach-based nearest targets, cheaper cooking/blueprint checks) |
| 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 | |
| 52 | 53 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 53 | 54 | |
| 54 | 55 | ## Planned |