Add wolves, health and colony defence
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 12:14 UTC
- commit
- bd659d1db1e2d674ca753b4c60668fd2073d305a
- parent
- 472bc95142
- tree
- browse at this commit
12 files changed +609 -67
| CLAUDE.md | +2 -0 | |
| src/animals.rs | +75 -7 | |
| src/colonist.rs | +3 -1 | |
| src/jobs/mod.rs | +81 -11 | |
| src/jobs/tests.rs | +28 -0 | |
| src/main.rs | +2 -1 | |
| src/mood.rs | +10 -1 | |
| src/population.rs | +45 -6 | |
| src/save.rs | +11 -0 | |
| src/selection.rs | +49 -40 | |
| src/threats.rs | added | +302 -0 |
| tasks.md | +1 -0 |
CLAUDE.md +2 -0
| @@ -50,6 +50,8 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 50 | 50 | - Crafting (jobs): `Task::Craft{recipe: Recipe::{Meal, Tools}, pickup, station, progress}` (was Cook): recipe gives station kind (Campfire/Workbench), accepted input, input count, work, output; `find_crafting`, `input_source`. Tools: `WorkType::Craft` candidate when Tools wanted and TOOL_IRON iron available → craft at a workbench, else `find_workbench` (one, near home, autonomous Structure build like campfire/bed). `Task::Equip{pickup}` (before autonomous candidates) for adults without `Tool{wear}`; equip records Tools use (learned demand); `tool_rate` ×1.5 work; wear per working tick, removed at TOOL_LIFE_TICKS (2 days). Iron has min goal 6. `StructureKind::Statue` (5 gold): plan_homes adds one blueprint at `Home::statue_tile` once a home is built → gold demand via planned_needs; mood +0.1 (`beauty`) when the family home has one; +0.05 (`floored`) when its interior is all WoodFloor. |
| 51 | 51 | - `src/save.rs` save/load. Action::Save/Load (F5/F9). `snapshot(world) -> SaveGame` (serde_json; SAVE_VERSION): seed, tick, rng, `WorldMap::layers()` (`MapLayers`: RLE terrain/plants/walls/roofs as u8 + sparse growth/wear; heights regenerated by `generate(size, seed)` in `restore_map`), `Zones::save` (`ZonesSave`, UVec2-keyed maps become Vecs — serde_json needs string keys), blueprints Vec, Homes, Designations, ColonyGoals, log, losses, colonists (`ColonistSave`, partner as index), items, structures (+fuel), animals. Not saved: jobs, paths, reservations, Affinity, idle timers (colonists restart idle). Storage: web localStorage key `simulator-save` (~150 KB), native `simulator-save.json` in cwd. Load: `start_load` (PreUpdate, exclusive) parses, inserts WorldSeed + restored map, writes WorldGenerated (all reset systems run in Update), stores `PendingLoad`; `finish_load` (PostUpdate) despawns colonists/animals/items/structures and `apply`s the save via SystemState spawn helpers (`spawn_colonist`, `spawn_stack`, `spawn_structure` → Entity, `spawn_animal`). New persistent state = add to SaveGame + snapshot + apply (+ serde derives; bevy `serialize` feature for glam). |
| 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 | +- `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 | +- 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. | |
| 53 | 55 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 54 | 56 | |
| 55 | 57 | ## Performance notes |
src/animals.rs +75 -7
| @@ -9,6 +9,7 @@ use crate::{ | ||
| 9 | 9 | pathfinding::find_path, |
| 10 | 10 | rng::SimRng, |
| 11 | 11 | sim::{SimPos, SimSystems, SimTick, TICKS_PER_HOUR, sim_pos}, |
| 12 | + threats::{Health, Predator}, | |
| 12 | 13 | world::{TILE_SIZE, TilePos, WorldMap}, |
| 13 | 14 | worldgen::{WorldGenerated, spawn_tiles}, |
| 14 | 15 | zones::Zones, |
| @@ -46,15 +47,17 @@ const PLACEMENT_ATTEMPTS: usize = 200; | ||
| 46 | 47 | pub enum AnimalKind { |
| 47 | 48 | Deer, |
| 48 | 49 | Rabbit, |
| 50 | + Wolf, | |
| 49 | 51 | } |
| 50 | 52 | |
| 51 | 53 | impl AnimalKind { |
| 52 | - pub const ALL: [AnimalKind; 2] = [AnimalKind::Deer, AnimalKind::Rabbit]; | |
| 54 | + pub const ALL: [AnimalKind; 3] = [AnimalKind::Deer, AnimalKind::Rabbit, AnimalKind::Wolf]; | |
| 53 | 55 | |
| 54 | 56 | pub fn meat(self) -> u32 { |
| 55 | 57 | match self { |
| 56 | 58 | AnimalKind::Deer => 30, |
| 57 | 59 | AnimalKind::Rabbit => 8, |
| 60 | + AnimalKind::Wolf => 12, | |
| 58 | 61 | } |
| 59 | 62 | } |
| 60 | 63 | |
| @@ -62,13 +65,26 @@ impl AnimalKind { | ||
| 62 | 65 | match self { |
| 63 | 66 | AnimalKind::Deer => 10, |
| 64 | 67 | AnimalKind::Rabbit => 16, |
| 68 | + AnimalKind::Wolf => 3, | |
| 65 | 69 | } |
| 66 | 70 | } |
| 67 | 71 | |
| 72 | + pub fn migration_ticks(self) -> u64 { | |
| 73 | + match self { | |
| 74 | + AnimalKind::Wolf => 24 * TICKS_PER_HOUR, | |
| 75 | + _ => MIGRATION_INTERVAL_TICKS, | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + pub fn is_predator(self) -> bool { | |
| 80 | + self == AnimalKind::Wolf | |
| 81 | + } | |
| 82 | + | |
| 68 | 83 | pub fn tame_work(self) -> u32 { |
| 69 | 84 | match self { |
| 70 | 85 | AnimalKind::Deer => 600, |
| 71 | 86 | AnimalKind::Rabbit => 300, |
| 87 | + AnimalKind::Wolf => u32::MAX, | |
| 72 | 88 | } |
| 73 | 89 | } |
| 74 | 90 | |
| @@ -76,6 +92,7 @@ impl AnimalKind { | ||
| 76 | 92 | match self { |
| 77 | 93 | AnimalKind::Deer => 360, |
| 78 | 94 | AnimalKind::Rabbit => 160, |
| 95 | + AnimalKind::Wolf => 300, | |
| 79 | 96 | } |
| 80 | 97 | } |
| 81 | 98 | |
| @@ -83,6 +100,7 @@ impl AnimalKind { | ||
| 83 | 100 | match self { |
| 84 | 101 | AnimalKind::Deer => 3.0, |
| 85 | 102 | AnimalKind::Rabbit => 2.5, |
| 103 | + AnimalKind::Wolf => 3.4, | |
| 86 | 104 | } |
| 87 | 105 | } |
| 88 | 106 | |
| @@ -90,12 +108,13 @@ impl AnimalKind { | ||
| 90 | 108 | match self { |
| 91 | 109 | AnimalKind::Deer => "deer", |
| 92 | 110 | AnimalKind::Rabbit => "rabbit", |
| 111 | + AnimalKind::Wolf => "wolf", | |
| 93 | 112 | } |
| 94 | 113 | } |
| 95 | 114 | } |
| 96 | 115 | |
| 97 | 116 | #[derive(Component, Debug, Clone, Copy, PartialEq)] |
| 98 | -#[require(Transform, Visibility, TilePos, Path, SimPos, AnimalIdle)] | |
| 117 | +#[require(Transform, Visibility, TilePos, Path, SimPos, AnimalIdle, Health)] | |
| 99 | 118 | pub struct Animal { |
| 100 | 119 | pub kind: AnimalKind, |
| 101 | 120 | } |
| @@ -120,6 +139,9 @@ pub struct AnimalAssets { | ||
| 120 | 139 | deer_head: Part, |
| 121 | 140 | rabbit_body: Part, |
| 122 | 141 | rabbit_ear: Part, |
| 142 | + wolf_body: Part, | |
| 143 | + wolf_head: Part, | |
| 144 | + wolf_tail: Part, | |
| 123 | 145 | } |
| 124 | 146 | |
| 125 | 147 | fn create_animal_assets( |
| @@ -147,6 +169,23 @@ fn create_animal_assets( | ||
| 147 | 169 | Rectangle::new(TILE_SIZE * 0.06, TILE_SIZE * 0.2).into(), |
| 148 | 170 | rabbit, |
| 149 | 171 | ), |
| 172 | + wolf_body: part( | |
| 173 | + Ellipse::new(TILE_SIZE * 0.38, TILE_SIZE * 0.17).into(), | |
| 174 | + Color::srgb_u8(95, 95, 102), | |
| 175 | + ), | |
| 176 | + wolf_head: part( | |
| 177 | + Triangle2d::new( | |
| 178 | + Vec2::new(0.0, -TILE_SIZE * 0.13), | |
| 179 | + Vec2::new(0.0, TILE_SIZE * 0.13), | |
| 180 | + Vec2::new(TILE_SIZE * 0.28, 0.0), | |
| 181 | + ) | |
| 182 | + .into(), | |
| 183 | + Color::srgb_u8(60, 60, 66), | |
| 184 | + ), | |
| 185 | + wolf_tail: part( | |
| 186 | + Rectangle::new(TILE_SIZE * 0.22, TILE_SIZE * 0.07).into(), | |
| 187 | + Color::srgb_u8(70, 70, 76), | |
| 188 | + ), | |
| 150 | 189 | }); |
| 151 | 190 | } |
| 152 | 191 | |
| @@ -183,7 +222,15 @@ pub fn spawn_animal( | ||
| 183 | 222 | c.spawn(part_bundle(&assets.rabbit_ear, at(0.1, 0.2))); |
| 184 | 223 | c.spawn(part_bundle(&assets.rabbit_ear, at(0.18, 0.2))); |
| 185 | 224 | } |
| 225 | + AnimalKind::Wolf => { | |
| 226 | + c.spawn(part_bundle(&assets.wolf_tail, at(-0.42, 0.06))); | |
| 227 | + c.spawn(part_bundle(&assets.wolf_body, at(0.0, 0.0))); | |
| 228 | + c.spawn(part_bundle(&assets.wolf_head, at(0.3, 0.05))); | |
| 229 | + } | |
| 186 | 230 | }); |
| 231 | + if kind.is_predator() { | |
| 232 | + entity.insert(Predator::default()); | |
| 233 | + } | |
| 187 | 234 | entity.id() |
| 188 | 235 | } |
| 189 | 236 | |
| @@ -265,6 +312,9 @@ fn migrate( | ||
| 265 | 312 | } |
| 266 | 313 | let colony = zones.home(map.size / 2); |
| 267 | 314 | for kind in AnimalKind::ALL { |
| 315 | + if !tick.0.is_multiple_of(kind.migration_ticks()) { | |
| 316 | + continue; | |
| 317 | + } | |
| 268 | 318 | let alive = animals.iter().filter(|a| a.kind == kind).count(); |
| 269 | 319 | if missing_animals(kind, alive) |
| 270 | 320 | && let Some(tile) = wild_tile(&map, &mut rng, colony) |
| @@ -298,9 +348,23 @@ pub fn wander( | ||
| 298 | 348 | zones: Res<Zones>, |
| 299 | 349 | mut rng: ResMut<SimRng>, |
| 300 | 350 | colonists: Query<(&TilePos, &CurrentJob), (With<Colonist>, Without<Animal>)>, |
| 301 | - mut animals: Query<(Entity, &TilePos, &mut Path, &mut AnimalIdle, Has<Tame>), With<Animal>>, | |
| 351 | + mut animals: Query< | |
| 352 | + ( | |
| 353 | + Entity, | |
| 354 | + &TilePos, | |
| 355 | + &mut Path, | |
| 356 | + &mut AnimalIdle, | |
| 357 | + Has<Tame>, | |
| 358 | + Option<&Predator>, | |
| 359 | + ), | |
| 360 | + With<Animal>, | |
| 361 | + >, | |
| 302 | 362 | ) { |
| 303 | - let threats: Vec<UVec2> = colonists.iter().map(|(t, _)| t.0).collect(); | |
| 363 | + let threats: Vec<UVec2> = colonists | |
| 364 | + .iter() | |
| 365 | + .map(|(t, _)| t.0) | |
| 366 | + .chain(animals.iter().filter(|a| a.5.is_some()).map(|a| a.1.0)) | |
| 367 | + .collect(); | |
| 304 | 368 | let being_tamed: Vec<Entity> = colonists |
| 305 | 369 | .iter() |
| 306 | 370 | .filter_map(|(_, job)| match job.0?.task { |
| @@ -309,7 +373,10 @@ pub fn wander( | ||
| 309 | 373 | }) |
| 310 | 374 | .collect(); |
| 311 | 375 | let home = zones.home(map.size / 2).as_ivec2(); |
| 312 | - for (entity, tile, mut path, mut idle, tame) in &mut animals { | |
| 376 | + for (entity, tile, mut path, mut idle, tame, predator) in &mut animals { | |
| 377 | + if predator.is_some_and(|p| p.target.is_some()) { | |
| 378 | + continue; | |
| 379 | + } | |
| 313 | 380 | if being_tamed.contains(&entity) { |
| 314 | 381 | path.0.truncate(1); |
| 315 | 382 | continue; |
| @@ -333,11 +400,12 @@ pub fn wander( | ||
| 333 | 400 | } |
| 334 | 401 | continue; |
| 335 | 402 | } |
| 403 | + let threats: &[UVec2] = if predator.is_some() { &[] } else { &threats }; | |
| 336 | 404 | let heading_into_danger = path |
| 337 | 405 | .0 |
| 338 | 406 | .back() |
| 339 | - .is_none_or(|end| flee_goal(*end, &threats).is_some()); | |
| 340 | - if heading_into_danger && let Some(goal) = flee_goal(tile.0, &threats) { | |
| 407 | + .is_none_or(|end| flee_goal(*end, threats).is_some()); | |
| 408 | + if heading_into_danger && let Some(goal) = flee_goal(tile.0, threats) { | |
| 341 | 409 | let goal = goal.clamp(IVec2::ZERO, map.size.as_ivec2() - 1).as_uvec2(); |
| 342 | 410 | if let Some(route) = find_path(&map, tile.0, goal) { |
| 343 | 411 | path.0 = route.into(); |
src/colonist.rs +3 -1
| @@ -12,6 +12,7 @@ use crate::{ | ||
| 12 | 12 | population::Starvation, |
| 13 | 13 | sim::{SimPos, SimSystems, sim_pos}, |
| 14 | 14 | skills::{Skills, WorkCarry}, |
| 15 | + threats::Health, | |
| 15 | 16 | world::{TILE_SIZE, TilePos, WorldMap}, |
| 16 | 17 | worldgen::{WorldGenerated, spawn_tiles}, |
| 17 | 18 | }; |
| @@ -96,7 +97,8 @@ pub fn spawn_colonist( | ||
| 96 | 97 | WorkPriorities, |
| 97 | 98 | Warmth, |
| 98 | 99 | Skills, |
| 99 | - WorkCarry | |
| 100 | + WorkCarry, | |
| 101 | + Health | |
| 100 | 102 | )] |
| 101 | 103 | pub struct Colonist { |
| 102 | 104 | pub name: String, |
src/jobs/mod.rs +81 -11
| @@ -33,6 +33,7 @@ use crate::{ | ||
| 33 | 33 | sim::{SimSystems, SimTick, hour_of_day}, |
| 34 | 34 | skills::{POOR_LEVEL, Skill, Skills, WorkCarry, speed}, |
| 35 | 35 | structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, |
| 36 | + threats::{Health, INJURED}, | |
| 36 | 37 | world::{Plant, Terrain, TileChanged, TilePos, WorldMap}, |
| 37 | 38 | worldgen::WorldGenerated, |
| 38 | 39 | zones::{ |
| @@ -737,7 +738,8 @@ pub fn find_taming( | ||
| 737 | 738 | if animals.iter().filter(|a| a.3).count() >= MAX_TAME { |
| 738 | 739 | return None; |
| 739 | 740 | } |
| 740 | - let (animal, _, kind, _) = nearest_reachable(map, reservations, animals, from, |a| !a.3)?; | |
| 741 | + let tameable = |a: &Wildlife| !a.3 && !a.2.is_predator(); | |
| 742 | + let (animal, _, kind, _) = nearest_reachable(map, reservations, animals, from, tameable)?; | |
| 741 | 743 | Some(Task::Tame { |
| 742 | 744 | animal, |
| 743 | 745 | kind, |
| @@ -1034,6 +1036,26 @@ pub fn find_lighting(ctx: &BuildContext, from: UVec2, dark: bool) -> Option<Task | ||
| 1034 | 1036 | ctx.build_task(from, site, BuildKind::Structure(kind)) |
| 1035 | 1037 | } |
| 1036 | 1038 | |
| 1039 | +pub const DANGER_RANGE: i32 = 6; | |
| 1040 | +pub const DEFEND_RANGE: i32 = 15; | |
| 1041 | +const FLEE_STEPS: f32 = 10.0; | |
| 1042 | + | |
| 1043 | +fn chebyshev(a: UVec2, b: UVec2) -> i32 { | |
| 1044 | + (a.as_ivec2() - b.as_ivec2()).abs().max_element() | |
| 1045 | +} | |
| 1046 | + | |
| 1047 | +pub fn flee_from(map: &WorldMap, from: UVec2, threat: UVec2) -> Option<Vec<UVec2>> { | |
| 1048 | + let away = (from.as_ivec2() - threat.as_ivec2()) | |
| 1049 | + .as_vec2() | |
| 1050 | + .normalize_or(Vec2::X); | |
| 1051 | + let goal = (from.as_ivec2() + (away * FLEE_STEPS).round().as_ivec2()) | |
| 1052 | + .clamp(IVec2::ZERO, map.size.as_ivec2() - 1); | |
| 1053 | + let open = nearest_tile(map, goal.as_uvec2(), 200, |p| { | |
| 1054 | + map.move_cost(p.as_ivec2()).is_some() | |
| 1055 | + })?; | |
| 1056 | + find_path(map, from, open).filter(|route| !route.is_empty()) | |
| 1057 | +} | |
| 1058 | + | |
| 1037 | 1059 | pub fn chilly_soon(tick: SimTick) -> bool { |
| 1038 | 1060 | let later = SimTick(tick.0 + LIGHT_LOOKAHEAD_TICKS); |
| 1039 | 1061 | outdoor_temperature(tick) < COLD_TEMP || outdoor_temperature(later) < COLD_TEMP |
| @@ -1183,6 +1205,7 @@ fn choose_jobs( | ||
| 1183 | 1205 | &Warmth, |
| 1184 | 1206 | Has<Tool>, |
| 1185 | 1207 | &Skills, |
| 1208 | + &Health, | |
| 1186 | 1209 | ), |
| 1187 | 1210 | With<Colonist>, |
| 1188 | 1211 | >, |
| @@ -1190,14 +1213,28 @@ fn choose_jobs( | ||
| 1190 | 1213 | mut gather_memo: Local<GatherMemo>, |
| 1191 | 1214 | ) { |
| 1192 | 1215 | let (homes, designations, reach) = plans; |
| 1216 | + let wolves: Vec<(Entity, UVec2)> = animals | |
| 1217 | + .iter() | |
| 1218 | + .filter(|(_, _, animal, _)| animal.kind.is_predator()) | |
| 1219 | + .map(|(entity, pos, ..)| (entity, pos.0)) | |
| 1220 | + .collect(); | |
| 1221 | + let nearest_wolf = |tile: UVec2, range: i32| { | |
| 1222 | + wolves | |
| 1223 | + .iter() | |
| 1224 | + .map(|&(entity, pos)| (entity, pos, chebyshev(pos, tile))) | |
| 1225 | + .filter(|&(.., d)| d <= range) | |
| 1226 | + .min_by_key(|&(_, pos, d)| (d, pos.y, pos.x)) | |
| 1227 | + }; | |
| 1193 | 1228 | let mut anyone_ready = false; |
| 1194 | - for (_, _, path, job, mut idle, _, rest, hunger, ..) in &mut colonists { | |
| 1229 | + for (_, tile, path, job, mut idle, _, rest, hunger, ..) in &mut colonists { | |
| 1195 | 1230 | let idle_now = job.0.is_none() && path.0.is_empty(); |
| 1196 | 1231 | if idle_now { |
| 1197 | 1232 | idle.0.tick(time.delta()); |
| 1198 | 1233 | } |
| 1199 | - anyone_ready |= | |
| 1200 | - (idle_now && idle.0.is_finished()) || rest.0 < EXHAUSTED_REST || hunger.0 < STARVING; | |
| 1234 | + anyone_ready |= (idle_now && idle.0.is_finished()) | |
| 1235 | + || rest.0 < EXHAUSTED_REST | |
| 1236 | + || hunger.0 < STARVING | |
| 1237 | + || nearest_wolf(tile.0, DANGER_RANGE).is_some(); | |
| 1201 | 1238 | } |
| 1202 | 1239 | if !anyone_ready { |
| 1203 | 1240 | return; |
| @@ -1273,16 +1310,26 @@ fn choose_jobs( | ||
| 1273 | 1310 | warmth, |
| 1274 | 1311 | has_tool, |
| 1275 | 1312 | skills, |
| 1313 | + health, | |
| 1276 | 1314 | ) in &mut colonists |
| 1277 | 1315 | { |
| 1278 | 1316 | let exhausted = rest.0 < EXHAUSTED_REST; |
| 1279 | 1317 | let starving = hunger.0 < STARVING && food_to_fetch(&reservations, &food_tiles); |
| 1280 | - let urgent = exhausted || starving; | |
| 1281 | - if urgent | |
| 1282 | - && job.0.is_some_and(|j| { | |
| 1283 | - !j.ordered && !matches!(j.task, Task::Sleep { .. } | Task::Eat { .. }) | |
| 1284 | - }) | |
| 1285 | - { | |
| 1318 | + let danger = nearest_wolf(tile.0, DANGER_RANGE); | |
| 1319 | + let urgent = exhausted || starving || danger.is_some(); | |
| 1320 | + let fighting_wolf = |task: Task| { | |
| 1321 | + matches!(task, Task::Hunt { kind, .. } if kind.is_predator()) | |
| 1322 | + || matches!(task, Task::MoveTo(_)) | |
| 1323 | + }; | |
| 1324 | + let interrupt = |j: Job| { | |
| 1325 | + !j.ordered | |
| 1326 | + && if danger.is_some() { | |
| 1327 | + !fighting_wolf(j.task) | |
| 1328 | + } else { | |
| 1329 | + !matches!(j.task, Task::Sleep { .. } | Task::Eat { .. }) | |
| 1330 | + } | |
| 1331 | + }; | |
| 1332 | + if urgent && job.0.is_some_and(interrupt) { | |
| 1286 | 1333 | reservations.release_all(entity); |
| 1287 | 1334 | job.0 = None; |
| 1288 | 1335 | path.0.truncate(1); |
| @@ -1310,7 +1357,30 @@ fn choose_jobs( | ||
| 1310 | 1357 | let food = wants_food(hunger.0) |
| 1311 | 1358 | .then(|| find_food(&map, &reservations, &snapshot, tile.0)) |
| 1312 | 1359 | .flatten(); |
| 1313 | - let task = if exhausted { | |
| 1360 | + let fighter = !child && health.0 >= INJURED && priorities.allows(WorkType::Food); | |
| 1361 | + let free_to_fight = fighter && !exhausted && !wants_food(hunger.0); | |
| 1362 | + let defend = | |
| 1363 | + danger | |
| 1364 | + .or_else(|| nearest_wolf(home, DEFEND_RANGE)) | |
| 1365 | + .filter(|&(wolf, pos, _)| { | |
| 1366 | + free_to_fight | |
| 1367 | + && reach.allows_near(pos) | |
| 1368 | + && reservations.is_free(Target::Animal(wolf)) | |
| 1369 | + }); | |
| 1370 | + let flee = danger | |
| 1371 | + .filter(|_| !fighter) | |
| 1372 | + .and_then(|(_, wolf, _)| flee_from(&map, tile.0, wolf)); | |
| 1373 | + let task = if let Some(route) = flee { | |
| 1374 | + let goal = route.last().copied().unwrap_or(tile.0); | |
| 1375 | + path.0 = route.into(); | |
| 1376 | + Some(Task::MoveTo(goal)) | |
| 1377 | + } else if let Some((wolf, ..)) = defend { | |
| 1378 | + Some(Task::Hunt { | |
| 1379 | + animal: wolf, | |
| 1380 | + kind: AnimalKind::Wolf, | |
| 1381 | + progress: 0, | |
| 1382 | + }) | |
| 1383 | + } else if exhausted { | |
| 1314 | 1384 | Some(Task::Sleep { |
| 1315 | 1385 | spot: sleep.spot(tile.0), |
| 1316 | 1386 | }) |
src/jobs/tests.rs +28 -0
| @@ -1526,3 +1526,31 @@ fn colonists_try_work_they_are_poor_at_last() { | ||
| 1526 | 1526 | } |
| 1527 | 1527 | )); |
| 1528 | 1528 | } |
| 1529 | + | |
| 1530 | +// A wolf near the colony is everyone's business: healthy adults drop their work to go after it, | |
| 1531 | +// while children (and the badly hurt) run the other way. | |
| 1532 | +#[test] | |
| 1533 | +fn adults_fight_wolves_and_children_flee() { | |
| 1534 | + let mut app = test_app(WorldMap::filled(UVec2::splat(40), Terrain::Grass)); | |
| 1535 | + let wolf = app | |
| 1536 | + .world_mut() | |
| 1537 | + .spawn(( | |
| 1538 | + Animal { | |
| 1539 | + kind: AnimalKind::Wolf, | |
| 1540 | + }, | |
| 1541 | + TilePos(UVec2::new(20, 20)), | |
| 1542 | + )) | |
| 1543 | + .id(); | |
| 1544 | + let adult = spawn_colonist(&mut app, UVec2::new(15, 20)); | |
| 1545 | + let child = spawn_colonist(&mut app, UVec2::new(22, 20)); | |
| 1546 | + app.world_mut() | |
| 1547 | + .entity_mut(child) | |
| 1548 | + .insert(crate::family::Child { born: 0 }); | |
| 1549 | + run_ticks(&mut app, 1); | |
| 1550 | + assert!(matches!( | |
| 1551 | + job(&app, adult).map(|j| j.task), | |
| 1552 | + Some(Task::Hunt { animal, .. }) if animal == wolf | |
| 1553 | + )); | |
| 1554 | + let route = &app.world().get::<Path>(child).unwrap().0; | |
| 1555 | + assert!(route.back().is_some_and(|end| end.x > 26)); | |
| 1556 | +} | |
src/main.rs +2 -1
| @@ -26,6 +26,7 @@ mod selection; | ||
| 26 | 26 | mod sim; |
| 27 | 27 | mod skills; |
| 28 | 28 | mod structures; |
| 29 | +mod threats; | |
| 29 | 30 | mod tilemap; |
| 30 | 31 | mod ui; |
| 31 | 32 | mod world; |
| @@ -81,6 +82,6 @@ fn main() { | ||
| 81 | 82 | zones::ZonesPlugin, |
| 82 | 83 | ui::HudPlugin, |
| 83 | 84 | )) |
| 84 | - .add_plugins(save::SavePlugin) | |
| 85 | + .add_plugins((save::SavePlugin, threats::ThreatsPlugin)) | |
| 85 | 86 | .run(); |
| 86 | 87 | } |
src/mood.rs +10 -1
| @@ -14,6 +14,7 @@ use crate::{ | ||
| 14 | 14 | sim::{SimSystems, SimTick, TICKS_PER_HOUR}, |
| 15 | 15 | skills::Skills, |
| 16 | 16 | structures::{Structure, StructureKind}, |
| 17 | + threats::{HURT, Health}, | |
| 17 | 18 | world::{TilePos, WorldMap}, |
| 18 | 19 | worldgen::WorldGenerated, |
| 19 | 20 | }; |
| @@ -112,6 +113,7 @@ pub struct MoodFactors { | ||
| 112 | 113 | pub cold: bool, |
| 113 | 114 | pub beauty: bool, |
| 114 | 115 | pub floored: bool, |
| 116 | + pub hurt: bool, | |
| 115 | 117 | } |
| 116 | 118 | |
| 117 | 119 | pub fn mood_target(f: MoodFactors) -> f32 { |
| @@ -144,6 +146,9 @@ pub fn mood_target(f: MoodFactors) -> f32 { | ||
| 144 | 146 | if f.floored { |
| 145 | 147 | mood += 0.05; |
| 146 | 148 | } |
| 149 | + if f.hurt { | |
| 150 | + mood -= 0.1; | |
| 151 | + } | |
| 147 | 152 | mood.clamp(0.0, 1.0) |
| 148 | 153 | } |
| 149 | 154 | |
| @@ -211,6 +216,7 @@ fn update_mood( | ||
| 211 | 216 | Has<Partner>, |
| 212 | 217 | Option<&Family>, |
| 213 | 218 | &Warmth, |
| 219 | + &Health, | |
| 214 | 220 | )>, |
| 215 | 221 | ) { |
| 216 | 222 | if !tick.0.is_multiple_of(TICKS_PER_HOUR) { |
| @@ -222,7 +228,9 @@ fn update_mood( | ||
| 222 | 228 | .filter(|(_, s)| s.kind == StructureKind::Statue) |
| 223 | 229 | .map(|(pos, _)| pos.0) |
| 224 | 230 | .collect(); |
| 225 | - for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth) in &mut colonists { | |
| 231 | + for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth, health) in | |
| 232 | + &mut colonists | |
| 233 | + { | |
| 226 | 234 | let sick_now = sick.is_some_and(|s| tick.0 < s.until); |
| 227 | 235 | if sick.is_some() && !sick_now { |
| 228 | 236 | commands.entity(entity).remove::<Sick>(); |
| @@ -244,6 +252,7 @@ fn update_mood( | ||
| 244 | 252 | cold: warmth.cold(), |
| 245 | 253 | beauty, |
| 246 | 254 | floored, |
| 255 | + hurt: health.0 < HURT, | |
| 247 | 256 | }); |
| 248 | 257 | mood.0 += (target - mood.0) / MOOD_HOURS; |
| 249 | 258 | } |
src/population.rs +45 -6
| @@ -4,12 +4,13 @@ use std::collections::VecDeque; | ||
| 4 | 4 | use bevy::prelude::*; |
| 5 | 5 | |
| 6 | 6 | use crate::{ |
| 7 | - colonist::{Colonist, ColonistAssets, NAMES, spawn_colonist}, | |
| 7 | + colonist::{Colonist, ColonistAssets, NAMES, Path, spawn_colonist}, | |
| 8 | 8 | economy::in_colony, |
| 9 | 9 | food::Hunger, |
| 10 | 10 | items::{Carrying, Item, ItemAssets, ItemQuery, drop_items}, |
| 11 | 11 | jobs::{CurrentJob, Job, Reservations, Task}, |
| 12 | 12 | mood::Losses, |
| 13 | + pathfinding::find_path, | |
| 13 | 14 | rng::SimRng, |
| 14 | 15 | sim::{SimSystems, SimTick, TICKS_PER_HOUR, format_clock}, |
| 15 | 16 | structures::{Structure, StructureKind}, |
| @@ -34,6 +35,7 @@ impl Plugin for PopulationPlugin { | ||
| 34 | 35 | pub const STARVATION_TICKS: u32 = 24 * TICKS_PER_HOUR as u32; |
| 35 | 36 | pub const MAX_COLONISTS: usize = 20; |
| 36 | 37 | pub const ARRIVAL_CHANCE: f32 = 0.6; |
| 38 | +const ARRIVAL_ATTEMPTS: usize = 10; | |
| 37 | 39 | const ARRIVAL_HOUR_TICK: u64 = 4 * TICKS_PER_HOUR; |
| 38 | 40 | const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR; |
| 39 | 41 | const LOG_LENGTH: usize = 4; |
| @@ -156,7 +158,11 @@ fn arrivals( | ||
| 156 | 158 | if !lucky || !prosperous(count, food, beds.saturating_sub(count), wood) { |
| 157 | 159 | return; |
| 158 | 160 | } |
| 159 | - let Some(tile) = edge_tile(&map, &mut rng) else { | |
| 161 | + let home = zones.home(map.size / 2); | |
| 162 | + let Some((tile, route)) = (0..ARRIVAL_ATTEMPTS).find_map(|_| { | |
| 163 | + let tile = edge_tile(&map, &mut rng)?; | |
| 164 | + find_path(&map, tile, home).map(|route| (tile, route)) | |
| 165 | + }) else { | |
| 160 | 166 | return; |
| 161 | 167 | }; |
| 162 | 168 | let taken: Vec<&str> = colonists.iter().map(|c| c.name.as_str()).collect(); |
| @@ -171,10 +177,13 @@ fn arrivals( | ||
| 171 | 177 | index, |
| 172 | 178 | tile, |
| 173 | 179 | ); |
| 174 | - commands.entity(newcomer).insert(CurrentJob(Some(Job { | |
| 175 | - task: Task::MoveTo(zones.home(map.size / 2)), | |
| 176 | - ordered: false, | |
| 177 | - }))); | |
| 180 | + commands.entity(newcomer).insert(( | |
| 181 | + CurrentJob(Some(Job { | |
| 182 | + task: Task::MoveTo(home), | |
| 183 | + ordered: false, | |
| 184 | + })), | |
| 185 | + Path(route.into()), | |
| 186 | + )); | |
| 178 | 187 | log.push( |
| 179 | 188 | *tick, |
| 180 | 189 | format!("{} has joined the colony", NAMES[index % NAMES.len()]), |
| @@ -248,4 +257,34 @@ mod tests { | ||
| 248 | 257 | let dropped: Vec<Item> = world.query::<&Item>().iter(world).copied().collect(); |
| 249 | 258 | assert_eq!(dropped.len(), 1); |
| 250 | 259 | } |
| 260 | + | |
| 261 | + // Newcomers used to get a "walk home" job with no route, so they stood at the map edge until | |
| 262 | + // they starved or a wolf found them. They now arrive with a path to the colony. | |
| 263 | + #[test] | |
| 264 | + fn newcomers_walk_to_the_colony() { | |
| 265 | + use crate::{colonist::Path, items::ItemKind, world::Terrain}; | |
| 266 | + let map = WorldMap::filled(UVec2::splat(40), Terrain::Grass); | |
| 267 | + let arrived = (0..20).find_map(|seed| { | |
| 268 | + let mut zones = Zones::default(); | |
| 269 | + zones.add_tiles(&map, [UVec2::new(20, 20)]); | |
| 270 | + let mut app = App::new(); | |
| 271 | + app.insert_resource(map.clone()) | |
| 272 | + .insert_resource(zones) | |
| 273 | + .insert_resource(SimTick(ARRIVAL_HOUR_TICK)) | |
| 274 | + .insert_resource(SimRng::new(seed)) | |
| 275 | + .init_resource::<ColonistAssets>() | |
| 276 | + .init_resource::<ItemAssets>() | |
| 277 | + .init_resource::<Assets<ColorMaterial>>() | |
| 278 | + .init_resource::<ColonyLog>() | |
| 279 | + .add_systems(Update, arrivals); | |
| 280 | + let world = app.world_mut(); | |
| 281 | + world.spawn((Item::new(ItemKind::Wood, 30), TilePos(UVec2::new(20, 20)))); | |
| 282 | + world.spawn((Item::new(ItemKind::Meal, 5), TilePos(UVec2::new(21, 20)))); | |
| 283 | + app.update(); | |
| 284 | + let world = app.world_mut(); | |
| 285 | + let mut query = world.query_filtered::<&Path, With<Colonist>>(); | |
| 286 | + query.iter(world).next().map(|path| path.0.back().copied()) | |
| 287 | + }); | |
| 288 | + assert_eq!(arrived, Some(Some(UVec2::new(20, 20)))); | |
| 289 | + } | |
| 251 | 290 | } |
src/save.rs +11 -0
| @@ -19,6 +19,7 @@ use crate::{ | ||
| 19 | 19 | sim::{SimPos, SimTick, sim_pos}, |
| 20 | 20 | skills::Skills, |
| 21 | 21 | structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, |
| 22 | + threats::Health, | |
| 22 | 23 | world::{MapLayers, TilePos, WorldMap}, |
| 23 | 24 | worldgen::{WorldGenerated, WorldSeed, generate}, |
| 24 | 25 | zones::{Zones, ZonesSave}, |
| @@ -57,6 +58,12 @@ pub struct ColonistSave { | ||
| 57 | 58 | starvation: u32, |
| 58 | 59 | #[serde(default)] |
| 59 | 60 | skills: Skills, |
| 61 | + #[serde(default = "full_health")] | |
| 62 | + health: f32, | |
| 63 | +} | |
| 64 | + | |
| 65 | +fn full_health() -> f32 { | |
| 66 | + 1.0 | |
| 60 | 67 | } |
| 61 | 68 | |
| 62 | 69 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] |
| @@ -110,6 +117,7 @@ pub fn snapshot(world: &mut World) -> SaveGame { | ||
| 110 | 117 | Option<&Unhappy>, |
| 111 | 118 | Option<&Starvation>, |
| 112 | 119 | Option<&Skills>, |
| 120 | + Option<&Health>, | |
| 113 | 121 | ), |
| 114 | 122 | )>(); |
| 115 | 123 | let rows: Vec<_> = colonist_query.iter(world).collect(); |
| @@ -130,6 +138,7 @@ pub fn snapshot(world: &mut World) -> SaveGame { | ||
| 130 | 138 | unhappy, |
| 131 | 139 | starvation, |
| 132 | 140 | skills, |
| 141 | + health, | |
| 133 | 142 | ) = extra; |
| 134 | 143 | ColonistSave { |
| 135 | 144 | name: colonist.name.clone(), |
| @@ -150,6 +159,7 @@ pub fn snapshot(world: &mut World) -> SaveGame { | ||
| 150 | 159 | unhappy: unhappy.map_or(0, |u| u.0), |
| 151 | 160 | starvation: starvation.map_or(0, |s| s.0), |
| 152 | 161 | skills: skills.cloned().unwrap_or_default(), |
| 162 | + health: health.map_or(1.0, |h| h.0), | |
| 153 | 163 | } |
| 154 | 164 | }, |
| 155 | 165 | ) |
| @@ -300,6 +310,7 @@ pub fn apply(world: &mut World, save: SaveGame) { | ||
| 300 | 310 | Unhappy(c.unhappy), |
| 301 | 311 | Starvation(c.starvation), |
| 302 | 312 | c.skills.clone(), |
| 313 | + Health(c.health), | |
| 303 | 314 | )); |
| 304 | 315 | if let Some(mood) = c.mood { |
| 305 | 316 | e.insert(Mood(mood)); |
src/selection.rs +49 -40
| @@ -20,6 +20,7 @@ use crate::{ | ||
| 20 | 20 | sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day}, |
| 21 | 21 | skills::Skills, |
| 22 | 22 | structures::{Fuel, Structure, StructureKind}, |
| 23 | + threats::Health, | |
| 23 | 24 | ui::{HideableUi, PointerOverUi}, |
| 24 | 25 | world::{TILE_SIZE, TilePos, WorldMap}, |
| 25 | 26 | worldgen::{WorldGenerated, WorldSeed}, |
| @@ -283,6 +284,7 @@ fn update_tile_info( | ||
| 283 | 284 | Option<&Warmth>, |
| 284 | 285 | Has<Tool>, |
| 285 | 286 | Option<&Skills>, |
| 287 | + Option<&Health>, | |
| 286 | 288 | )>, |
| 287 | 289 | mut text: Single<&mut Text, With<TileInfoText>>, |
| 288 | 290 | ) { |
| @@ -303,46 +305,53 @@ fn update_tile_info( | ||
| 303 | 305 | fed: hunger.0, |
| 304 | 306 | }) + &kin |
| 305 | 307 | .get(entity) |
| 306 | - .map(|(partner, child, mood, age, sick, warmth, tool, skills)| { | |
| 307 | - let partner = partner | |
| 308 | - .and_then(|p| colonists.get(p.0).ok()) | |
| 309 | - .map(|p| format!(", partner {}", p.0.name)); | |
| 310 | - let child = | |
| 311 | - child.map(|c| format!(", child (grows up in {}d)", c.days_left(*tick))); | |
| 312 | - let mood = mood | |
| 313 | - .map(|m| format!(", mood {:.0}%", m.0 * 100.0)) | |
| 314 | - .unwrap_or_default(); | |
| 315 | - let age = age | |
| 316 | - .map(|a| format!(", age {}", a.years(*tick))) | |
| 317 | - .unwrap_or_default(); | |
| 318 | - let sick = if sick { ", sick" } else { "" }; | |
| 319 | - let tool = if tool { ", has tools" } else { "" }; | |
| 320 | - let skills = skills | |
| 321 | - .filter(|_| child.is_none()) | |
| 322 | - .map(|s| { | |
| 323 | - let best: Vec<String> = s | |
| 324 | - .best(3) | |
| 325 | - .iter() | |
| 326 | - .map(|(skill, level)| format!("{} {level}", skill.name())) | |
| 327 | - .collect(); | |
| 328 | - format!("\nSkills: {}", best.join(", ")) | |
| 329 | - }) | |
| 330 | - .unwrap_or_default(); | |
| 331 | - let warmth = warmth | |
| 332 | - .map(|w| { | |
| 333 | - let cold = if w.cold() { " (cold)" } else { "" }; | |
| 334 | - format!(", {:.0}C{cold}", w.0) | |
| 335 | - }) | |
| 336 | - .unwrap_or_default(); | |
| 337 | - partner.unwrap_or_default() | |
| 338 | - + &child.unwrap_or_default() | |
| 339 | - + &mood | |
| 340 | - + &age | |
| 341 | - + sick | |
| 342 | - + tool | |
| 343 | - + &warmth | |
| 344 | - + &skills | |
| 345 | - }) | |
| 308 | + .map( | |
| 309 | + |(partner, child, mood, age, sick, warmth, tool, skills, health)| { | |
| 310 | + let partner = partner | |
| 311 | + .and_then(|p| colonists.get(p.0).ok()) | |
| 312 | + .map(|p| format!(", partner {}", p.0.name)); | |
| 313 | + let child = child | |
| 314 | + .map(|c| format!(", child (grows up in {}d)", c.days_left(*tick))); | |
| 315 | + let mood = mood | |
| 316 | + .map(|m| format!(", mood {:.0}%", m.0 * 100.0)) | |
| 317 | + .unwrap_or_default(); | |
| 318 | + let age = age | |
| 319 | + .map(|a| format!(", age {}", a.years(*tick))) | |
| 320 | + .unwrap_or_default(); | |
| 321 | + let sick = if sick { ", sick" } else { "" }; | |
| 322 | + let tool = if tool { ", has tools" } else { "" }; | |
| 323 | + let health = health | |
| 324 | + .filter(|h| h.0 < 1.0) | |
| 325 | + .map(|h| format!(", health {:.0}%", h.0 * 100.0)) | |
| 326 | + .unwrap_or_default(); | |
| 327 | + let skills = skills | |
| 328 | + .filter(|_| child.is_none()) | |
| 329 | + .map(|s| { | |
| 330 | + let best: Vec<String> = s | |
| 331 | + .best(3) | |
| 332 | + .iter() | |
| 333 | + .map(|(skill, level)| format!("{} {level}", skill.name())) | |
| 334 | + .collect(); | |
| 335 | + format!("\nSkills: {}", best.join(", ")) | |
| 336 | + }) | |
| 337 | + .unwrap_or_default(); | |
| 338 | + let warmth = warmth | |
| 339 | + .map(|w| { | |
| 340 | + let cold = if w.cold() { " (cold)" } else { "" }; | |
| 341 | + format!(", {:.0}C{cold}", w.0) | |
| 342 | + }) | |
| 343 | + .unwrap_or_default(); | |
| 344 | + partner.unwrap_or_default() | |
| 345 | + + &child.unwrap_or_default() | |
| 346 | + + &mood | |
| 347 | + + &age | |
| 348 | + + sick | |
| 349 | + + tool | |
| 350 | + + &health | |
| 351 | + + &warmth | |
| 352 | + + &skills | |
| 353 | + }, | |
| 354 | + ) | |
| 346 | 355 | .unwrap_or_default() |
| 347 | 356 | }) |
| 348 | 357 | .unwrap_or_else(|_| "-".into()), |
src/threats.rs +302 -0
| @@ -0,0 +1,302 @@ | ||
| 1 | +use std::collections::HashSet; | |
| 2 | + | |
| 3 | +use bevy::prelude::*; | |
| 4 | + | |
| 5 | +use crate::{ | |
| 6 | + animals::Animal, | |
| 7 | + colonist::{Colonist, Path}, | |
| 8 | + items::{Carrying, ItemAssets, ItemQuery, drop_items}, | |
| 9 | + jobs::{CurrentJob, Reservations, Target}, | |
| 10 | + mood::Losses, | |
| 11 | + needs::is_sleeping, | |
| 12 | + pathfinding::{find_path_adjacent, is_adjacent}, | |
| 13 | + population::ColonyLog, | |
| 14 | + sim::{SimSystems, SimTick, TICKS_PER_HOUR}, | |
| 15 | + structures::{Structure, StructureKind}, | |
| 16 | + world::{TilePos, WorldMap}, | |
| 17 | +}; | |
| 18 | + | |
| 19 | +pub struct ThreatsPlugin; | |
| 20 | + | |
| 21 | +impl Plugin for ThreatsPlugin { | |
| 22 | + fn build(&self, app: &mut App) { | |
| 23 | + app.add_systems( | |
| 24 | + FixedUpdate, | |
| 25 | + ( | |
| 26 | + prowl.in_set(SimSystems::Decide), | |
| 27 | + (bite, heal).chain().in_set(SimSystems::Act), | |
| 28 | + ), | |
| 29 | + ); | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 33 | +pub const PROWL_RANGE: i32 = 24; | |
| 34 | +pub const COLONIST_DETOUR: i32 = 6; | |
| 35 | +pub const COLONIST_BITE: f32 = 0.25; | |
| 36 | +pub const ANIMAL_BITE: f32 = 0.5; | |
| 37 | +pub const BITE_COOLDOWN_TICKS: u32 = 60; | |
| 38 | +pub const FED_TICKS: u64 = 12 * TICKS_PER_HOUR; | |
| 39 | +pub const INJURED: f32 = 0.5; | |
| 40 | +pub const HURT: f32 = 0.75; | |
| 41 | +const HEAL_PER_DAY: f32 = 0.3; | |
| 42 | +const BED_HEAL_FACTOR: f32 = 3.0; | |
| 43 | +const HEAL_INTERVAL_TICKS: u64 = 60; | |
| 44 | +const PROWL_INTERVAL_TICKS: u64 = 30; | |
| 45 | + | |
| 46 | +#[derive(Component, Debug, Clone, Copy, PartialEq)] | |
| 47 | +pub struct Health(pub f32); | |
| 48 | + | |
| 49 | +impl Default for Health { | |
| 50 | + fn default() -> Self { | |
| 51 | + Self(1.0) | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +#[derive(Component, Debug, Default, Clone, Copy, PartialEq)] | |
| 56 | +pub struct Predator { | |
| 57 | + pub target: Option<Entity>, | |
| 58 | + pub fed_until: u64, | |
| 59 | + pub cooldown: u32, | |
| 60 | +} | |
| 61 | + | |
| 62 | +#[derive(Clone, Copy, Debug, PartialEq)] | |
| 63 | +pub struct Prey { | |
| 64 | + pub entity: Entity, | |
| 65 | + pub tile: UVec2, | |
| 66 | + pub colonist: bool, | |
| 67 | +} | |
| 68 | + | |
| 69 | +pub fn pick_prey(from: UVec2, prey: &[Prey]) -> Option<Prey> { | |
| 70 | + prey.iter() | |
| 71 | + .filter_map(|p| { | |
| 72 | + let distance = (p.tile.as_ivec2() - from.as_ivec2()).abs().max_element(); | |
| 73 | + let cost = distance + if p.colonist { COLONIST_DETOUR } else { 0 }; | |
| 74 | + (distance <= PROWL_RANGE).then_some((cost, p)) | |
| 75 | + }) | |
| 76 | + .min_by_key(|(cost, p)| (*cost, p.tile.y, p.tile.x)) | |
| 77 | + .map(|(_, p)| *p) | |
| 78 | +} | |
| 79 | + | |
| 80 | +#[allow(clippy::type_complexity)] | |
| 81 | +fn prowl( | |
| 82 | + tick: Res<SimTick>, | |
| 83 | + map: Res<WorldMap>, | |
| 84 | + mut wolves: Query<(&TilePos, &mut Path, &mut Predator), With<Animal>>, | |
| 85 | + animals: Query<(Entity, &TilePos), (With<Animal>, Without<Predator>)>, | |
| 86 | + colonists: Query<(Entity, &TilePos), With<Colonist>>, | |
| 87 | +) { | |
| 88 | + if !tick.0.is_multiple_of(PROWL_INTERVAL_TICKS) { | |
| 89 | + return; | |
| 90 | + } | |
| 91 | + let prey: Vec<Prey> = animals | |
| 92 | + .iter() | |
| 93 | + .map(|(entity, tile)| Prey { | |
| 94 | + entity, | |
| 95 | + tile: tile.0, | |
| 96 | + colonist: false, | |
| 97 | + }) | |
| 98 | + .chain(colonists.iter().map(|(entity, tile)| Prey { | |
| 99 | + entity, | |
| 100 | + tile: tile.0, | |
| 101 | + colonist: true, | |
| 102 | + })) | |
| 103 | + .collect(); | |
| 104 | + for (tile, mut path, mut predator) in &mut wolves { | |
| 105 | + if tick.0 < predator.fed_until { | |
| 106 | + predator.target = None; | |
| 107 | + continue; | |
| 108 | + } | |
| 109 | + let Some(target) = pick_prey(tile.0, &prey) else { | |
| 110 | + predator.target = None; | |
| 111 | + continue; | |
| 112 | + }; | |
| 113 | + predator.target = Some(target.entity); | |
| 114 | + let end = path.0.back().copied().unwrap_or(tile.0); | |
| 115 | + if is_adjacent(end.as_ivec2(), target.tile.as_ivec2()) || end == target.tile { | |
| 116 | + continue; | |
| 117 | + } | |
| 118 | + if let Some(route) = find_path_adjacent(&map, tile.0, target.tile) { | |
| 119 | + let entering = path.0.front().copied(); | |
| 120 | + path.0 = entering.into_iter().chain(route).collect(); | |
| 121 | + } | |
| 122 | + } | |
| 123 | +} | |
| 124 | + | |
| 125 | +#[allow(clippy::too_many_arguments, clippy::type_complexity)] | |
| 126 | +fn bite( | |
| 127 | + mut commands: Commands, | |
| 128 | + tick: Res<SimTick>, | |
| 129 | + item_assets: Res<ItemAssets>, | |
| 130 | + mut log: ResMut<ColonyLog>, | |
| 131 | + mut losses: ResMut<Losses>, | |
| 132 | + mut reservations: ResMut<Reservations>, | |
| 133 | + mut items: ItemQuery, | |
| 134 | + mut wolves: Query<(&TilePos, &mut Predator), With<Animal>>, | |
| 135 | + mut colonists: Query< | |
| 136 | + (&Colonist, &TilePos, &mut Health, &Carrying), | |
| 137 | + (Without<Animal>, Without<Predator>), | |
| 138 | + >, | |
| 139 | + mut prey: Query<(&TilePos, &mut Health), (With<Animal>, Without<Predator>, Without<Colonist>)>, | |
| 140 | +) { | |
| 141 | + for (tile, mut predator) in &mut wolves { | |
| 142 | + predator.cooldown = predator.cooldown.saturating_sub(1); | |
| 143 | + let Some(target) = predator.target else { | |
| 144 | + continue; | |
| 145 | + }; | |
| 146 | + if predator.cooldown > 0 { | |
| 147 | + continue; | |
| 148 | + } | |
| 149 | + let near = |other: &TilePos| { | |
| 150 | + other.0 == tile.0 || is_adjacent(other.0.as_ivec2(), tile.0.as_ivec2()) | |
| 151 | + }; | |
| 152 | + if let Ok((colonist, pos, mut health, carrying)) = colonists.get_mut(target) { | |
| 153 | + if !near(pos) { | |
| 154 | + continue; | |
| 155 | + } | |
| 156 | + predator.cooldown = BITE_COOLDOWN_TICKS; | |
| 157 | + if health.0 >= 1.0 { | |
| 158 | + log.push(*tick, format!("{} was attacked by a wolf", colonist.name)); | |
| 159 | + } | |
| 160 | + health.0 -= COLONIST_BITE; | |
| 161 | + if health.0 <= 0.0 { | |
| 162 | + if let Some(load) = carrying.0 { | |
| 163 | + drop_items(&mut commands, &mut items, &item_assets, pos.0, load); | |
| 164 | + } | |
| 165 | + reservations.release_all(target); | |
| 166 | + commands.entity(target).despawn(); | |
| 167 | + losses.0.push(tick.0); | |
| 168 | + log.push(*tick, format!("{} was killed by a wolf", colonist.name)); | |
| 169 | + predator.fed_until = tick.0 + FED_TICKS; | |
| 170 | + predator.target = None; | |
| 171 | + } | |
| 172 | + } else if let Ok((pos, mut health)) = prey.get_mut(target) { | |
| 173 | + if !near(pos) { | |
| 174 | + continue; | |
| 175 | + } | |
| 176 | + predator.cooldown = BITE_COOLDOWN_TICKS; | |
| 177 | + health.0 -= ANIMAL_BITE; | |
| 178 | + if health.0 <= 0.0 { | |
| 179 | + reservations.release(Target::Animal(target)); | |
| 180 | + commands.entity(target).despawn(); | |
| 181 | + predator.fed_until = tick.0 + FED_TICKS; | |
| 182 | + predator.target = None; | |
| 183 | + } | |
| 184 | + } else { | |
| 185 | + predator.target = None; | |
| 186 | + } | |
| 187 | + } | |
| 188 | +} | |
| 189 | + | |
| 190 | +pub fn healed(health: f32, ticks: f32, in_bed: bool) -> f32 { | |
| 191 | + let rate = HEAL_PER_DAY / (24.0 * TICKS_PER_HOUR as f32); | |
| 192 | + let rate = if in_bed { rate * BED_HEAL_FACTOR } else { rate }; | |
| 193 | + (health + rate * ticks).min(1.0) | |
| 194 | +} | |
| 195 | + | |
| 196 | +#[allow(clippy::type_complexity)] | |
| 197 | +fn heal( | |
| 198 | + tick: Res<SimTick>, | |
| 199 | + structures: Query<(&TilePos, &Structure), Without<Health>>, | |
| 200 | + mut patients: Query<(&mut Health, &TilePos, Option<(&CurrentJob, &Path)>)>, | |
| 201 | +) { | |
| 202 | + if !tick.0.is_multiple_of(HEAL_INTERVAL_TICKS) { | |
| 203 | + return; | |
| 204 | + } | |
| 205 | + let beds: HashSet<UVec2> = structures | |
| 206 | + .iter() | |
| 207 | + .filter(|(_, s)| s.kind == StructureKind::Bed) | |
| 208 | + .map(|(pos, _)| pos.0) | |
| 209 | + .collect(); | |
| 210 | + for (mut health, tile, job) in &mut patients { | |
| 211 | + if health.0 >= 1.0 { | |
| 212 | + continue; | |
| 213 | + } | |
| 214 | + let in_bed = | |
| 215 | + job.is_some_and(|(job, path)| is_sleeping(job, tile, path) && beds.contains(&tile.0)); | |
| 216 | + health.0 = healed(health.0, HEAL_INTERVAL_TICKS as f32, in_bed); | |
| 217 | + } | |
| 218 | +} | |
| 219 | + | |
| 220 | +#[cfg(test)] | |
| 221 | +mod tests { | |
| 222 | + use super::*; | |
| 223 | + | |
| 224 | + // Wolves go for the nearest meal but would rather take an animal than a person, unless the | |
| 225 | + // person is much closer. | |
| 226 | + #[test] | |
| 227 | + fn wolves_prefer_animals_to_people() { | |
| 228 | + let e = |i: u32| Entity::from_raw_u32(i).unwrap(); | |
| 229 | + let deer = Prey { | |
| 230 | + entity: e(1), | |
| 231 | + tile: UVec2::new(10, 0), | |
| 232 | + colonist: false, | |
| 233 | + }; | |
| 234 | + let person = Prey { | |
| 235 | + entity: e(2), | |
| 236 | + tile: UVec2::new(8, 0), | |
| 237 | + colonist: true, | |
| 238 | + }; | |
| 239 | + assert_eq!(pick_prey(UVec2::ZERO, &[deer, person]), Some(deer)); | |
| 240 | + let close = Prey { | |
| 241 | + tile: UVec2::new(2, 0), | |
| 242 | + ..person | |
| 243 | + }; | |
| 244 | + assert_eq!(pick_prey(UVec2::ZERO, &[deer, close]), Some(close)); | |
| 245 | + let far = Prey { | |
| 246 | + tile: UVec2::new(PROWL_RANGE as u32 + 1, 0), | |
| 247 | + ..deer | |
| 248 | + }; | |
| 249 | + assert_eq!(pick_prey(UVec2::ZERO, &[far]), None); | |
| 250 | + } | |
| 251 | + | |
| 252 | + #[test] | |
| 253 | + fn wounds_heal_slowly_and_faster_in_bed() { | |
| 254 | + let day = 24.0 * TICKS_PER_HOUR as f32; | |
| 255 | + assert!((healed(0.5, day, false) - 0.8).abs() < 1e-4); | |
| 256 | + assert_eq!(healed(0.5, day, true), 1.0); | |
| 257 | + } | |
| 258 | + | |
| 259 | + // A hungry wolf stalks the nearest prey, bites it to death and then rests while it digests. | |
| 260 | + #[test] | |
| 261 | + fn wolves_hunt_down_prey_and_then_rest() { | |
| 262 | + use crate::{ | |
| 263 | + animals::{AnimalIdle, AnimalKind}, | |
| 264 | + colonist::{MoveSpeed, follow_path}, | |
| 265 | + sim::sim_pos, | |
| 266 | + world::Terrain, | |
| 267 | + }; | |
| 268 | + let mut app = App::new(); | |
| 269 | + app.insert_resource(WorldMap::filled(UVec2::splat(30), Terrain::Grass)) | |
| 270 | + .init_resource::<SimTick>() | |
| 271 | + .init_resource::<Time>() | |
| 272 | + .init_resource::<ItemAssets>() | |
| 273 | + .init_resource::<ColonyLog>() | |
| 274 | + .init_resource::<Losses>() | |
| 275 | + .init_resource::<Reservations>() | |
| 276 | + .add_systems(Update, (prowl, follow_path, bite).chain()); | |
| 277 | + let spawn = |app: &mut App, kind: AnimalKind, tile: UVec2| { | |
| 278 | + app.world_mut() | |
| 279 | + .spawn(( | |
| 280 | + Animal { kind }, | |
| 281 | + TilePos(tile), | |
| 282 | + sim_pos(WorldMap::tile_center(tile)), | |
| 283 | + MoveSpeed(3.0), | |
| 284 | + AnimalIdle::default(), | |
| 285 | + )) | |
| 286 | + .id() | |
| 287 | + }; | |
| 288 | + let wolf = spawn(&mut app, AnimalKind::Wolf, UVec2::new(2, 2)); | |
| 289 | + app.world_mut().entity_mut(wolf).insert(Predator::default()); | |
| 290 | + let rabbit = spawn(&mut app, AnimalKind::Rabbit, UVec2::new(12, 2)); | |
| 291 | + for t in 0..2000 { | |
| 292 | + app.world_mut().resource_mut::<SimTick>().0 = t; | |
| 293 | + app.world_mut() | |
| 294 | + .resource_mut::<Time>() | |
| 295 | + .advance_by(std::time::Duration::from_secs_f64(1.0 / 60.0)); | |
| 296 | + app.update(); | |
| 297 | + } | |
| 298 | + assert!(app.world().get_entity(rabbit).is_err()); | |
| 299 | + let predator = app.world().get::<Predator>(wolf).unwrap(); | |
| 300 | + assert!(predator.fed_until > 0 && predator.target.is_none()); | |
| 301 | + } | |
| 302 | +} | |
tasks.md +1 -0
| @@ -46,6 +46,7 @@ | ||
| 46 | 46 | - [x] Wood floors: colonists floor finished homes (mood bonus), Build tool floor mode; light only recomputes on wall/roof changes; search reach fix for far stacks |
| 47 | 47 | - [x] Connected wall rendering (wall tops join neighbouring walls and doors) |
| 48 | 48 | - [x] Skills: eight work skills set work speed, improve with practice, newcomers have a talent; poor-skill work is tried last |
| 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 | |
| 49 | 50 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 50 | 51 | |
| 51 | 52 | ## Planned |