Add taming and breeding of animals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

@@ -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