Add wild animals and hunting

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 11:09 UTC
commit
28a0cff806df735db8a695b99556ff5258fc6489
parent
3db77ac6b4
tree
browse at this commit

6 files changed +499 -5

src/animals.rs +325 -0

@@ -0,0 +1,325 @@
1 +use std::{ops::Range, time::Duration};
2 +
3 +use bevy::prelude::*;
4 +
5 +use crate::{
6 + colonist::{MoveSpeed, Path},
7 + jobs::{Reservations, Target},
8 + pathfinding::find_path,
9 + rng::SimRng,
10 + sim::{SimPos, SimSystems, SimTick, TICKS_PER_HOUR, sim_pos},
11 + world::{TILE_SIZE, TilePos, WorldMap},
12 + worldgen::{WorldGenerated, spawn_tiles},
13 +};
14 +
15 +pub struct AnimalsPlugin;
16 +
17 +impl Plugin for AnimalsPlugin {
18 + fn build(&self, app: &mut App) {
19 + app.add_systems(Startup, create_animal_assets)
20 + .add_systems(Update, populate.run_if(on_message::<WorldGenerated>))
21 + .add_systems(
22 + FixedUpdate,
23 + (
24 + wander.in_set(SimSystems::Decide),
25 + migrate.in_set(SimSystems::Act),
26 + ),
27 + );
28 + }
29 +}
30 +
31 +const WANDER_RADIUS: i32 = 10;
32 +const IDLE_SECS: Range<f32> = 2.0..8.0;
33 +const MIN_DISTANCE_FROM_COLONY: f32 = 25.0;
34 +const MIGRATION_INTERVAL_TICKS: u64 = 2 * TICKS_PER_HOUR;
35 +const PLACEMENT_ATTEMPTS: usize = 200;
36 +
37 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38 +pub enum AnimalKind {
39 + Deer,
40 + Rabbit,
41 +}
42 +
43 +impl AnimalKind {
44 + pub const ALL: [AnimalKind; 2] = [AnimalKind::Deer, AnimalKind::Rabbit];
45 +
46 + pub fn meat(self) -> u32 {
47 + match self {
48 + AnimalKind::Deer => 30,
49 + AnimalKind::Rabbit => 8,
50 + }
51 + }
52 +
53 + pub fn population(self) -> usize {
54 + match self {
55 + AnimalKind::Deer => 10,
56 + AnimalKind::Rabbit => 16,
57 + }
58 + }
59 +
60 + pub fn hunt_work(self) -> u32 {
61 + match self {
62 + AnimalKind::Deer => 360,
63 + AnimalKind::Rabbit => 160,
64 + }
65 + }
66 +
67 + fn speed(self) -> f32 {
68 + match self {
69 + AnimalKind::Deer => 3.0,
70 + AnimalKind::Rabbit => 2.5,
71 + }
72 + }
73 +
74 + pub fn name(self) -> &'static str {
75 + match self {
76 + AnimalKind::Deer => "deer",
77 + AnimalKind::Rabbit => "rabbit",
78 + }
79 + }
80 +}
81 +
82 +#[derive(Component, Debug, Clone, Copy, PartialEq)]
83 +#[require(Transform, Visibility, TilePos, Path, SimPos, AnimalIdle)]
84 +pub struct Animal {
85 + pub kind: AnimalKind,
86 +}
87 +
88 +#[derive(Component, Debug)]
89 +pub struct AnimalIdle(Timer);
90 +
91 +impl Default for AnimalIdle {
92 + fn default() -> Self {
93 + Self(Timer::from_seconds(IDLE_SECS.start, TimerMode::Once))
94 + }
95 +}
96 +
97 +type Part = (Handle<Mesh>, Handle<ColorMaterial>);
98 +
99 +#[derive(Resource, Default)]
100 +pub struct AnimalAssets {
101 + deer_body: Part,
102 + deer_head: Part,
103 + rabbit_body: Part,
104 + rabbit_ear: Part,
105 +}
106 +
107 +fn create_animal_assets(
108 + mut commands: Commands,
109 + mut meshes: ResMut<Assets<Mesh>>,
110 + mut materials: ResMut<Assets<ColorMaterial>>,
111 +) {
112 + let mut part = |mesh: Mesh, color: Color| (meshes.add(mesh), materials.add(color));
113 + let deer = Color::srgb_u8(150, 100, 60);
114 + let rabbit = Color::srgb_u8(185, 175, 160);
115 + commands.insert_resource(AnimalAssets {
116 + deer_body: part(
117 + Ellipse::new(TILE_SIZE * 0.42, TILE_SIZE * 0.22).into(),
118 + deer,
119 + ),
120 + deer_head: part(
121 + Rectangle::new(TILE_SIZE * 0.2, TILE_SIZE * 0.3).into(),
122 + Color::srgb_u8(120, 80, 48),
123 + ),
124 + rabbit_body: part(
125 + Ellipse::new(TILE_SIZE * 0.22, TILE_SIZE * 0.15).into(),
126 + rabbit,
127 + ),
128 + rabbit_ear: part(
129 + Rectangle::new(TILE_SIZE * 0.06, TILE_SIZE * 0.2).into(),
130 + rabbit,
131 + ),
132 + });
133 +}
134 +
135 +fn part_bundle((mesh, material): &Part, transform: Transform) -> impl Bundle {
136 + (
137 + Mesh2d(mesh.clone()),
138 + MeshMaterial2d(material.clone()),
139 + transform,
140 + )
141 +}
142 +
143 +pub fn spawn_animal(commands: &mut Commands, assets: &AnimalAssets, kind: AnimalKind, tile: UVec2) {
144 + let at = |x: f32, y: f32| Transform::from_xyz(x * TILE_SIZE, y * TILE_SIZE, 0.01);
145 + let mut entity = commands.spawn((
146 + Name::new(kind.name()),
147 + Animal { kind },
148 + MoveSpeed(kind.speed()),
149 + TilePos(tile),
150 + sim_pos(WorldMap::tile_center(tile)),
151 + Transform::from_translation(WorldMap::tile_center(tile).extend(0.9)),
152 + ));
153 + entity.with_children(|c| match kind {
154 + AnimalKind::Deer => {
155 + c.spawn(part_bundle(&assets.deer_body, at(0.0, 0.0)));
156 + c.spawn(part_bundle(&assets.deer_head, at(0.38, 0.15)));
157 + }
158 + AnimalKind::Rabbit => {
159 + c.spawn(part_bundle(&assets.rabbit_body, at(0.0, 0.0)));
160 + c.spawn(part_bundle(&assets.rabbit_ear, at(0.1, 0.2)));
161 + c.spawn(part_bundle(&assets.rabbit_ear, at(0.18, 0.2)));
162 + }
163 + });
164 +}
165 +
166 +pub fn wild_tile(map: &WorldMap, rng: &mut SimRng, away_from: UVec2) -> Option<UVec2> {
167 + (0..PLACEMENT_ATTEMPTS).find_map(|_| {
168 + let tile = IVec2::new(
169 + rng.range_i32(0..map.size.x as i32),
170 + rng.range_i32(0..map.size.y as i32),
171 + );
172 + let far = tile.as_vec2().distance(away_from.as_vec2()) >= MIN_DISTANCE_FROM_COLONY;
173 + let open = map.move_cost(tile).is_some() && map.plant(tile).is_none();
174 + (far && open && !map.get(tile).is_some_and(|t| t.is_water())).then(|| tile.as_uvec2())
175 + })
176 +}
177 +
178 +fn populate(
179 + mut commands: Commands,
180 + map: Res<WorldMap>,
181 + assets: Res<AnimalAssets>,
182 + mut rng: ResMut<SimRng>,
183 + existing: Query<Entity, With<Animal>>,
184 +) {
185 + for entity in &existing {
186 + commands.entity(entity).despawn();
187 + }
188 + let colony = spawn_tiles(&map, 1)
189 + .first()
190 + .copied()
191 + .unwrap_or(map.size / 2);
192 + for kind in AnimalKind::ALL {
193 + for _ in 0..kind.population() {
194 + if let Some(tile) = wild_tile(&map, &mut rng, colony) {
195 + spawn_animal(&mut commands, &assets, kind, tile);
196 + }
197 + }
198 + }
199 +}
200 +
201 +pub fn missing_animals(kind: AnimalKind, alive: usize) -> bool {
202 + alive < kind.population()
203 +}
204 +
205 +fn migrate(
206 + mut commands: Commands,
207 + tick: Res<SimTick>,
208 + map: Res<WorldMap>,
209 + assets: Res<AnimalAssets>,
210 + mut rng: ResMut<SimRng>,
211 + animals: Query<&Animal>,
212 +) {
213 + if !tick.0.is_multiple_of(MIGRATION_INTERVAL_TICKS) || tick.0 == 0 {
214 + return;
215 + }
216 + let colony = spawn_tiles(&map, 1)
217 + .first()
218 + .copied()
219 + .unwrap_or(map.size / 2);
220 + for kind in AnimalKind::ALL {
221 + let alive = animals.iter().filter(|a| a.kind == kind).count();
222 + if missing_animals(kind, alive)
223 + && let Some(tile) = wild_tile(&map, &mut rng, colony)
224 + {
225 + spawn_animal(&mut commands, &assets, kind, tile);
226 + }
227 + }
228 +}
229 +
230 +pub fn wander(
231 + time: Res<Time>,
232 + map: Res<WorldMap>,
233 + reservations: Res<Reservations>,
234 + mut rng: ResMut<SimRng>,
235 + mut animals: Query<(Entity, &TilePos, &mut Path, &mut AnimalIdle), With<Animal>>,
236 +) {
237 + for (entity, tile, mut path, mut idle) in &mut animals {
238 + if reservations.holder(Target::Animal(entity)).is_some() {
239 + path.0.truncate(1);
240 + continue;
241 + }
242 + if !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() {
243 + continue;
244 + }
245 + let goal = tile.0.as_ivec2()
246 + + IVec2::new(
247 + rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
248 + rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
249 + );
250 + if map.in_bounds(goal)
251 + && let Some(route) = find_path(&map, tile.0, goal.as_uvec2())
252 + {
253 + path.0 = route.into();
254 + let secs = rng.range_f32(IDLE_SECS);
255 + idle.0.set_duration(Duration::from_secs_f32(secs));
256 + idle.0.reset();
257 + }
258 + }
259 +}
260 +
261 +#[cfg(test)]
262 +mod tests {
263 + use super::*;
264 + use crate::world::Terrain;
265 +
266 + #[test]
267 + fn wild_animals_appear_away_from_the_colony_on_open_ground() {
268 + let mut map = WorldMap::filled(UVec2::splat(80), Terrain::Grass);
269 + map.set_plant(UVec2::new(70, 70), Some(crate::world::Plant::Tree));
270 + let mut rng = SimRng::new(3);
271 + for _ in 0..50 {
272 + let tile = wild_tile(&map, &mut rng, UVec2::new(40, 40)).unwrap();
273 + assert!(tile.as_vec2().distance(Vec2::splat(40.0)) >= MIN_DISTANCE_FROM_COLONY);
274 + assert!(map.plant(tile.as_ivec2()).is_none());
275 + }
276 + }
277 +
278 + #[test]
279 + fn herds_regrow_up_to_their_population() {
280 + assert!(missing_animals(AnimalKind::Deer, 3));
281 + assert!(!missing_animals(
282 + AnimalKind::Deer,
283 + AnimalKind::Deer.population()
284 + ));
285 + }
286 +
287 + #[test]
288 + fn idle_animals_wander_and_hunted_ones_stand_still() {
289 + let mut app = App::new();
290 + app.insert_resource(WorldMap::filled(UVec2::splat(30), Terrain::Grass))
291 + .insert_resource(SimRng::new(1))
292 + .init_resource::<Time>()
293 + .init_resource::<Reservations>()
294 + .add_systems(Update, wander);
295 + let free = app
296 + .world_mut()
297 + .spawn((
298 + Animal {
299 + kind: AnimalKind::Deer,
300 + },
301 + TilePos(UVec2::splat(15)),
302 + ))
303 + .id();
304 + let hunted = app
305 + .world_mut()
306 + .spawn((
307 + Animal {
308 + kind: AnimalKind::Rabbit,
309 + },
310 + TilePos(UVec2::splat(10)),
311 + ))
312 + .id();
313 + app.world_mut()
314 + .resource_mut::<Reservations>()
315 + .reserve(Target::Animal(hunted), Entity::PLACEHOLDER);
316 + for _ in 0..10 {
317 + app.world_mut()
318 + .resource_mut::<Time>()
319 + .advance_by(Duration::from_secs(1));
320 + app.update();
321 + }
322 + assert!(!app.world().get::<Path>(free).unwrap().0.is_empty());
323 + assert!(app.world().get::<Path>(hunted).unwrap().0.is_empty());
324 + }
325 +}

src/colonist.rs +7 -3

@@ -84,12 +84,15 @@ pub fn repath(
84 84 }
85 85 }
86 86
87 +#[derive(Component, Debug, Clone, Copy, PartialEq)]
88 +pub struct MoveSpeed(pub f32);
89 +
87 90 pub fn follow_path(
88 91 time: Res<Time>,
89 92 map: Res<WorldMap>,
90 - mut colonists: Query<(&mut TilePos, &mut Path, &mut SimPos)>,
93 + mut movers: Query<(&mut TilePos, &mut Path, &mut SimPos, Option<&MoveSpeed>)>,
91 94 ) {
92 - for (mut tile, mut path, mut pos) in &mut colonists {
95 + for (mut tile, mut path, mut pos, speed) in &mut movers {
93 96 let Some(&next) = path.0.front() else {
94 97 continue;
95 98 };
@@ -97,7 +100,8 @@ pub fn follow_path(
97 100 .step_cost(tile.0.as_ivec2(), next.as_ivec2())
98 101 .unwrap_or(100) as f32
99 102 / 100.0;
100 - let max_distance = TILES_PER_SECOND * TILE_SIZE / cost * time.delta_secs();
103 + let tiles_per_second = speed.map_or(TILES_PER_SECOND, |s| s.0);
104 + let max_distance = tiles_per_second * TILE_SIZE / cost * time.delta_secs();
101 105 let target = WorldMap::tile_center(next);
102 106 let arrived;
103 107 (pos.0, arrived) = step_towards(pos.0, target, max_distance);

src/jobs/mod.rs +101 -2

@@ -7,6 +7,7 @@ use std::{
7 7 use bevy::prelude::*;
8 8
9 9 use crate::{
10 + animals::{Animal, AnimalKind},
10 11 building::{Blueprints, BuildKind},
11 12 colonist::{Colonist, Path, repath},
12 13 food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food},
@@ -108,6 +109,11 @@ pub enum Task {
108 109 pickup: Option<UVec2>,
109 110 progress: u32,
110 111 },
112 + Hunt {
113 + animal: Entity,
114 + kind: AnimalKind,
115 + progress: u32,
116 + },
111 117 }
112 118
113 119 pub fn work_rate(light: &LightMap, tile: UVec2) -> u32 {
@@ -213,6 +219,7 @@ pub enum Target {
213 219 Storage(UVec2),
214 220 StockpileExpansion,
215 221 SleepSpot(UVec2),
222 + Animal(Entity),
216 223 }
217 224
218 225 #[derive(Resource, Default, Debug)]
@@ -283,8 +290,9 @@ fn handle_orders(
283 290 map: Res<WorldMap>,
284 291 zones: Res<Zones>,
285 292 items: Query<(&Item, &TilePos)>,
293 + animals: Query<(Entity, &TilePos, &Animal), Without<Colonist>>,
286 294 mut reservations: ResMut<Reservations>,
287 - mut colonists: Query<(&TilePos, &mut Path, &mut CurrentJob)>,
295 + mut colonists: Query<(&TilePos, &mut Path, &mut CurrentJob), With<Colonist>>,
288 296 ) {
289 297 for order in orders.read() {
290 298 let Ok((tile, mut path, mut job)) = colonists.get_mut(order.colonist) else {
@@ -293,7 +301,14 @@ fn handle_orders(
293 301 let loose_item = items
294 302 .iter()
295 303 .find(|(_, pos)| pos.0 == order.tile && !zones.is_stockpile(pos.0));
296 - let task = if let Some(work) = site_work(&map, order.tile) {
304 + let animal = animals.iter().find(|(_, pos, _)| pos.0 == order.tile);
305 + let task = if let Some((animal, _, a)) = animal {
306 + Some(Task::Hunt {
307 + animal,
308 + kind: a.kind,
309 + progress: 0,
310 + })
311 + } else if let Some(work) = site_work(&map, order.tile) {
297 312 Some(Task::Work {
298 313 site: order.tile,
299 314 kind: work.kind,
@@ -345,6 +360,7 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
345 360 reservations.reserve(Target::Pickup(pickup), by);
346 361 }
347 362 }
363 + Task::Hunt { animal, .. } => reservations.reserve(Target::Animal(animal), by),
348 364 Task::Build { site, pickup, .. } => {
349 365 reservations.reserve(Target::Site(site), by);
350 366 if let Some(pickup) = pickup {
@@ -420,6 +436,35 @@ pub fn find_haul(
420 436 })
421 437 }
422 438
439 +pub const HUNT_RADIUS: f32 = 60.0;
440 +
441 +pub fn find_hunt(
442 + map: &WorldMap,
443 + reservations: &Reservations,
444 + animals: &[(Entity, UVec2, AnimalKind)],
445 + from: UVec2,
446 +) -> Option<Task> {
447 + let mut prey: Vec<_> = animals
448 + .iter()
449 + .filter(|(e, pos, _)| {
450 + reservations.is_free(Target::Animal(*e))
451 + && pos.as_vec2().distance(from.as_vec2()) <= HUNT_RADIUS
452 + })
453 + .collect();
454 + prey.sort_by(|a, b| {
455 + a.1.as_vec2()
456 + .distance_squared(from.as_vec2())
457 + .total_cmp(&b.1.as_vec2().distance_squared(from.as_vec2()))
458 + });
459 + prey.into_iter()
460 + .find(|(_, pos, _)| find_path_adjacent(map, from, *pos).is_some())
461 + .map(|&(animal, _, kind)| Task::Hunt {
462 + animal,
463 + kind,
464 + progress: 0,
465 + })
466 +}
467 +
423 468 pub fn stockpile_too_full(zones: &Zones, items: &[(Item, TilePos)]) -> bool {
424 469 let occupied: HashSet<UVec2> = items.iter().map(|(_, pos)| pos.0).collect();
425 470 zones.has_stockpile() && zones.empty_stockpile_tiles(&occupied) < MIN_EMPTY_STOCKPILE_TILES
@@ -640,6 +685,7 @@ fn choose_jobs(
640 685 mut reservations: ResMut<Reservations>,
641 686 mut items: ItemQuery,
642 687 structures: Query<(&TilePos, &Structure, Option<&Fuel>), Without<Colonist>>,
688 + animals: Query<(Entity, &TilePos, &Animal), Without<Colonist>>,
643 689 mut colonists: Query<
644 690 (
645 691 Entity,
@@ -654,6 +700,10 @@ fn choose_jobs(
654 700 With<Colonist>,
655 701 >,
656 702 ) {
703 + let wildlife: Vec<(Entity, UVec2, AnimalKind)> = animals
704 + .iter()
705 + .map(|(e, pos, a)| (e, pos.0, a.kind))
706 + .collect();
657 707 let colonist_count = colonists.iter().count();
658 708 let structures_list: Vec<(UVec2, StructureKind, u32)> = structures
659 709 .iter()
@@ -680,6 +730,11 @@ fn choose_jobs(
680 730 })
681 731 .collect();
682 732 let mut pending = on_the_way(&map, colonists.iter().map(|c| c.3.0));
733 + for job in colonists.iter().filter_map(|c| c.3.0) {
734 + if let Task::Hunt { kind, .. } = job.task {
735 + *pending.entry(ItemKind::Meat).or_insert(0) += kind.meat();
736 + }
737 + }
683 738 let hour = hour_of_day(*tick);
684 739 for (entity, tile, mut path, mut job, mut idle, mut carrying, rest, hunger) in &mut colonists {
685 740 let exhausted = rest.0 < EXHAUSTED_REST;
@@ -769,6 +824,11 @@ fn choose_jobs(
769 824 find_expansion(&map, &zones, &reservations, &snapshot, &blueprints)
770 825 .or_else(|| dark.then(|| find_lighting(&build, tile.0)).flatten())
771 826 .or_else(|| find_work(&map, &reservations, tile.0, wanted))
827 + .or_else(|| {
828 + wanted(ItemKind::Meat)
829 + .then(|| find_hunt(&map, &reservations, &wildlife, tile.0))
830 + .flatten()
831 + })
772 832 .or_else(|| find_haul(&map, &zones, &reservations, &snapshot, tile.0))
773 833 .or_else(|| find_bed_site(&build, colonist_count, tile.0))
774 834 .or_else(|| find_blueprint_work(&build, tile.0))
@@ -779,6 +839,9 @@ fn choose_jobs(
779 839 {
780 840 *pending.entry(work.item).or_insert(0) += work.count;
781 841 }
842 + if let Task::Hunt { kind, .. } = task {
843 + *pending.entry(ItemKind::Meat).or_insert(0) += kind.meat();
844 + }
782 845 if let Task::Build {
783 846 site,
784 847 kind: BuildKind::Structure(kind),
@@ -837,6 +900,7 @@ fn run_jobs(
837 900 mut blueprints: ResMut<Blueprints>,
838 901 mut items: ItemQuery,
839 902 structures: Query<&TilePos, (With<Structure>, Without<Colonist>)>,
903 + animals: Query<&TilePos, (With<Animal>, Without<Colonist>)>,
840 904 mut changed: MessageWriter<TileChanged>,
841 905 mut colonists: Query<
842 906 (
@@ -865,6 +929,41 @@ fn run_jobs(
865 929 }
866 930 let step = match &mut job.task {
867 931 Task::MoveTo(_) => Step::Done,
932 + Task::Hunt {
933 + animal,
934 + kind,
935 + progress,
936 + } => match animals.get(*animal) {
937 + Ok(prey) if reservations.holder(Target::Animal(*animal)) == Some(entity) => {
938 + let prey = prey.0;
939 + if prey != tile.0 && !is_adjacent(tile.0.as_ivec2(), prey.as_ivec2()) {
940 + match find_path_adjacent(&map, tile.0, prey) {
941 + Some(p) if !p.is_empty() => {
942 + path.0 = p.into();
943 + Step::Continue
944 + }
945 + _ => Step::Done,
946 + }
947 + } else {
948 + *progress += work_rate(&light, tile.0);
949 + if *progress < kind.hunt_work() {
950 + Step::Continue
951 + } else {
952 + commands.entity(*animal).despawn();
953 + drop_items(
954 + &mut commands,
955 + &mut items,
956 + &item_assets,
957 + prey,
958 + ItemKind::Meat,
959 + kind.meat(),
960 + );
961 + Step::Done
962 + }
963 + }
964 + }
965 + _ => Step::Done,
966 + },
868 967 Task::Eat { pickup, progress } => {
869 968 if let Some(from) = *pickup {
870 969 if reservations.holder(Target::Pickup(from)) != Some(entity) {

src/jobs/tests.rs +51 -0

@@ -1,5 +1,6 @@
1 1 use super::*;
2 2 use crate::{
3 + animals::{Animal, AnimalKind},
3 4 colonist::follow_path,
4 5 food::Hunger,
5 6 items::STACK_LIMIT,
@@ -840,3 +841,53 @@ fn colonists_forage_ripe_berries_when_the_colony_wants_them() {
840 841 assert_eq!(map.growth(ripe.as_ivec2()), 0.0);
841 842 assert_eq!(map.growth(unripe.as_ivec2()), 0.5);
842 843 }
844 +
845 +fn put_animal(app: &mut App, kind: AnimalKind, tile: UVec2) -> Entity {
846 + app.world_mut()
847 + .spawn((
848 + Animal { kind },
849 + TilePos(tile),
850 + sim_pos(WorldMap::tile_center(tile)),
851 + ))
852 + .id()
853 +}
854 +
855 +#[test]
856 +fn colonists_hunt_when_the_colony_wants_meat() {
857 + let mut app = test_app(map_with_trees(&[]));
858 + app.add_systems(Update, crate::animals::wander.before(choose_jobs));
859 + set_goals(&mut app, &[(ItemKind::Meat, 20)]);
860 + let deer = put_animal(&mut app, AnimalKind::Deer, UVec2::new(15, 15));
861 + spawn_colonist(&mut app, UVec2::new(2, 2));
862 + run_ticks(&mut app, 60 * 15);
863 + assert!(app.world().get_entity(deer).is_err());
864 + assert_eq!(items_of(&mut app, ItemKind::Meat), AnimalKind::Deer.meat());
865 +}
866 +
867 +#[test]
868 +fn animals_are_left_alone_when_there_is_enough_meat() {
869 + let mut app = test_app(map_with_trees(&[]));
870 + set_goals(&mut app, &[]);
871 + let rabbit = put_animal(&mut app, AnimalKind::Rabbit, UVec2::new(10, 10));
872 + spawn_colonist(&mut app, UVec2::new(2, 2));
873 + run_ticks(&mut app, 60 * 10);
874 + assert!(app.world().get_entity(rabbit).is_ok());
875 +}
876 +
877 +#[test]
878 +fn right_clicking_an_animal_orders_a_hunt() {
879 + let mut app = test_app(map_with_trees(&[]));
880 + set_goals(&mut app, &[]);
881 + let rabbit = put_animal(&mut app, AnimalKind::Rabbit, UVec2::new(10, 10));
882 + let colonist = spawn_colonist(&mut app, UVec2::new(2, 2));
883 + app.world_mut().write_message(Order {
884 + colonist,
885 + tile: UVec2::new(10, 10),
886 + });
887 + run_ticks(&mut app, 60 * 10);
888 + assert!(app.world().get_entity(rabbit).is_err());
889 + assert_eq!(
890 + items_of(&mut app, ItemKind::Meat),
891 + AnimalKind::Rabbit.meat()
892 + );
893 +}

src/main.rs +2 -0

@@ -1,4 +1,5 @@
1 1 mod actions;
2 +mod animals;
2 3 mod building;
3 4 mod camera;
4 5 mod colonist;
@@ -54,6 +55,7 @@ fn main() {
54 55 ))
55 56 .add_plugins((
56 57 food::FoodPlugin,
58 + animals::AnimalsPlugin,
57 59 plants::PlantsPlugin,
58 60 selection::SelectionPlugin,
59 61 zones::ZonesPlugin,

src/selection.rs +13 -0

@@ -2,6 +2,7 @@ use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
2 2
3 3 use crate::{
4 4 actions::Action,
5 + animals::Animal,
5 6 building::Blueprints,
6 7 camera::CameraController,
7 8 colonist::{Colonist, Path},
@@ -248,6 +249,7 @@ fn update_tile_info(
248 249 )>,
249 250 items: Query<(&Item, &TilePos)>,
250 251 structures: Query<(&Structure, &TilePos, Option<&Fuel>)>,
252 + animals: Query<(&Animal, &TilePos)>,
251 253 blueprints: Res<Blueprints>,
252 254 goals: Res<ColonyGoals>,
253 255 zones: Res<Zones>,
@@ -317,6 +319,12 @@ fn update_tile_info(
317 319 .and_then(|t| blueprints.get(t))
318 320 .map(|kind| format!(", planned {}", kind.name())),
319 321 )
322 + .chain(
323 + animals
324 + .iter()
325 + .filter(|(_, pos)| Some(pos.0) == cursor.tile)
326 + .map(|(animal, _)| format!(", {}", animal.kind.name())),
327 + )
320 328 .collect();
321 329 let tool_hint = if tool.active {
322 330 "\nSTOCKPILE TOOL: drag to add, shift-drag to remove"
@@ -384,6 +392,7 @@ pub fn progress_fraction(task: Task) -> Option<f32> {
384 392 Task::Work { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
385 393 Task::ExpandStockpile { progress, .. } => Some(progress as f32 / EXPAND_WORK as f32),
386 394 Task::Build { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
395 + Task::Hunt { kind, progress, .. } => Some(progress as f32 / kind.hunt_work() as f32),
387 396 _ => None,
388 397 }
389 398 }
@@ -470,6 +479,10 @@ pub fn describe_colonist(view: &ColonistView) -> String {
470 479 _,
471 480 ) => "fetching food".to_string(),
472 481 (Some(Task::Eat { .. }), _) => "eating".to_string(),
482 + (Some(task @ Task::Hunt { kind, progress, .. }), _) if progress > 0 => {
483 + format!("hunting a {}, {}%", kind.name(), percent(task))
484 + }
485 + (Some(Task::Hunt { kind, .. }), _) => format!("stalking a {}", kind.name()),
473 486 (Some(task @ Task::Build { kind, .. }), _) => {
474 487 format!("building a {}, {}%", kind.name(), percent(task))
475 488 }