Animals flee from colonists; hunters shoot from range

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 22:14 UTC
commit
a62c0e859580b8c6e46d5b8474a91b58138079ce
parent
137cf1843f
tree
browse at this commit

3 files changed +111 -35

src/animals.rs +69 -32

@@ -3,8 +3,7 @@ use std::{ops::Range, time::Duration};
3 3 use bevy::prelude::*;
4 4
5 5 use crate::{
6 - colonist::{MoveSpeed, Path},
7 - jobs::{Reservations, Target},
6 + colonist::{Colonist, MoveSpeed, Path},
8 7 pathfinding::find_path,
9 8 rng::SimRng,
10 9 sim::{SimPos, SimSystems, SimTick, TICKS_PER_HOUR, sim_pos},
@@ -29,6 +28,8 @@ impl Plugin for AnimalsPlugin {
29 28 }
30 29
31 30 const WANDER_RADIUS: i32 = 10;
31 +pub const FLEE_RANGE: i32 = 4;
32 +const FLEE_DISTANCE: f32 = 7.0;
32 33 const IDLE_SECS: Range<f32> = 2.0..8.0;
33 34 const MIN_DISTANCE_FROM_COLONY: f32 = 25.0;
34 35 const MIGRATION_INTERVAL_TICKS: u64 = 2 * TICKS_PER_HOUR;
@@ -227,17 +228,42 @@ fn migrate(
227 228 }
228 229 }
229 230
231 +pub fn flee_goal(tile: UVec2, threats: &[UVec2]) -> Option<IVec2> {
232 + let near: Vec<IVec2> = threats
233 + .iter()
234 + .map(|t| t.as_ivec2())
235 + .filter(|t| (*t - tile.as_ivec2()).abs().max_element() <= FLEE_RANGE)
236 + .collect();
237 + if near.is_empty() {
238 + return None;
239 + }
240 + let away: Vec2 = near
241 + .iter()
242 + .map(|t| (tile.as_ivec2() - *t).as_vec2())
243 + .sum::<Vec2>()
244 + .normalize_or(Vec2::X);
245 + Some(tile.as_ivec2() + (away * FLEE_DISTANCE).round().as_ivec2())
246 +}
247 +
230 248 pub fn wander(
231 249 time: Res<Time>,
232 250 map: Res<WorldMap>,
233 - reservations: Res<Reservations>,
234 251 mut rng: ResMut<SimRng>,
235 - mut animals: Query<(Entity, &TilePos, &mut Path, &mut AnimalIdle), With<Animal>>,
252 + colonists: Query<&TilePos, (With<Colonist>, Without<Animal>)>,
253 + mut animals: Query<(&TilePos, &mut Path, &mut AnimalIdle), With<Animal>>,
236 254 ) {
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;
255 + let threats: Vec<UVec2> = colonists.iter().map(|t| t.0).collect();
256 + for (tile, mut path, mut idle) in &mut animals {
257 + let heading_into_danger = path
258 + .0
259 + .back()
260 + .is_none_or(|end| flee_goal(*end, &threats).is_some());
261 + if heading_into_danger && let Some(goal) = flee_goal(tile.0, &threats) {
262 + let goal = goal.clamp(IVec2::ZERO, map.size.as_ivec2() - 1).as_uvec2();
263 + if let Some(route) = find_path(&map, tile.0, goal) {
264 + path.0 = route.into();
265 + continue;
266 + }
241 267 }
242 268 if !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() {
243 269 continue;
@@ -264,6 +290,17 @@ mod tests {
264 290 use crate::world::Terrain;
265 291
266 292 #[test]
293 + fn animals_run_away_from_nearby_colonists() {
294 + let tile = UVec2::new(10, 10);
295 + let goal = flee_goal(tile, &[UVec2::new(8, 10), UVec2::new(30, 30)]).unwrap();
296 + assert!(goal.x > 10 + FLEE_RANGE);
297 + assert_eq!(
298 + flee_goal(tile, &[UVec2::new(10, 10 + FLEE_RANGE as u32 + 1)]),
299 + None
300 + );
301 + }
302 +
303 + #[test]
267 304 fn wild_animals_appear_away_from_the_colony_on_open_ground() {
268 305 let mut map = WorldMap::filled(UVec2::splat(80), Terrain::Grass);
269 306 map.set_plant(UVec2::new(70, 70), Some(crate::world::Plant::Tree));
@@ -284,42 +321,42 @@ mod tests {
284 321 ));
285 322 }
286 323
324 + // Hunted animals used to stand still for the hunter; now everything wild bolts from people
325 + // nearby, which is why hunters shoot from beyond FLEE_RANGE.
287 326 #[test]
288 - fn idle_animals_wander_and_hunted_ones_stand_still() {
327 + fn idle_animals_wander_and_startled_ones_bolt() {
289 328 let mut app = App::new();
290 329 app.insert_resource(WorldMap::filled(UVec2::splat(30), Terrain::Grass))
291 330 .insert_resource(SimRng::new(1))
292 331 .init_resource::<Time>()
293 - .init_resource::<Reservations>()
294 332 .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();
333 + let spawn = |app: &mut App, tile: UVec2| {
334 + app.world_mut()
335 + .spawn((
336 + Animal {
337 + kind: AnimalKind::Deer,
338 + },
339 + TilePos(tile),
340 + ))
341 + .id()
342 + };
343 + let idle = spawn(&mut app, UVec2::splat(25));
344 + let startled = spawn(&mut app, UVec2::splat(10));
313 345 app.world_mut()
314 - .resource_mut::<Reservations>()
315 - .reserve(Target::Animal(hunted), Entity::PLACEHOLDER);
346 + .spawn((Colonist { name: "Ada".into() }, TilePos(UVec2::new(9, 10))));
347 + app.update();
348 + let route = &app.world().get::<Path>(startled).unwrap().0;
349 + assert!(
350 + route
351 + .back()
352 + .is_some_and(|end| end.x > 10 + FLEE_RANGE as u32)
353 + );
316 354 for _ in 0..10 {
317 355 app.world_mut()
318 356 .resource_mut::<Time>()
319 357 .advance_by(Duration::from_secs(1));
320 358 app.update();
321 359 }
322 - assert!(!app.world().get::<Path>(free).unwrap().0.is_empty());
323 - assert!(app.world().get::<Path>(hunted).unwrap().0.is_empty());
360 + assert!(!app.world().get::<Path>(idle).unwrap().0.is_empty());
324 361 }
325 362 }

src/jobs/mod.rs +11 -2

@@ -18,7 +18,7 @@ use crate::{
18 18 CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, drop_items, room_for,
19 19 stock, take_items,
20 20 },
21 - light::{LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from},
21 + light::{LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from, line_of_sight},
22 22 needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep},
23 23 pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_of, nearest_tile},
24 24 rng::SimRng,
@@ -462,6 +462,7 @@ pub fn find_sowing(
462 462 }
463 463
464 464 pub const HUNT_RADIUS: f32 = 60.0;
465 +pub const HUNT_RANGE: f32 = 6.0;
465 466
466 467 pub fn find_hunt(
467 468 map: &WorldMap,
@@ -1186,7 +1187,14 @@ fn run_jobs(
1186 1187 } => match animals.get(*animal) {
1187 1188 Ok((prey, _)) if reservations.holder(Target::Animal(*animal)) == Some(entity) => {
1188 1189 let prey = prey.0;
1189 - if prey != tile.0 && !is_adjacent(tile.0.as_ivec2(), prey.as_ivec2()) {
1190 + let in_range = tile.0.as_vec2().distance(prey.as_vec2()) <= HUNT_RANGE
1191 + && line_of_sight(&map, tile.0, prey);
1192 + let stale = path.0.back().is_none_or(|end| {
1193 + end.as_vec2().distance(prey.as_vec2()) > HUNT_RANGE / 2.0
1194 + });
1195 + if !in_range && !stale {
1196 + Step::Continue
1197 + } else if !in_range {
1190 1198 match find_path_adjacent(&map, tile.0, prey) {
1191 1199 Some(p) if !p.is_empty() => {
1192 1200 path.0 = p.into();
@@ -1195,6 +1203,7 @@ fn run_jobs(
1195 1203 _ => Step::Done,
1196 1204 }
1197 1205 } else {
1206 + path.0.truncate(1);
1198 1207 *progress += work_rate(&light, tile.0);
1199 1208 if *progress < kind.hunt_work() {
1200 1209 Step::Continue

src/jobs/tests.rs +31 -1

@@ -863,7 +863,7 @@ fn colonists_hunt_when_the_colony_wants_meat() {
863 863 set_goals(&mut app, &[(ItemKind::Meat, 20)]);
864 864 let deer = put_animal(&mut app, AnimalKind::Deer, UVec2::new(15, 15));
865 865 spawn_colonist(&mut app, UVec2::new(2, 2));
866 - run_ticks(&mut app, 60 * 15);
866 + run_ticks(&mut app, 60 * 15 + BUTCHER_WORK);
867 867 assert!(app.world().get_entity(deer).is_err());
868 868 assert_eq!(items_of(&mut app, ItemKind::Meat), AnimalKind::Deer.meat());
869 869 }
@@ -1235,3 +1235,33 @@ fn carcasses_are_butchered_into_meat() {
1235 1235 assert_eq!(items_of(&mut app, ItemKind::Carcass), 0);
1236 1236 assert_eq!(items_of(&mut app, ItemKind::Meat), 8);
1237 1237 }
1238 +
1239 +#[test]
1240 +fn hunters_shoot_from_a_distance() {
1241 + let mut app = test_app(map_with_trees(&[]));
1242 + app.add_systems(Update, crate::animals::wander.before(choose_jobs));
1243 + set_goals(&mut app, &[(ItemKind::Meat, 20)]);
1244 + let deer = put_animal(&mut app, AnimalKind::Deer, UVec2::new(15, 15));
1245 + let hunter = spawn_colonist(&mut app, UVec2::new(2, 2));
1246 + let mut shots_from = Vec::new();
1247 + let mut last_progress = 0;
1248 + for _ in 0..60 * 15 {
1249 + run_ticks(&mut app, 1);
1250 + let Some(prey) = app.world().get::<TilePos>(deer) else {
1251 + break;
1252 + };
1253 + let at = app.world().get::<TilePos>(hunter).unwrap();
1254 + if let Some(Task::Hunt { progress, .. }) = task(&app, hunter) {
1255 + if progress > last_progress {
1256 + shots_from.push(prey.0.as_vec2().distance(at.0.as_vec2()));
1257 + }
1258 + last_progress = progress;
1259 + }
1260 + }
1261 + assert!(app.world().get_entity(deer).is_err());
1262 + assert!(shots_from.iter().any(|&d| d > 2.0), "{shots_from:?}");
1263 + assert!(
1264 + shots_from.iter().all(|&d| d <= HUNT_RANGE + 1.5),
1265 + "{shots_from:?}"
1266 + );
1267 +}