Add taming and breeding of animals
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 22:17 UTC
- commit
- d95ecc9559c0df110a606814e333af03b79d3bf4
- parent
- a62c0e8595
- tree
- browse at this commit
6 files changed +244 -32
| CLAUDE.md | +1 -0 | |
| src/animals.rs | +88 -7 | |
| src/jobs/mod.rs | +98 -20 | |
| src/jobs/tests.rs | +41 -0 | |
| src/selection.rs | +15 -4 | |
| tasks.md | +1 -1 |
CLAUDE.md +1 -0
| @@ -37,6 +37,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 37 | 37 | - `src/food.rs` spoilage: `spoil_food` (every 60 ticks) adds `spoilage(kind, roofed, ticks)` to food stacks and carried food (shelf life meat 2 d, berries 3, meals 4, vegetables 5; ×0.5 under a roof); spoil ≥ 1 removes it. `Hunger(f32)` on colonists (1 = fed): `update_hunger` drains 1/24 per game hour (half while sleeping). HUNGRY 0.35 → Eat job; STARVING 0.05 interrupts autonomous work like exhaustion, but only when unreserved food exists (`food_to_fetch`), else colonists thrashed their job every tick. `find_food` prefers Meal stacks, then raw food; `portion(kind, fed)` = units to fill up. Eat: walk to pickup, take portion into `Carrying`, eat EAT_TICKS, add nutrition. |
| 38 | 38 | - `src/plants.rs` `grow_plants` every GROWTH_INTERVAL_TICKS: plants with `ripening_ticks` (BerryBush 2 days, Crop 1.5) gain growth × `fertility(terrain)` (Soil 1, Grass .8, Sand .4, Gravel .3; wild bushes ≥ .8); writes TileChanged when `PlantLook` stage changes. |
| 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 | +- 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. | |
| 40 | 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. |
| 41 | 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. |
| 42 | 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. |
src/animals.rs +88 -7
| @@ -4,11 +4,13 @@ use bevy::prelude::*; | ||
| 4 | 4 | |
| 5 | 5 | use crate::{ |
| 6 | 6 | colonist::{Colonist, MoveSpeed, Path}, |
| 7 | + jobs::{CurrentJob, Task}, | |
| 7 | 8 | pathfinding::find_path, |
| 8 | 9 | rng::SimRng, |
| 9 | 10 | sim::{SimPos, SimSystems, SimTick, TICKS_PER_HOUR, sim_pos}, |
| 10 | 11 | world::{TILE_SIZE, TilePos, WorldMap}, |
| 11 | 12 | worldgen::{WorldGenerated, spawn_tiles}, |
| 13 | + zones::Zones, | |
| 12 | 14 | }; |
| 13 | 15 | |
| 14 | 16 | pub struct AnimalsPlugin; |
| @@ -21,13 +23,17 @@ impl Plugin for AnimalsPlugin { | ||
| 21 | 23 | FixedUpdate, |
| 22 | 24 | ( |
| 23 | 25 | wander.in_set(SimSystems::Decide), |
| 24 | - migrate.in_set(SimSystems::Act), | |
| 26 | + (migrate, breed).in_set(SimSystems::Act), | |
| 25 | 27 | ), |
| 26 | 28 | ); |
| 27 | 29 | } |
| 28 | 30 | } |
| 29 | 31 | |
| 30 | 32 | const WANDER_RADIUS: i32 = 10; |
| 33 | +pub const MAX_TAME: usize = 6; | |
| 34 | +pub const PEN_RADIUS: i32 = 8; | |
| 35 | +const BREED_CHANCE: f32 = 0.5; | |
| 36 | +const BREED_INTERVAL_TICKS: u64 = 24 * TICKS_PER_HOUR; | |
| 31 | 37 | pub const FLEE_RANGE: i32 = 4; |
| 32 | 38 | const FLEE_DISTANCE: f32 = 7.0; |
| 33 | 39 | const IDLE_SECS: Range<f32> = 2.0..8.0; |
| @@ -58,6 +64,13 @@ impl AnimalKind { | ||
| 58 | 64 | } |
| 59 | 65 | } |
| 60 | 66 | |
| 67 | + pub fn tame_work(self) -> u32 { | |
| 68 | + match self { | |
| 69 | + AnimalKind::Deer => 600, | |
| 70 | + AnimalKind::Rabbit => 300, | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 61 | 74 | pub fn hunt_work(self) -> u32 { |
| 62 | 75 | match self { |
| 63 | 76 | AnimalKind::Deer => 360, |
| @@ -86,6 +99,9 @@ pub struct Animal { | ||
| 86 | 99 | pub kind: AnimalKind, |
| 87 | 100 | } |
| 88 | 101 | |
| 102 | +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] | |
| 103 | +pub struct Tame; | |
| 104 | + | |
| 89 | 105 | #[derive(Component, Debug)] |
| 90 | 106 | pub struct AnimalIdle(Timer); |
| 91 | 107 | |
| @@ -141,7 +157,12 @@ fn part_bundle((mesh, material): &Part, transform: Transform) -> impl Bundle { | ||
| 141 | 157 | ) |
| 142 | 158 | } |
| 143 | 159 | |
| 144 | -pub fn spawn_animal(commands: &mut Commands, assets: &AnimalAssets, kind: AnimalKind, tile: UVec2) { | |
| 160 | +pub fn spawn_animal( | |
| 161 | + commands: &mut Commands, | |
| 162 | + assets: &AnimalAssets, | |
| 163 | + kind: AnimalKind, | |
| 164 | + tile: UVec2, | |
| 165 | +) -> Entity { | |
| 145 | 166 | let at = |x: f32, y: f32| Transform::from_xyz(x * TILE_SIZE, y * TILE_SIZE, 0.01); |
| 146 | 167 | let mut entity = commands.spawn(( |
| 147 | 168 | Name::new(kind.name()), |
| @@ -162,6 +183,7 @@ pub fn spawn_animal(commands: &mut Commands, assets: &AnimalAssets, kind: Animal | ||
| 162 | 183 | c.spawn(part_bundle(&assets.rabbit_ear, at(0.18, 0.2))); |
| 163 | 184 | } |
| 164 | 185 | }); |
| 186 | + entity.id() | |
| 165 | 187 | } |
| 166 | 188 | |
| 167 | 189 | pub fn wild_tile(map: &WorldMap, rng: &mut SimRng, away_from: UVec2) -> Option<UVec2> { |
| @@ -203,13 +225,38 @@ pub fn missing_animals(kind: AnimalKind, alive: usize) -> bool { | ||
| 203 | 225 | alive < kind.population() |
| 204 | 226 | } |
| 205 | 227 | |
| 228 | +fn breed( | |
| 229 | + mut commands: Commands, | |
| 230 | + tick: Res<SimTick>, | |
| 231 | + assets: Res<AnimalAssets>, | |
| 232 | + mut rng: ResMut<SimRng>, | |
| 233 | + tame: Query<(&Animal, &TilePos), With<Tame>>, | |
| 234 | +) { | |
| 235 | + if !tick.0.is_multiple_of(BREED_INTERVAL_TICKS) || tick.0 == 0 { | |
| 236 | + return; | |
| 237 | + } | |
| 238 | + let mut total = tame.iter().count(); | |
| 239 | + for kind in AnimalKind::ALL { | |
| 240 | + let herd: Vec<UVec2> = tame | |
| 241 | + .iter() | |
| 242 | + .filter(|(a, _)| a.kind == kind) | |
| 243 | + .map(|(_, pos)| pos.0) | |
| 244 | + .collect(); | |
| 245 | + if herd.len() >= 2 && total < MAX_TAME && rng.range_f32(0.0..1.0) < BREED_CHANCE { | |
| 246 | + let young = spawn_animal(&mut commands, &assets, kind, herd[0]); | |
| 247 | + commands.entity(young).insert(Tame); | |
| 248 | + total += 1; | |
| 249 | + } | |
| 250 | + } | |
| 251 | +} | |
| 252 | + | |
| 206 | 253 | fn migrate( |
| 207 | 254 | mut commands: Commands, |
| 208 | 255 | tick: Res<SimTick>, |
| 209 | 256 | map: Res<WorldMap>, |
| 210 | 257 | assets: Res<AnimalAssets>, |
| 211 | 258 | mut rng: ResMut<SimRng>, |
| 212 | - animals: Query<&Animal>, | |
| 259 | + animals: Query<&Animal, Without<Tame>>, | |
| 213 | 260 | ) { |
| 214 | 261 | if !tick.0.is_multiple_of(MIGRATION_INTERVAL_TICKS) || tick.0 == 0 { |
| 215 | 262 | return; |
| @@ -245,15 +292,48 @@ pub fn flee_goal(tile: UVec2, threats: &[UVec2]) -> Option<IVec2> { | ||
| 245 | 292 | Some(tile.as_ivec2() + (away * FLEE_DISTANCE).round().as_ivec2()) |
| 246 | 293 | } |
| 247 | 294 | |
| 295 | +#[allow(clippy::type_complexity)] | |
| 248 | 296 | pub fn wander( |
| 249 | 297 | time: Res<Time>, |
| 250 | 298 | map: Res<WorldMap>, |
| 299 | + zones: Res<Zones>, | |
| 251 | 300 | mut rng: ResMut<SimRng>, |
| 252 | - colonists: Query<&TilePos, (With<Colonist>, Without<Animal>)>, | |
| 253 | - mut animals: Query<(&TilePos, &mut Path, &mut AnimalIdle), With<Animal>>, | |
| 301 | + colonists: Query<(&TilePos, &CurrentJob), (With<Colonist>, Without<Animal>)>, | |
| 302 | + mut animals: Query<(Entity, &TilePos, &mut Path, &mut AnimalIdle, Has<Tame>), With<Animal>>, | |
| 254 | 303 | ) { |
| 255 | - let threats: Vec<UVec2> = colonists.iter().map(|t| t.0).collect(); | |
| 256 | - for (tile, mut path, mut idle) in &mut animals { | |
| 304 | + let threats: Vec<UVec2> = colonists.iter().map(|(t, _)| t.0).collect(); | |
| 305 | + let being_tamed: Vec<Entity> = colonists | |
| 306 | + .iter() | |
| 307 | + .filter_map(|(_, job)| match job.0?.task { | |
| 308 | + Task::Tame { animal, .. } => Some(animal), | |
| 309 | + _ => None, | |
| 310 | + }) | |
| 311 | + .collect(); | |
| 312 | + let home = zones.home(map.size / 2).as_ivec2(); | |
| 313 | + for (entity, tile, mut path, mut idle, tame) in &mut animals { | |
| 314 | + if being_tamed.contains(&entity) { | |
| 315 | + path.0.truncate(1); | |
| 316 | + continue; | |
| 317 | + } | |
| 318 | + if tame { | |
| 319 | + if !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() { | |
| 320 | + continue; | |
| 321 | + } | |
| 322 | + let goal = home | |
| 323 | + + IVec2::new( | |
| 324 | + rng.range_i32(-PEN_RADIUS..PEN_RADIUS + 1), | |
| 325 | + rng.range_i32(-PEN_RADIUS..PEN_RADIUS + 1), | |
| 326 | + ); | |
| 327 | + if map.in_bounds(goal) | |
| 328 | + && let Some(route) = find_path(&map, tile.0, goal.as_uvec2()) | |
| 329 | + { | |
| 330 | + path.0 = route.into(); | |
| 331 | + idle.0 | |
| 332 | + .set_duration(Duration::from_secs_f32(rng.range_f32(IDLE_SECS))); | |
| 333 | + idle.0.reset(); | |
| 334 | + } | |
| 335 | + continue; | |
| 336 | + } | |
| 257 | 337 | let heading_into_danger = path |
| 258 | 338 | .0 |
| 259 | 339 | .back() |
| @@ -329,6 +409,7 @@ mod tests { | ||
| 329 | 409 | app.insert_resource(WorldMap::filled(UVec2::splat(30), Terrain::Grass)) |
| 330 | 410 | .insert_resource(SimRng::new(1)) |
| 331 | 411 | .init_resource::<Time>() |
| 412 | + .init_resource::<Zones>() | |
| 332 | 413 | .add_systems(Update, wander); |
| 333 | 414 | let spawn = |app: &mut App, tile: UVec2| { |
| 334 | 415 | app.world_mut() |
src/jobs/mod.rs +98 -20
| @@ -7,7 +7,7 @@ use std::{ | ||
| 7 | 7 | use bevy::prelude::*; |
| 8 | 8 | |
| 9 | 9 | use crate::{ |
| 10 | - animals::{Animal, AnimalKind}, | |
| 10 | + animals::{Animal, AnimalKind, MAX_TAME, Tame}, | |
| 11 | 11 | building::{Blueprints, BuildKind}, |
| 12 | 12 | colonist::{Colonist, Path, repath}, |
| 13 | 13 | designations::Designations, |
| @@ -126,6 +126,11 @@ pub enum Task { | ||
| 126 | 126 | site: UVec2, |
| 127 | 127 | progress: u32, |
| 128 | 128 | }, |
| 129 | + Tame { | |
| 130 | + animal: Entity, | |
| 131 | + kind: AnimalKind, | |
| 132 | + progress: u32, | |
| 133 | + }, | |
| 129 | 134 | } |
| 130 | 135 | |
| 131 | 136 | pub fn work_rate(light: &LightMap, tile: UVec2) -> u32 { |
| @@ -351,7 +356,9 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) { | ||
| 351 | 356 | reservations.reserve(Target::Pickup(pickup), by); |
| 352 | 357 | } |
| 353 | 358 | } |
| 354 | - Task::Hunt { animal, .. } => reservations.reserve(Target::Animal(animal), by), | |
| 359 | + Task::Hunt { animal, .. } | Task::Tame { animal, .. } => { | |
| 360 | + reservations.reserve(Target::Animal(animal), by) | |
| 361 | + } | |
| 355 | 362 | Task::Butcher { site, .. } => reservations.reserve(Target::Pickup(site), by), |
| 356 | 363 | Task::Cook { |
| 357 | 364 | pickup, station, .. |
| @@ -464,31 +471,66 @@ pub fn find_sowing( | ||
| 464 | 471 | pub const HUNT_RADIUS: f32 = 60.0; |
| 465 | 472 | pub const HUNT_RANGE: f32 = 6.0; |
| 466 | 473 | |
| 467 | -pub fn find_hunt( | |
| 474 | +pub type Wildlife = (Entity, UVec2, AnimalKind, bool); | |
| 475 | + | |
| 476 | +fn nearest_reachable( | |
| 468 | 477 | map: &WorldMap, |
| 469 | 478 | reservations: &Reservations, |
| 470 | - animals: &[(Entity, UVec2, AnimalKind)], | |
| 479 | + animals: &[Wildlife], | |
| 471 | 480 | from: UVec2, |
| 472 | -) -> Option<Task> { | |
| 473 | - let mut prey: Vec<_> = animals | |
| 481 | + pick: impl Fn(&Wildlife) -> bool, | |
| 482 | +) -> Option<Wildlife> { | |
| 483 | + let mut candidates: Vec<&Wildlife> = animals | |
| 474 | 484 | .iter() |
| 475 | - .filter(|(e, pos, _)| { | |
| 476 | - reservations.is_free(Target::Animal(*e)) | |
| 477 | - && pos.as_vec2().distance(from.as_vec2()) <= HUNT_RADIUS | |
| 485 | + .filter(|a| { | |
| 486 | + pick(a) | |
| 487 | + && reservations.is_free(Target::Animal(a.0)) | |
| 488 | + && a.1.as_vec2().distance(from.as_vec2()) <= HUNT_RADIUS | |
| 478 | 489 | }) |
| 479 | 490 | .collect(); |
| 480 | - prey.sort_by(|a, b| { | |
| 491 | + candidates.sort_by(|a, b| { | |
| 481 | 492 | a.1.as_vec2() |
| 482 | 493 | .distance_squared(from.as_vec2()) |
| 483 | 494 | .total_cmp(&b.1.as_vec2().distance_squared(from.as_vec2())) |
| 484 | 495 | }); |
| 485 | - prey.into_iter() | |
| 486 | - .find(|(_, pos, _)| find_path_adjacent(map, from, *pos).is_some()) | |
| 487 | - .map(|&(animal, _, kind)| Task::Hunt { | |
| 488 | - animal, | |
| 489 | - kind, | |
| 490 | - progress: 0, | |
| 491 | - }) | |
| 496 | + candidates | |
| 497 | + .into_iter() | |
| 498 | + .find(|a| find_path_adjacent(map, from, a.1).is_some()) | |
| 499 | + .copied() | |
| 500 | +} | |
| 501 | + | |
| 502 | +pub fn find_hunt( | |
| 503 | + map: &WorldMap, | |
| 504 | + reservations: &Reservations, | |
| 505 | + animals: &[Wildlife], | |
| 506 | + from: UVec2, | |
| 507 | +) -> Option<Task> { | |
| 508 | + let herd = |kind: AnimalKind| animals.iter().filter(|a| a.3 && a.2 == kind).count(); | |
| 509 | + let spare_tame = |a: &Wildlife| a.3 && herd(a.2) > 2; | |
| 510 | + let (animal, _, kind, _) = nearest_reachable(map, reservations, animals, from, spare_tame) | |
| 511 | + .or_else(|| nearest_reachable(map, reservations, animals, from, |a| !a.3))?; | |
| 512 | + Some(Task::Hunt { | |
| 513 | + animal, | |
| 514 | + kind, | |
| 515 | + progress: 0, | |
| 516 | + }) | |
| 517 | +} | |
| 518 | + | |
| 519 | +pub fn find_taming( | |
| 520 | + map: &WorldMap, | |
| 521 | + reservations: &Reservations, | |
| 522 | + animals: &[Wildlife], | |
| 523 | + from: UVec2, | |
| 524 | +) -> Option<Task> { | |
| 525 | + if animals.iter().filter(|a| a.3).count() >= MAX_TAME { | |
| 526 | + return None; | |
| 527 | + } | |
| 528 | + let (animal, _, kind, _) = nearest_reachable(map, reservations, animals, from, |a| !a.3)?; | |
| 529 | + Some(Task::Tame { | |
| 530 | + animal, | |
| 531 | + kind, | |
| 532 | + progress: 0, | |
| 533 | + }) | |
| 492 | 534 | } |
| 493 | 535 | |
| 494 | 536 | pub fn stockpile_too_full(zones: &Zones, items: &[(Item, TilePos)]) -> bool { |
| @@ -824,7 +866,7 @@ fn choose_jobs( | ||
| 824 | 866 | mut reservations: ResMut<Reservations>, |
| 825 | 867 | mut items: ItemQuery, |
| 826 | 868 | structures: Query<(&TilePos, &Structure, Option<&Fuel>), Without<Colonist>>, |
| 827 | - animals: Query<(Entity, &TilePos, &Animal), Without<Colonist>>, | |
| 869 | + animals: Query<(Entity, &TilePos, &Animal, Has<Tame>), Without<Colonist>>, | |
| 828 | 870 | mut colonists: Query< |
| 829 | 871 | ( |
| 830 | 872 | Entity, |
| @@ -843,9 +885,9 @@ fn choose_jobs( | ||
| 843 | 885 | plans: (Res<Homes>, Res<Designations>), |
| 844 | 886 | ) { |
| 845 | 887 | let (homes, designations) = plans; |
| 846 | - let wildlife: Vec<(Entity, UVec2, AnimalKind)> = animals | |
| 888 | + let wildlife: Vec<Wildlife> = animals | |
| 847 | 889 | .iter() |
| 848 | - .map(|(e, pos, a)| (e, pos.0, a.kind)) | |
| 890 | + .map(|(e, pos, a, tame)| (e, pos.0, a.kind, tame)) | |
| 849 | 891 | .collect(); |
| 850 | 892 | let adults = colonists.iter().filter(|c| !c.9).count(); |
| 851 | 893 | let structures_list: Vec<(UVec2, StructureKind, u32)> = structures |
| @@ -1000,6 +1042,11 @@ fn choose_jobs( | ||
| 1000 | 1042 | .flatten() |
| 1001 | 1043 | }) |
| 1002 | 1044 | .or_else(|| find_sowing(&map, &zones, &reservations, tile.0)) |
| 1045 | + .or_else(|| { | |
| 1046 | + (!wanted(ItemKind::Meat)) | |
| 1047 | + .then(|| find_taming(&map, &reservations, &wildlife, tile.0)) | |
| 1048 | + .flatten() | |
| 1049 | + }) | |
| 1003 | 1050 | .or_else(|| find_haul(&map, &zones, &reservations, &snapshot, tile.0)) |
| 1004 | 1051 | }; |
| 1005 | 1052 | if let Some(task) = task { |
| @@ -1222,6 +1269,37 @@ fn run_jobs( | ||
| 1222 | 1269 | } |
| 1223 | 1270 | _ => Step::Done, |
| 1224 | 1271 | }, |
| 1272 | + Task::Tame { | |
| 1273 | + animal, | |
| 1274 | + kind, | |
| 1275 | + progress, | |
| 1276 | + } => match animals.get(*animal) { | |
| 1277 | + Ok((prey, _)) if reservations.holder(Target::Animal(*animal)) == Some(entity) => { | |
| 1278 | + let prey = prey.0; | |
| 1279 | + if prey != tile.0 && !is_adjacent(tile.0.as_ivec2(), prey.as_ivec2()) { | |
| 1280 | + if path.0.is_empty() { | |
| 1281 | + match find_path_adjacent(&map, tile.0, prey) { | |
| 1282 | + Some(p) if !p.is_empty() => { | |
| 1283 | + path.0 = p.into(); | |
| 1284 | + Step::Continue | |
| 1285 | + } | |
| 1286 | + _ => Step::Done, | |
| 1287 | + } | |
| 1288 | + } else { | |
| 1289 | + Step::Continue | |
| 1290 | + } | |
| 1291 | + } else { | |
| 1292 | + *progress += work_rate(&light, tile.0); | |
| 1293 | + if *progress < kind.tame_work() { | |
| 1294 | + Step::Continue | |
| 1295 | + } else { | |
| 1296 | + commands.entity(*animal).insert(Tame); | |
| 1297 | + Step::Done | |
| 1298 | + } | |
| 1299 | + } | |
| 1300 | + } | |
| 1301 | + _ => Step::Done, | |
| 1302 | + }, | |
| 1225 | 1303 | Task::Butcher { site, progress } => { |
| 1226 | 1304 | let site = *site; |
| 1227 | 1305 | if reservations.holder(Target::Pickup(site)) != Some(entity) { |
src/jobs/tests.rs +41 -0
| @@ -1265,3 +1265,44 @@ fn hunters_shoot_from_a_distance() { | ||
| 1265 | 1265 | "{shots_from:?}" |
| 1266 | 1266 | ); |
| 1267 | 1267 | } |
| 1268 | + | |
| 1269 | +#[test] | |
| 1270 | +fn colonists_tame_wild_animals_when_food_is_plentiful() { | |
| 1271 | + let mut app = test_app(map_with_trees(&[])); | |
| 1272 | + app.add_systems(Update, crate::animals::wander.before(choose_jobs)); | |
| 1273 | + set_goals(&mut app, &[]); | |
| 1274 | + put_beds(&mut app, 1); | |
| 1275 | + let rabbit = put_animal(&mut app, AnimalKind::Rabbit, UVec2::new(12, 12)); | |
| 1276 | + spawn_colonist(&mut app, UVec2::new(2, 2)); | |
| 1277 | + run_ticks(&mut app, 60 * 6 + AnimalKind::Rabbit.tame_work()); | |
| 1278 | + assert!(app.world().get::<crate::animals::Tame>(rabbit).is_some()); | |
| 1279 | +} | |
| 1280 | + | |
| 1281 | +// Tame herds are a renewable meat supply, so hunters take spare tame animals (beyond a breeding | |
| 1282 | +// pair) before chasing wild ones. | |
| 1283 | +#[test] | |
| 1284 | +fn hunters_slaughter_spare_tame_animals_first() { | |
| 1285 | + let wild = ( | |
| 1286 | + Entity::from_raw_u32(1).unwrap(), | |
| 1287 | + UVec2::new(5, 5), | |
| 1288 | + AnimalKind::Deer, | |
| 1289 | + false, | |
| 1290 | + ); | |
| 1291 | + let tame = |i: u32, x: u32| { | |
| 1292 | + ( | |
| 1293 | + Entity::from_raw_u32(10 + i).unwrap(), | |
| 1294 | + UVec2::new(x, 15), | |
| 1295 | + AnimalKind::Deer, | |
| 1296 | + true, | |
| 1297 | + ) | |
| 1298 | + }; | |
| 1299 | + let map = map_with_trees(&[]); | |
| 1300 | + let reservations = Reservations::default(); | |
| 1301 | + let pick = |animals: &[Wildlife]| match find_hunt(&map, &reservations, animals, UVec2::ONE) { | |
| 1302 | + Some(Task::Hunt { animal, .. }) => animal, | |
| 1303 | + _ => panic!("no hunt"), | |
| 1304 | + }; | |
| 1305 | + assert_eq!(pick(&[wild, tame(0, 10), tame(1, 11)]), wild.0); | |
| 1306 | + let herd = [wild, tame(0, 10), tame(1, 11), tame(2, 12)]; | |
| 1307 | + assert_eq!(pick(&herd), tame(0, 10).0); | |
| 1308 | +} | |
src/selection.rs +15 -4
| @@ -2,7 +2,7 @@ use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow}; | ||
| 2 | 2 | |
| 3 | 3 | use crate::{ |
| 4 | 4 | actions::Action, |
| 5 | - animals::Animal, | |
| 5 | + animals::{Animal, Tame}, | |
| 6 | 6 | building::{Blueprints, BuildMode, BuildTool}, |
| 7 | 7 | camera::CameraController, |
| 8 | 8 | colonist::{Colonist, Path}, |
| @@ -258,7 +258,7 @@ fn update_tile_info( | ||
| 258 | 258 | )>, |
| 259 | 259 | items: Query<(&Item, &TilePos)>, |
| 260 | 260 | structures: Query<(&Structure, &TilePos, Option<&Fuel>)>, |
| 261 | - animals: Query<(&Animal, &TilePos)>, | |
| 261 | + animals: Query<(&Animal, &TilePos, Has<Tame>)>, | |
| 262 | 262 | blueprints: Res<Blueprints>, |
| 263 | 263 | goals: Res<ColonyGoals>, |
| 264 | 264 | log: Res<ColonyLog>, |
| @@ -357,8 +357,14 @@ fn update_tile_info( | ||
| 357 | 357 | .chain( |
| 358 | 358 | animals |
| 359 | 359 | .iter() |
| 360 | - .filter(|(_, pos)| Some(pos.0) == cursor.tile) | |
| 361 | - .map(|(animal, _)| format!(", {}", animal.kind.name())), | |
| 360 | + .filter(|(_, pos, _)| Some(pos.0) == cursor.tile) | |
| 361 | + .map(|(animal, _, tame)| { | |
| 362 | + format!( | |
| 363 | + ", {}{}", | |
| 364 | + if tame { "tame " } else { "" }, | |
| 365 | + animal.kind.name() | |
| 366 | + ) | |
| 367 | + }), | |
| 362 | 368 | ) |
| 363 | 369 | .collect(); |
| 364 | 370 | let events: String = log.0.iter().map(|line| format!("\n{line}")).collect(); |
| @@ -467,6 +473,7 @@ pub fn progress_fraction(task: Task) -> Option<f32> { | ||
| 467 | 473 | Task::Hunt { kind, progress, .. } => Some(progress as f32 / kind.hunt_work() as f32), |
| 468 | 474 | Task::Cook { progress, .. } => Some(progress as f32 / COOK_WORK as f32), |
| 469 | 475 | Task::Butcher { progress, .. } => Some(progress as f32 / BUTCHER_WORK as f32), |
| 476 | + Task::Tame { kind, progress, .. } => Some(progress as f32 / kind.tame_work() as f32), | |
| 470 | 477 | _ => None, |
| 471 | 478 | } |
| 472 | 479 | } |
| @@ -567,6 +574,10 @@ pub fn describe_colonist(view: &ColonistView) -> String { | ||
| 567 | 574 | (Some(Task::Cook { .. }), n) if n > 0 => "carrying ingredients to the fire".to_string(), |
| 568 | 575 | (Some(task @ Task::Cook { .. }), _) => format!("cooking a meal, {}%", percent(task)), |
| 569 | 576 | (Some(Task::Butcher { .. }), n) if n > 0 => "walking to a carcass".to_string(), |
| 577 | + (Some(Task::Tame { kind, .. }), n) if n > 0 => format!("approaching a {}", kind.name()), | |
| 578 | + (Some(task @ Task::Tame { kind, .. }), _) => { | |
| 579 | + format!("taming a {}, {}%", kind.name(), percent(task)) | |
| 580 | + } | |
| 570 | 581 | (Some(task @ Task::Butcher { .. }), _) => format!("butchering, {}%", percent(task)), |
| 571 | 582 | (Some(task @ Task::Build { kind, .. }), _) => { |
| 572 | 583 | format!("building a {}, {}%", kind.name(), percent(task)) |
tasks.md +1 -1
| @@ -30,11 +30,11 @@ | ||
| 30 | 30 | - [x] Refuelling fires; manual build tool (B: wall room outlines, doors, campfires, torches, beds); zoom buttons |
| 31 | 31 | - [x] Designations: Chop/Mine tool (X) marks trees and rock for anyone to work |
| 32 | 32 | - [x] Walls cast shadows, roofs keep daylight out; deconstruction (build tool mode, refunds half) |
| 33 | +- [x] Animals flee, ranged hunting, carcasses butchered into meat, taming with breeding herds and slaughter of spare tame animals | |
| 33 | 34 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 34 | 35 | |
| 35 | 36 | ## Planned |
| 36 | 37 | - [ ] Mood, food poisoning from raw food; colonists leaving when unhappy; old age/disease |
| 37 | -- [ ] Animals flee from hunters; tameable animals; butchering as a separate step | |
| 38 | 38 | - [ ] Per-colonist work priorities (busy colonies leave low-priority work such as hauling undone) |
| 39 | 39 | - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority |
| 40 | 40 | - [ ] UI to view/override learned stock targets |