Add bridges, built where wading or detours around water are common
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 17:28 UTC
- commit
- b092f10569d145458fe7f2fecd3aa8d4691321a4
- parent
- fc106c9431
- tree
- browse at this commit
12 files changed +255 -29
| CLAUDE.md | +1 -1 | |
| src/building.rs | +30 -3 | |
| src/jobs/mod.rs | +45 -15 | |
| src/jobs/tests.rs | +26 -0 | |
| src/pathfinding.rs | +49 -4 | |
| src/paths.rs | +79 -1 | |
| src/selection.rs | +3 -0 | |
| src/tilemap.rs | +6 -0 | |
| src/world.rs | +5 -2 | |
| src/worldgen.rs | +5 -1 | |
| src/zones.rs | +5 -2 | |
| tasks.md | +1 -0 |
CLAUDE.md +1 -1
| @@ -39,7 +39,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 39 | 39 | - `src/animals.rs` `Animal{kind: AnimalKind::{Deer, Rabbit}}` entities (Path/TilePos/SimPos/MoveSpeed, moved by colonist `follow_path`; ellipse-body visuals, z=0.9). `populate` on WorldGenerated (population per kind, ≥25 tiles from colony), `migrate` refills one per kind every 2 game hours, `wander` idles 2–8 s then walks ≤10 tiles; an animal reserved as `Target::Animal` (being hunted) stands still. meat 30/8, hunt_work 360/160. |
| 40 | 40 | - Hunting/animals (2nd pass): wild animals flee (`flee_goal`, FLEE_RANGE 4, run FLEE_DISTANCE) from colonists, abandoning paths that end near a threat; hunters shoot from ≤ HUNT_RANGE 6 with `line_of_sight` (repath when the path end drifts > HUNT_RANGE/2 from prey); kills drop `ItemKind::Carcass` (meat count, RawFood stock group, spoils like meat, not edible); `find_butchering` (before food gathering) → `Task::Butcher{site}` converts the carcass stack into Meat in place after BUTCHER_WORK. `Tame` animals: `Task::Tame` (adjacent, kind.tame_work; target stands still while being tamed) via `find_taming` when meat isn't wanted and < MAX_TAME 6; tame animals don't flee, wander within PEN_RADIUS of home, `breed` daily (≥2 of a kind, 50%), excluded from migration counts. `find_hunt` (`Wildlife` = (entity, tile, kind, tame)) takes spare tame animals (herd > 2) before wild ones. |
| 41 | 41 | - `src/farming.rs` `plan_farm` (every 600 ticks, once a stockpile exists and no farm): `FarmContext::plan` picks the FARM_SIZE² square near home (BFS corners) maximising soil − distance where every tile is farmable, plant-free and ≥ FARM_CLEARANCE from stockpile, blueprints, structures and items. |
| 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`. `busy_path` (Paved or wear ≥ BUSY_WEAR = PAVE_WEAR): colonists don't plan homes/expansions (`HomeContext::open`), shelter walls or BuildContext sites (fires, beds, workbench) there; manual placement is unaffected. 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. | |
| 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`. Bridges: `Terrain::Bridge` (cost 80 like Path, never generated, not zonable, counts as busy path) via `BuildKind::Bridge` (2 wood, 180 work, not blocked by structures; completion sets terrain + clears wear). `bridging_candidates` (hourly with paving): water tiles (shallow or deep) with wear ≥ BRIDGE_WEAR next to walkable ground, busiest first, up to BRIDGE_BATCH pending. Wading adds wear to shallow water; `note_detour` (in run_jobs' `walk_to`, which now takes `&mut WorldMap`) runs `find_bridged_path` (A* `astar_core` with `bridging`: water passable at bridge cost) when a route of ≥ DETOUR_MIN_TILES is ≥1.5× the straight distance (or missing) and adds 1 wear to every water tile of a bridged route that's ≥1.5× shorter — demand for a currently impossible/indirect crossing; bridges then grow from the shore. Build tool Bridge mode (`place_bridges`, water only). `busy_path` (Paved, Bridge or wear ≥ BUSY_WEAR = PAVE_WEAR): colonists don't plan homes/expansions (`HomeContext::open`), shelter walls or BuildContext sites (fires, beds, workbench) there; manual placement is unaffected. 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 | 45 | - `src/family.rs` families. `Family(u32)` (shared by partners and their children), `Partner(Entity)`, `Child{born}` (CHILD_SCALE visual, grows up after CHILDHOOD_TICKS = 4 days in `grow_up`) on colonists. `bond` (hourly): unpartnered adults not of the same family within BOND_RANGE accumulate `Affinity` hours per pair; ≥ BOND_HOURS + BOND_CHANCE roll → partners with a new family id; dangling Partner removed when the partner dies. `Homes{by_family: family → Home{lo, hi}}` (wall-ring corners, interior HOME_INTERIOR 4×3, `bed_tiles` 2 beds top-left, `owner(tile)`). `plan_homes` (every 300 ticks, Decide): homes of extinct families move to `Homes.vacant` (serde default; `all()`, `is_home()` cover owned + vacant; `owner()` owned only, so vacant beds count as communal for sleeping); `abandoned_home` adopts enclosed HOME_INTERIOR rooms around unowned beds as vacant (old saves); couples without a home take the vacant home nearest the colony before a new one is planned. Floods `reachable` from colony home (if walkable) and drops blueprints with no reachable tile in their 3×3 (sealed pockets inflated goals forever). Per home: `door_repair` (no door/planned door whose `outward` tile is reachable → Door on an empty door site, else Deconstruct the best wall; runs even with blueprints pending), else roofs (`roofs_needed`), hearth at `Home::hearth_tile` (hi−1), statue + `BuildKind::Floor` on unfloored interior tiles (`floored`; doesn't overwrite other blueprints) for owned built homes. `surplus_beds`: reachable communal beds beyond homeless adults → Deconstruct (nearest kept). Households (`Household{partners, children}` per family; `adults()` = max(partners,1)) size homes via `desired_interior` (solo 3×2, couple 4×3, +1 child 5×3, 2+ children 5×4). Couples get a vacant home (preferring ones big enough) or `plan_sized`; otherwise one solo adult per pass (no Partner, not Child, family None / parents' family / no home) gets a new `Family` (Commands) and a vacant home ≤ 4×3 or a planned solo room. Owned built homes smaller than desired get `HomeContext::expansion`: grow one side (width first, then height) where the new ring may reuse walls and the new strip is free; opened old wall/door tiles get Deconstruct, missing new ring tiles Wall; Homes entry updated at once. Owned built homes get Bed blueprints up to `adults()` at `bed_tiles_for`. New homes via `HomeContext::plan_sized`: BFS from colony home (center ≥ HOME_MIN_DISTANCE), first HOME_CANDIDATES fits, pick the one sharing most walls (`shared_walls`: ring tiles may be existing walls or planned wall blueprints, else open and not in a room; interior open and not in a room; some door site must open onto walkable, non-room ground). `Home::blueprints(map, toward)` plans only missing ring walls, door via `door_site` (non-wall, walkable outward), beds. Adoption: any rectangular room (`home_of_room`, interior ≥ MIN_HOME_INTERIOR 3×2) with ≥2 beds not already a home → vacant. `births` (daily 20:00): family with 2 living partners, built home (roofed, 2 beds), < MAX_CHILDREN, colony < MAX_COLONISTS and food ≥ colonists, BIRTH_CHANCE → child spawned inside home. Children: choose_jobs gives them only sleep/eat/wander; `find_bed_site` counts adults. Sleep: own home bed (adults), free roofed home floor (children), else communal beds (`Homes::owner` None) → campfire. `plan_shelter` ignores beds inside homes (owned or vacant). Selection text shows partner / child days left. |
src/building.rs +30 -3
| @@ -59,6 +59,7 @@ pub enum BuildKind { | ||
| 59 | 59 | Roof, |
| 60 | 60 | Pave, |
| 61 | 61 | Floor, |
| 62 | + Bridge, | |
| 62 | 63 | Refuel(StructureKind), |
| 63 | 64 | Deconstruct, |
| 64 | 65 | } |
| @@ -70,6 +71,7 @@ impl BuildKind { | ||
| 70 | 71 | BuildKind::Roof => (ItemKind::Wood, 1), |
| 71 | 72 | BuildKind::Pave => (ItemKind::Stone, 1), |
| 72 | 73 | BuildKind::Floor => (ItemKind::Wood, 1), |
| 74 | + BuildKind::Bridge => (ItemKind::Wood, 2), | |
| 73 | 75 | BuildKind::Refuel(kind) => kind.refuel_cost(), |
| 74 | 76 | BuildKind::Deconstruct => (ItemKind::Wood, 0), |
| 75 | 77 | } |
| @@ -88,6 +90,7 @@ impl BuildKind { | ||
| 88 | 90 | BuildKind::Roof => 120, |
| 89 | 91 | BuildKind::Pave => 120, |
| 90 | 92 | BuildKind::Floor => 90, |
| 93 | + BuildKind::Bridge => 180, | |
| 91 | 94 | BuildKind::Refuel(_) => 60, |
| 92 | 95 | BuildKind::Deconstruct => 200, |
| 93 | 96 | } |
| @@ -99,6 +102,7 @@ impl BuildKind { | ||
| 99 | 102 | BuildKind::Roof => "roof", |
| 100 | 103 | BuildKind::Pave => "paving", |
| 101 | 104 | BuildKind::Floor => "floor", |
| 105 | + BuildKind::Bridge => "bridge", | |
| 102 | 106 | BuildKind::Refuel(_) => "refuelling", |
| 103 | 107 | BuildKind::Deconstruct => "deconstruction", |
| 104 | 108 | } |
| @@ -276,7 +280,7 @@ pub fn plan_shelter( | ||
| 276 | 280 | let shelter_pending = blueprints |
| 277 | 281 | .0 |
| 278 | 282 | .values() |
| 279 | - .any(|k| !matches!(k, BuildKind::Pave | BuildKind::Floor)); | |
| 283 | + .any(|k| !matches!(k, BuildKind::Pave | BuildKind::Floor | BuildKind::Bridge)); | |
| 280 | 284 | if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) || shelter_pending { |
| 281 | 285 | return; |
| 282 | 286 | } |
| @@ -320,10 +324,11 @@ pub fn plan_shelter( | ||
| 320 | 324 | pub enum BuildMode { |
| 321 | 325 | Place(StructureKind), |
| 322 | 326 | Floor, |
| 327 | + Bridge, | |
| 323 | 328 | Deconstruct, |
| 324 | 329 | } |
| 325 | 330 | |
| 326 | -pub const BUILD_TOOL_CYCLE: [BuildMode; 9] = [ | |
| 331 | +pub const BUILD_TOOL_CYCLE: [BuildMode; 10] = [ | |
| 327 | 332 | BuildMode::Place(StructureKind::Wall), |
| 328 | 333 | BuildMode::Place(StructureKind::Door), |
| 329 | 334 | BuildMode::Place(StructureKind::Campfire), |
| @@ -333,6 +338,7 @@ pub const BUILD_TOOL_CYCLE: [BuildMode; 9] = [ | ||
| 333 | 338 | BuildMode::Place(StructureKind::Statue), |
| 334 | 339 | BuildMode::Place(StructureKind::Hearth), |
| 335 | 340 | BuildMode::Floor, |
| 341 | + BuildMode::Bridge, | |
| 336 | 342 | ]; |
| 337 | 343 | |
| 338 | 344 | #[derive(Resource, Default, Debug)] |
| @@ -375,6 +381,15 @@ pub fn mark_deconstruction( | ||
| 375 | 381 | } |
| 376 | 382 | } |
| 377 | 383 | |
| 384 | +pub fn place_bridges(map: &WorldMap, blueprints: &mut Blueprints, start: UVec2, end: UVec2) { | |
| 385 | + for tile in rect(start, end) { | |
| 386 | + let water = map.get(tile.as_ivec2()).is_some_and(|t| t.is_water()); | |
| 387 | + if water && blueprints.get(tile).is_none() { | |
| 388 | + blueprints.0.insert(tile, BuildKind::Bridge); | |
| 389 | + } | |
| 390 | + } | |
| 391 | +} | |
| 392 | + | |
| 378 | 393 | pub fn place_floors(map: &WorldMap, blueprints: &mut Blueprints, start: UVec2, end: UVec2) { |
| 379 | 394 | for tile in rect(start, end) { |
| 380 | 395 | let p = tile.as_ivec2(); |
| @@ -477,6 +492,7 @@ fn build_tool( | ||
| 477 | 492 | place_blueprints(&map, &mut zones, &mut blueprints, &occupied, kind, &tiles); |
| 478 | 493 | } |
| 479 | 494 | BuildMode::Floor => place_floors(&map, &mut blueprints, start, end), |
| 495 | + BuildMode::Bridge => place_bridges(&map, &mut blueprints, start, end), | |
| 480 | 496 | BuildMode::Deconstruct => mark_deconstruction(&map, &mut blueprints, &occupied, start, end), |
| 481 | 497 | } |
| 482 | 498 | } |
| @@ -515,7 +531,7 @@ fn draw_blueprints(mut gizmos: Gizmos, blueprints: Res<Blueprints>) { | ||
| 515 | 531 | BuildKind::Structure(StructureKind::Door) => DOOR_BLUEPRINT_COLOR, |
| 516 | 532 | BuildKind::Roof => ROOF_BLUEPRINT_COLOR, |
| 517 | 533 | BuildKind::Pave => PAVE_BLUEPRINT_COLOR, |
| 518 | - BuildKind::Floor => FLOOR_BLUEPRINT_COLOR, | |
| 534 | + BuildKind::Floor | BuildKind::Bridge => FLOOR_BLUEPRINT_COLOR, | |
| 519 | 535 | BuildKind::Deconstruct => DECONSTRUCT_COLOR, |
| 520 | 536 | _ => WALL_BLUEPRINT_COLOR, |
| 521 | 537 | }; |
| @@ -703,4 +719,15 @@ mod tests { | ||
| 703 | 719 | assert_eq!(blueprints.0.len(), 9 - 3); |
| 704 | 720 | assert!(blueprints.0.values().all(|k| *k == BuildKind::Floor)); |
| 705 | 721 | } |
| 722 | + | |
| 723 | + #[test] | |
| 724 | + fn the_bridge_tool_only_plans_over_water() { | |
| 725 | + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass); | |
| 726 | + map.set(UVec2::new(2, 2), Terrain::ShallowWater); | |
| 727 | + map.set(UVec2::new(3, 2), Terrain::DeepWater); | |
| 728 | + let mut blueprints = Blueprints::default(); | |
| 729 | + place_bridges(&map, &mut blueprints, UVec2::new(1, 1), UVec2::new(4, 3)); | |
| 730 | + assert_eq!(blueprints.0.len(), 2); | |
| 731 | + assert!(blueprints.0.values().all(|k| *k == BuildKind::Bridge)); | |
| 732 | + } | |
| 706 | 733 | } |
src/jobs/mod.rs +45 -15
| @@ -27,8 +27,8 @@ use crate::{ | ||
| 27 | 27 | mood::{Ate, Sick}, |
| 28 | 28 | needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep}, |
| 29 | 29 | pathfinding::{ |
| 30 | - ColonyReach, WANDER_SEARCH, can_step, find_nearby_path, find_path, find_path_adjacent, | |
| 31 | - is_adjacent, nearest_of, nearest_tile, search_reach, | |
| 30 | + ColonyReach, WANDER_SEARCH, can_step, find_bridged_path, find_nearby_path, find_path, | |
| 31 | + find_path_adjacent, is_adjacent, nearest_of, nearest_tile, search_reach, | |
| 32 | 32 | }, |
| 33 | 33 | paths::busy_path, |
| 34 | 34 | rng::SimRng, |
| @@ -1812,8 +1812,33 @@ enum Step { | ||
| 1812 | 1812 | Done, |
| 1813 | 1813 | } |
| 1814 | 1814 | |
| 1815 | -fn walk_to(map: &WorldMap, from: UVec2, to: UVec2, path: &mut Path) -> Step { | |
| 1816 | - match find_path(map, from, to) { | |
| 1815 | +pub const DETOUR_MIN_TILES: usize = 24; | |
| 1816 | +const DETOUR_SEARCH: usize = 4000; | |
| 1817 | + | |
| 1818 | +pub fn note_detour(map: &mut WorldMap, from: UVec2, to: UVec2, route: Option<&[UVec2]>) { | |
| 1819 | + let straight = (to.as_ivec2() - from.as_ivec2()).abs().max_element() as usize; | |
| 1820 | + if route.is_some_and(|r| r.len() < DETOUR_MIN_TILES || r.len() * 2 < straight * 3) { | |
| 1821 | + return; | |
| 1822 | + } | |
| 1823 | + let Some(bridged) = find_bridged_path(map, from, to, DETOUR_SEARCH) else { | |
| 1824 | + return; | |
| 1825 | + }; | |
| 1826 | + let shorter = route.is_none_or(|r| bridged.len() * 3 / 2 < r.len()); | |
| 1827 | + if !shorter { | |
| 1828 | + return; | |
| 1829 | + } | |
| 1830 | + for tile in bridged { | |
| 1831 | + let p = tile.as_ivec2(); | |
| 1832 | + if map.get(p).is_some_and(|t| t.is_water()) { | |
| 1833 | + map.set_wear(tile, map.wear(p) + 1.0); | |
| 1834 | + } | |
| 1835 | + } | |
| 1836 | +} | |
| 1837 | + | |
| 1838 | +fn walk_to(map: &mut WorldMap, from: UVec2, to: UVec2, path: &mut Path) -> Step { | |
| 1839 | + let route = find_path(map, from, to); | |
| 1840 | + note_detour(map, from, to, route.as_deref()); | |
| 1841 | + match route { | |
| 1817 | 1842 | Some(p) if !p.is_empty() => { |
| 1818 | 1843 | path.0 = p.into(); |
| 1819 | 1844 | Step::Continue |
| @@ -1936,7 +1961,7 @@ fn run_jobs( | ||
| 1936 | 1961 | Some(_) if pickup.is_some() => { |
| 1937 | 1962 | let from = pickup.expect("checked above"); |
| 1938 | 1963 | if tile.0 != from { |
| 1939 | - walk_to(&map, tile.0, from, &mut path) | |
| 1964 | + walk_to(&mut map, tile.0, from, &mut path) | |
| 1940 | 1965 | } else { |
| 1941 | 1966 | reservations.release(Target::Pickup(from)); |
| 1942 | 1967 | carrying.0 = take_items(&mut commands, &mut items, from, 1) |
| @@ -1979,7 +2004,7 @@ fn run_jobs( | ||
| 1979 | 2004 | if reservations.holder(Target::Pickup(from)) != Some(entity) { |
| 1980 | 2005 | Step::Done |
| 1981 | 2006 | } else if tile.0 != from { |
| 1982 | - walk_to(&map, tile.0, from, &mut path) | |
| 2007 | + walk_to(&mut map, tile.0, from, &mut path) | |
| 1983 | 2008 | } else { |
| 1984 | 2009 | reservations.release(Target::Pickup(from)); |
| 1985 | 2010 | let stack = items |
| @@ -2010,7 +2035,7 @@ fn run_jobs( | ||
| 2010 | 2035 | if reservations.holder(Target::Pickup(from)) != Some(entity) { |
| 2011 | 2036 | Step::Done |
| 2012 | 2037 | } else if tile.0 != from { |
| 2013 | - walk_to(&map, tile.0, from, &mut path) | |
| 2038 | + walk_to(&mut map, tile.0, from, &mut path) | |
| 2014 | 2039 | } else { |
| 2015 | 2040 | let load = take_items(&mut commands, &mut items, from, input) |
| 2016 | 2041 | .filter(|load| recipe.accepts(load.kind) && load.count > 0); |
| @@ -2140,7 +2165,7 @@ fn run_jobs( | ||
| 2140 | 2165 | if reservations.holder(Target::Pickup(site)) != Some(entity) { |
| 2141 | 2166 | Step::Done |
| 2142 | 2167 | } else if tile.0 != site { |
| 2143 | - walk_to(&map, tile.0, site, &mut path) | |
| 2168 | + walk_to(&mut map, tile.0, site, &mut path) | |
| 2144 | 2169 | } else { |
| 2145 | 2170 | *progress += rate; |
| 2146 | 2171 | if *progress < BUTCHER_WORK { |
| @@ -2168,7 +2193,7 @@ fn run_jobs( | ||
| 2168 | 2193 | if reservations.holder(Target::Pickup(from)) != Some(entity) { |
| 2169 | 2194 | Step::Done |
| 2170 | 2195 | } else if tile.0 != from { |
| 2171 | - walk_to(&map, tile.0, from, &mut path) | |
| 2196 | + walk_to(&mut map, tile.0, from, &mut path) | |
| 2172 | 2197 | } else { |
| 2173 | 2198 | let kind = items |
| 2174 | 2199 | .iter() |
| @@ -2209,7 +2234,7 @@ fn run_jobs( | ||
| 2209 | 2234 | if reservations.holder(Target::SleepSpot(*spot)) != Some(entity) { |
| 2210 | 2235 | Step::Done |
| 2211 | 2236 | } else if tile.0 != *spot { |
| 2212 | - let step = walk_to(&map, tile.0, *spot, &mut path); | |
| 2237 | + let step = walk_to(&mut map, tile.0, *spot, &mut path); | |
| 2213 | 2238 | if matches!(step, Step::Done) { |
| 2214 | 2239 | reservations.release(Target::SleepSpot(*spot)); |
| 2215 | 2240 | *spot = tile.0; |
| @@ -2270,7 +2295,7 @@ fn run_jobs( | ||
| 2270 | 2295 | } |
| 2271 | 2296 | Task::ExpandStockpile { site, progress } => { |
| 2272 | 2297 | if tile.0 != *site { |
| 2273 | - walk_to(&map, tile.0, *site, &mut path) | |
| 2298 | + walk_to(&mut map, tile.0, *site, &mut path) | |
| 2274 | 2299 | } else { |
| 2275 | 2300 | *progress += rate; |
| 2276 | 2301 | if *progress >= EXPAND_WORK { |
| @@ -2308,7 +2333,7 @@ fn run_jobs( | ||
| 2308 | 2333 | let structure_there = structures.iter().any(|(_, pos, ..)| pos.0 == site); |
| 2309 | 2334 | let blocked = map.plant(site.as_ivec2()).is_some() |
| 2310 | 2335 | || match kind { |
| 2311 | - BuildKind::Roof | BuildKind::Floor => false, | |
| 2336 | + BuildKind::Roof | BuildKind::Floor | BuildKind::Bridge => false, | |
| 2312 | 2337 | BuildKind::Refuel(fire) => !structures |
| 2313 | 2338 | .iter() |
| 2314 | 2339 | .any(|(_, pos, s, _)| pos.0 == site && s.kind == fire), |
| @@ -2324,7 +2349,7 @@ fn run_jobs( | ||
| 2324 | 2349 | if reservations.holder(Target::Pickup(from)) != Some(entity) { |
| 2325 | 2350 | Step::Done |
| 2326 | 2351 | } else if tile.0 != from { |
| 2327 | - walk_to(&map, tile.0, from, &mut path) | |
| 2352 | + walk_to(&mut map, tile.0, from, &mut path) | |
| 2328 | 2353 | } else if carrying.0.is_some_and(|load| load.kind != material) { |
| 2329 | 2354 | Step::Done |
| 2330 | 2355 | } else { |
| @@ -2401,6 +2426,11 @@ fn run_jobs( | ||
| 2401 | 2426 | map.set(site, Terrain::WoodFloor); |
| 2402 | 2427 | changed.write(TileChanged(site)); |
| 2403 | 2428 | } |
| 2429 | + BuildKind::Bridge => { | |
| 2430 | + map.set(site, Terrain::Bridge); | |
| 2431 | + map.set_wear(site, 0.0); | |
| 2432 | + changed.write(TileChanged(site)); | |
| 2433 | + } | |
| 2404 | 2434 | BuildKind::Deconstruct => { |
| 2405 | 2435 | for (entity, pos, structure, _) in &structures { |
| 2406 | 2436 | if pos.0 != site { |
| @@ -2449,7 +2479,7 @@ fn run_jobs( | ||
| 2449 | 2479 | if reservations.holder(Target::Pickup(from)) != Some(entity) { |
| 2450 | 2480 | Step::Done |
| 2451 | 2481 | } else if tile.0 != from { |
| 2452 | - walk_to(&map, tile.0, from, &mut path) | |
| 2482 | + walk_to(&mut map, tile.0, from, &mut path) | |
| 2453 | 2483 | } else { |
| 2454 | 2484 | let kind = items |
| 2455 | 2485 | .iter() |
| @@ -2472,7 +2502,7 @@ fn run_jobs( | ||
| 2472 | 2502 | } |
| 2473 | 2503 | } else if let Some(load) = carrying.0 { |
| 2474 | 2504 | if tile.0 != to { |
| 2475 | - walk_to(&map, tile.0, to, &mut path) | |
| 2505 | + walk_to(&mut map, tile.0, to, &mut path) | |
| 2476 | 2506 | } else { |
| 2477 | 2507 | let room = room_for(items.iter().map(|(_, i, p)| (i, p)), to, load.kind); |
| 2478 | 2508 | let dropped = load.count.min(room); |
src/jobs/tests.rs +26 -0
| @@ -1636,3 +1636,29 @@ fn doctors_tend_the_wounded_with_herbs() { | ||
| 1636 | 1636 | Some(Task::Sleep { .. }) |
| 1637 | 1637 | )); |
| 1638 | 1638 | } |
| 1639 | + | |
| 1640 | +// Bridges over deep water are built from the shore outwards: each finished span is the footing | |
| 1641 | +// for the next, until the far bank can be reached. | |
| 1642 | +#[test] | |
| 1643 | +fn colonists_bridge_deep_water_from_the_shore() { | |
| 1644 | + let mut map = WorldMap::filled(UVec2::new(30, 20), Terrain::Grass); | |
| 1645 | + for y in 0..20 { | |
| 1646 | + for x in 12..16 { | |
| 1647 | + map.set(UVec2::new(x, y), Terrain::DeepWater); | |
| 1648 | + } | |
| 1649 | + } | |
| 1650 | + let mut app = test_app(map); | |
| 1651 | + put_beds(&mut app, 1); | |
| 1652 | + put_items(&mut app, UVec2::new(5, 5), ItemKind::Wood, 20); | |
| 1653 | + let mut blueprints = app.world_mut().resource_mut::<Blueprints>(); | |
| 1654 | + for x in 12..16 { | |
| 1655 | + blueprints.0.insert(UVec2::new(x, 8), BuildKind::Bridge); | |
| 1656 | + } | |
| 1657 | + spawn_colonist(&mut app, UVec2::new(6, 8)); | |
| 1658 | + run_ticks(&mut app, 60 * 60); | |
| 1659 | + let map = app.world().resource::<WorldMap>(); | |
| 1660 | + for x in 12..16 { | |
| 1661 | + assert_eq!(map.get(IVec2::new(x, 8)), Some(Terrain::Bridge)); | |
| 1662 | + } | |
| 1663 | + assert!(find_path(map, UVec2::new(6, 8), UVec2::new(20, 8)).is_some()); | |
| 1664 | +} | |
src/pathfinding.rs +49 -4
| @@ -5,7 +5,7 @@ use std::{ | ||
| 5 | 5 | |
| 6 | 6 | use bevy::prelude::*; |
| 7 | 7 | |
| 8 | -use crate::world::{CHEAPEST_MOVE_COST, WorldMap}; | |
| 8 | +use crate::world::{CHEAPEST_MOVE_COST, Terrain, WorldMap}; | |
| 9 | 9 | |
| 10 | 10 | const STRAIGHT: u32 = 10; |
| 11 | 11 | const DIAGONAL: u32 = 14; |
| @@ -229,6 +229,25 @@ fn astar_limited( | ||
| 229 | 229 | heuristic: impl Fn(IVec2) -> u32, |
| 230 | 230 | max_expanded: usize, |
| 231 | 231 | ) -> Option<Vec<UVec2>> { |
| 232 | + astar_core(map, start, is_goal, heuristic, max_expanded, false) | |
| 233 | +} | |
| 234 | + | |
| 235 | +fn bridgeable(map: &WorldMap, p: IVec2) -> bool { | |
| 236 | + !map.is_wall(p) && map.get(p).is_some_and(|t| t.is_water()) | |
| 237 | +} | |
| 238 | + | |
| 239 | +fn passable(map: &WorldMap, p: IVec2, bridging: bool) -> bool { | |
| 240 | + map.move_cost(p).is_some() || (bridging && bridgeable(map, p)) | |
| 241 | +} | |
| 242 | + | |
| 243 | +fn astar_core( | |
| 244 | + map: &WorldMap, | |
| 245 | + start: UVec2, | |
| 246 | + is_goal: impl Fn(IVec2) -> bool, | |
| 247 | + heuristic: impl Fn(IVec2) -> u32, | |
| 248 | + max_expanded: usize, | |
| 249 | + bridging: bool, | |
| 250 | +) -> Option<Vec<UVec2>> { | |
| 232 | 251 | let start = start.as_ivec2(); |
| 233 | 252 | if !map.in_bounds(start) { |
| 234 | 253 | return None; |
| @@ -286,11 +305,20 @@ fn astar_limited( | ||
| 286 | 305 | let current_cost = cost[index(current)]; |
| 287 | 306 | for step in NEIGHBORS { |
| 288 | 307 | let next = current + step; |
| 289 | - if !can_step(map, current, step) { | |
| 308 | + let corners_open = step.x == 0 | |
| 309 | + || step.y == 0 | |
| 310 | + || (passable(map, current + IVec2::new(step.x, 0), bridging) | |
| 311 | + && passable(map, current + IVec2::new(0, step.y), bridging)); | |
| 312 | + if !passable(map, next, bridging) || !corners_open { | |
| 290 | 313 | continue; |
| 291 | 314 | } |
| 292 | - let Some(terrain_cost) = map.step_cost(current, next) else { | |
| 293 | - continue; | |
| 315 | + let terrain_cost = if bridging && bridgeable(map, next) { | |
| 316 | + Terrain::Bridge.move_cost().unwrap_or(STRAIGHT) | |
| 317 | + } else { | |
| 318 | + let Some(cost) = map.step_cost(current, next) else { | |
| 319 | + continue; | |
| 320 | + }; | |
| 321 | + cost | |
| 294 | 322 | }; |
| 295 | 323 | let diagonal = step.x != 0 && step.y != 0; |
| 296 | 324 | let base = if diagonal { DIAGONAL } else { STRAIGHT }; |
| @@ -310,6 +338,23 @@ fn astar_limited( | ||
| 310 | 338 | |
| 311 | 339 | pub const WANDER_SEARCH: usize = 800; |
| 312 | 340 | |
| 341 | +pub fn find_bridged_path( | |
| 342 | + map: &WorldMap, | |
| 343 | + start: UVec2, | |
| 344 | + goal: UVec2, | |
| 345 | + max_expanded: usize, | |
| 346 | +) -> Option<Vec<UVec2>> { | |
| 347 | + let goal = goal.as_ivec2(); | |
| 348 | + astar_core( | |
| 349 | + map, | |
| 350 | + start, | |
| 351 | + |p| p == goal, | |
| 352 | + |p| octile(p, goal), | |
| 353 | + max_expanded, | |
| 354 | + true, | |
| 355 | + ) | |
| 356 | +} | |
| 357 | + | |
| 313 | 358 | pub fn find_nearby_path( |
| 314 | 359 | map: &WorldMap, |
| 315 | 360 | start: UVec2, |
src/paths.rs +79 -1
| @@ -28,7 +28,40 @@ pub const PAVE_WEAR: f32 = 20.0; | ||
| 28 | 28 | pub const BUSY_WEAR: f32 = PAVE_WEAR; |
| 29 | 29 | |
| 30 | 30 | pub fn busy_path(map: &WorldMap, tile: IVec2) -> bool { |
| 31 | - map.get(tile) == Some(Terrain::Paved) || map.wear(tile) >= BUSY_WEAR | |
| 31 | + matches!(map.get(tile), Some(Terrain::Paved | Terrain::Bridge)) || map.wear(tile) >= BUSY_WEAR | |
| 32 | +} | |
| 33 | + | |
| 34 | +pub const BRIDGE_WEAR: f32 = PATH_WEAR; | |
| 35 | +pub const BRIDGE_BATCH: usize = 6; | |
| 36 | + | |
| 37 | +pub fn bridging_candidates(map: &WorldMap, blueprints: &Blueprints) -> Vec<UVec2> { | |
| 38 | + let pending = blueprints | |
| 39 | + .0 | |
| 40 | + .values() | |
| 41 | + .filter(|k| **k == BuildKind::Bridge) | |
| 42 | + .count(); | |
| 43 | + let reachable_from_land = |p: UVec2| { | |
| 44 | + [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y] | |
| 45 | + .iter() | |
| 46 | + .any(|d| map.move_cost(p.as_ivec2() + *d).is_some()) | |
| 47 | + }; | |
| 48 | + let mut busiest: Vec<UVec2> = map | |
| 49 | + .worn_tiles() | |
| 50 | + .into_iter() | |
| 51 | + .filter(|&p| { | |
| 52 | + map.get(p.as_ivec2()).is_some_and(|t| t.is_water()) | |
| 53 | + && map.wear(p.as_ivec2()) >= BRIDGE_WEAR | |
| 54 | + && blueprints.get(p).is_none() | |
| 55 | + && reachable_from_land(p) | |
| 56 | + }) | |
| 57 | + .collect(); | |
| 58 | + busiest.sort_by(|a, b| { | |
| 59 | + map.wear(b.as_ivec2()) | |
| 60 | + .total_cmp(&map.wear(a.as_ivec2())) | |
| 61 | + .then((a.y, a.x).cmp(&(b.y, b.x))) | |
| 62 | + }); | |
| 63 | + busiest.truncate(BRIDGE_BATCH.saturating_sub(pending)); | |
| 64 | + busiest | |
| 32 | 65 | } |
| 33 | 66 | pub const REGROW_WEAR: f32 = 1.0; |
| 34 | 67 | pub const WEAR_KEPT_PER_HOUR: f32 = 0.98; |
| @@ -119,6 +152,9 @@ fn plan_paving( | ||
| 119 | 152 | for tile in paving_candidates(&map, &blueprints, &occupied) { |
| 120 | 153 | blueprints.0.insert(tile, BuildKind::Pave); |
| 121 | 154 | } |
| 155 | + for tile in bridging_candidates(&map, &blueprints) { | |
| 156 | + blueprints.0.insert(tile, BuildKind::Bridge); | |
| 157 | + } | |
| 122 | 158 | } |
| 123 | 159 | |
| 124 | 160 | #[cfg(test)] |
| @@ -199,4 +235,46 @@ mod tests { | ||
| 199 | 235 | assert_eq!(picked[0], UVec2::new(17, 0)); |
| 200 | 236 | assert!(!picked.contains(&UVec2::new(18, 0))); |
| 201 | 237 | } |
| 238 | + | |
| 239 | + fn lake_map() -> WorldMap { | |
| 240 | + let mut map = WorldMap::filled(UVec2::new(40, 30), Terrain::Grass); | |
| 241 | + for y in 0..25 { | |
| 242 | + for x in 18..22 { | |
| 243 | + map.set(UVec2::new(x, y), Terrain::DeepWater); | |
| 244 | + } | |
| 245 | + } | |
| 246 | + map | |
| 247 | + } | |
| 248 | + | |
| 249 | + // A lake forces a long walk around; colonists notice the much shorter route a bridge would | |
| 250 | + // give and put demand on its water tiles, then bridge them from the shore outwards. | |
| 251 | + #[test] | |
| 252 | + fn detours_around_water_lead_to_bridges() { | |
| 253 | + let mut map = lake_map(); | |
| 254 | + let (from, to) = (UVec2::new(10, 5), UVec2::new(30, 5)); | |
| 255 | + let route = crate::pathfinding::find_path(&map, from, to).unwrap(); | |
| 256 | + let bridged = crate::pathfinding::find_bridged_path(&map, from, to, 4000).unwrap(); | |
| 257 | + assert!(bridged.len() * 2 < route.len()); | |
| 258 | + for _ in 0..BRIDGE_WEAR as usize { | |
| 259 | + crate::jobs::note_detour(&mut map, from, to, Some(&route)); | |
| 260 | + } | |
| 261 | + let blueprints = Blueprints::default(); | |
| 262 | + let plan = bridging_candidates(&map, &blueprints); | |
| 263 | + assert!(!plan.is_empty()); | |
| 264 | + assert!(plan.iter().all(|t| t.x == 18 || t.x == 21), "{plan:?}"); | |
| 265 | + assert!(plan.iter().all(|t| (3..=7).contains(&t.y)), "{plan:?}"); | |
| 266 | + } | |
| 267 | + | |
| 268 | + #[test] | |
| 269 | + fn short_or_straight_walks_put_no_demand_on_water() { | |
| 270 | + let mut map = lake_map(); | |
| 271 | + let route = crate::pathfinding::find_path(&map, UVec2::new(10, 28), UVec2::new(30, 28)); | |
| 272 | + crate::jobs::note_detour( | |
| 273 | + &mut map, | |
| 274 | + UVec2::new(10, 28), | |
| 275 | + UVec2::new(30, 28), | |
| 276 | + route.as_deref(), | |
| 277 | + ); | |
| 278 | + assert!(map.worn_tiles().is_empty()); | |
| 279 | + } | |
| 202 | 280 | } |
src/selection.rs +3 -0
| @@ -453,6 +453,9 @@ fn update_tile_info( | ||
| 453 | 453 | "\nSTOCKPILE TOOL: drag to add, shift-drag to remove".to_string() |
| 454 | 454 | } |
| 455 | 455 | (Some(ZoneKind::Farm), _) => "\nFARM TOOL: drag to add, shift-drag to remove".to_string(), |
| 456 | + (None, Some(BuildMode::Bridge)) => { | |
| 457 | + "\nBUILD BRIDGE: drag over water, shift-drag to remove plans, B for next".to_string() | |
| 458 | + } | |
| 456 | 459 | (None, Some(BuildMode::Floor)) => { |
| 457 | 460 | "\nBUILD FLOOR: drag an area, shift-drag to remove plans, B for next".to_string() |
| 458 | 461 | } |
src/tilemap.rs +6 -0
| @@ -370,10 +370,16 @@ fn terrain_texture(terrain: Terrain) -> Texture { | ||
| 370 | 370 | let mortar = terrain == Terrain::Paved |
| 371 | 371 | && (y % 5 == 0 || (x + if (y / 5) % 2 == 0 { 0 } else { 4 }) % 8 == 0); |
| 372 | 372 | let seam = terrain == Terrain::WoodFloor && (y % 4 == 0 || (x + (y / 4) * 5) % 16 == 0); |
| 373 | + let rail = terrain == Terrain::Bridge && (x <= 1 || x >= TEXTURE_SIZE - 2); | |
| 374 | + let gap = terrain == Terrain::Bridge && y % 4 == 0; | |
| 373 | 375 | let c = if mortar { |
| 374 | 376 | Srgba::rgb_u8(110, 106, 100) |
| 375 | 377 | } else if seam { |
| 376 | 378 | Srgba::rgb_u8(100, 70, 42) |
| 379 | + } else if rail { | |
| 380 | + Srgba::rgb_u8(80, 56, 34) | |
| 381 | + } else if gap { | |
| 382 | + Terrain::ShallowWater.color() | |
| 377 | 383 | } else { |
| 378 | 384 | speck.unwrap_or(base) |
| 379 | 385 | }; |
src/world.rs +5 -2
| @@ -23,10 +23,11 @@ pub enum Terrain { | ||
| 23 | 23 | Path, |
| 24 | 24 | Paved, |
| 25 | 25 | WoodFloor, |
| 26 | + Bridge, | |
| 26 | 27 | } |
| 27 | 28 | |
| 28 | 29 | impl Terrain { |
| 29 | - pub const ALL: [Terrain; 12] = [ | |
| 30 | + pub const ALL: [Terrain; 13] = [ | |
| 30 | 31 | Terrain::Grass, |
| 31 | 32 | Terrain::Soil, |
| 32 | 33 | Terrain::Sand, |
| @@ -39,6 +40,7 @@ impl Terrain { | ||
| 39 | 40 | Terrain::Path, |
| 40 | 41 | Terrain::Paved, |
| 41 | 42 | Terrain::WoodFloor, |
| 43 | + Terrain::Bridge, | |
| 42 | 44 | ]; |
| 43 | 45 | |
| 44 | 46 | pub fn index(self) -> u16 { |
| @@ -48,7 +50,7 @@ impl Terrain { | ||
| 48 | 50 | pub fn move_cost(self) -> Option<u32> { |
| 49 | 51 | match self { |
| 50 | 52 | Terrain::Paved | Terrain::WoodFloor => Some(CHEAPEST_MOVE_COST), |
| 51 | - Terrain::Path => Some(80), | |
| 53 | + Terrain::Path | Terrain::Bridge => Some(80), | |
| 52 | 54 | Terrain::Grass | Terrain::Soil => Some(100), |
| 53 | 55 | Terrain::Sand | Terrain::Gravel => Some(130), |
| 54 | 56 | Terrain::ShallowWater => Some(300), |
| @@ -76,6 +78,7 @@ impl Terrain { | ||
| 76 | 78 | Terrain::Path => Srgba::rgb_u8(160, 128, 86), |
| 77 | 79 | Terrain::Paved => Srgba::rgb_u8(168, 164, 156), |
| 78 | 80 | Terrain::WoodFloor => Srgba::rgb_u8(150, 108, 66), |
| 81 | + Terrain::Bridge => Srgba::rgb_u8(132, 96, 60), | |
| 79 | 82 | } |
| 80 | 83 | } |
| 81 | 84 | |
src/worldgen.rs +5 -1
| @@ -381,7 +381,11 @@ mod tests { | ||
| 381 | 381 | let generated = |t: &Terrain| { |
| 382 | 382 | !matches!( |
| 383 | 383 | t, |
| 384 | - Terrain::Gravel | Terrain::Path | Terrain::Paved | Terrain::WoodFloor | |
| 384 | + Terrain::Gravel | |
| 385 | + | Terrain::Path | |
| 386 | + | Terrain::Paved | |
| 387 | + | Terrain::WoodFloor | |
| 388 | + | Terrain::Bridge | |
| 385 | 389 | ) |
| 386 | 390 | }; |
| 387 | 391 | for terrain in Terrain::ALL.into_iter().filter(generated) { |
src/zones.rs +5 -2
| @@ -11,7 +11,7 @@ use crate::{ | ||
| 11 | 11 | plants::fertility, |
| 12 | 12 | selection::{Cursor, update_cursor}, |
| 13 | 13 | ui::PointerOverUi, |
| 14 | - world::{TILE_SIZE, WorldMap}, | |
| 14 | + world::{TILE_SIZE, Terrain, WorldMap}, | |
| 15 | 15 | worldgen::WorldGenerated, |
| 16 | 16 | }; |
| 17 | 17 | |
| @@ -319,7 +319,10 @@ pub fn plan_expansion( | ||
| 319 | 319 | } |
| 320 | 320 | |
| 321 | 321 | pub fn can_zone(map: &WorldMap, tile: IVec2) -> bool { |
| 322 | - map.move_cost(tile).is_some() && !map.get(tile).is_some_and(|t| t.is_water()) | |
| 322 | + map.move_cost(tile).is_some() | |
| 323 | + && !map | |
| 324 | + .get(tile) | |
| 325 | + .is_some_and(|t| t.is_water() || t == Terrain::Bridge) | |
| 323 | 326 | } |
| 324 | 327 | |
| 325 | 328 | pub fn can_farm(map: &WorldMap, tile: IVec2) -> bool { |
tasks.md +1 -0
| @@ -56,6 +56,7 @@ | ||
| 56 | 56 | - [x] Variable home sizes (solo room, couple, more space per child); solo colonists and grown children get their own room; families expand homes by moving a wall out |
| 57 | 57 | - [x] Colonists keep busy paths (paved or heavily worn) free when planning buildings |
| 58 | 58 | - [x] Optimization: wolves give up on unreachable prey, cached warm spots, cheaper heat checks |
| 59 | +- [x] Bridges: colonists bridge busy wading spots and water on much shorter (or otherwise impossible) routes, over deep water from the shore outwards; Build tool Bridge mode | |
| 59 | 60 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 60 | 61 | |
| 61 | 62 | ## Planned |