src/animals.rs 16.9 K · 550 lines · raw · history

1 use serde::{Deserialize, Serialize};
2 use std::{ops::Range, time::Duration};
3
4 use bevy::prelude::*;
5
6 use crate::{
7 colonist::{Colonist, MoveSpeed, Path},
8 jobs::{CurrentJob, Task},
9 pathfinding::{WANDER_SEARCH, find_nearby_path},
10 rng::SimRng,
11 sim::{SimPos, SimSystems, SimTick, TICKS_PER_HOUR, sim_pos},
12 threats::{Health, Predator},
13 world::{TILE_SIZE, TilePos, WorldMap},
14 worldgen::{WorldGenerated, spawn_tiles},
15 zones::Zones,
16 };
17
18 pub struct AnimalsPlugin;
19
20 impl Plugin for AnimalsPlugin {
21 fn build(&self, app: &mut App) {
22 app.add_systems(Startup, create_animal_assets)
23 .add_systems(Update, populate.run_if(on_message::<WorldGenerated>))
24 .add_systems(
25 FixedUpdate,
26 (
27 wander.in_set(SimSystems::Decide),
28 (migrate, breed).in_set(SimSystems::Act),
29 ),
30 );
31 }
32 }
33
34 const WANDER_RADIUS: i32 = 10;
35 pub const MAX_TAME: usize = 6;
36 pub const PEN_RADIUS: i32 = 8;
37 const BREED_CHANCE: f32 = 0.5;
38 const BREED_INTERVAL_TICKS: u64 = 24 * TICKS_PER_HOUR;
39 pub const FLEE_RANGE: i32 = 4;
40 const FLEE_DISTANCE: f32 = 7.0;
41 const FLEE_RETRY_SECS: f32 = 1.0;
42 const IDLE_SECS: Range<f32> = 2.0..8.0;
43 const MIN_DISTANCE_FROM_COLONY: f32 = 25.0;
44 const MIGRATION_INTERVAL_TICKS: u64 = 2 * TICKS_PER_HOUR;
45 const PLACEMENT_ATTEMPTS: usize = 200;
46
47 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
48 pub enum AnimalKind {
49 Deer,
50 Rabbit,
51 Wolf,
52 }
53
54 impl AnimalKind {
55 pub const ALL: [AnimalKind; 3] = [AnimalKind::Deer, AnimalKind::Rabbit, AnimalKind::Wolf];
56
57 pub fn meat(self) -> u32 {
58 match self {
59 AnimalKind::Deer => 30,
60 AnimalKind::Rabbit => 8,
61 AnimalKind::Wolf => 12,
62 }
63 }
64
65 pub fn population(self) -> usize {
66 match self {
67 AnimalKind::Deer => 10,
68 AnimalKind::Rabbit => 16,
69 AnimalKind::Wolf => 3,
70 }
71 }
72
73 pub fn migration_ticks(self) -> u64 {
74 match self {
75 AnimalKind::Wolf => 24 * TICKS_PER_HOUR,
76 _ => MIGRATION_INTERVAL_TICKS,
77 }
78 }
79
80 pub fn is_predator(self) -> bool {
81 self == AnimalKind::Wolf
82 }
83
84 pub fn tame_work(self) -> u32 {
85 match self {
86 AnimalKind::Deer => 600,
87 AnimalKind::Rabbit => 300,
88 AnimalKind::Wolf => u32::MAX,
89 }
90 }
91
92 pub fn hunt_work(self) -> u32 {
93 match self {
94 AnimalKind::Deer => 360,
95 AnimalKind::Rabbit => 160,
96 AnimalKind::Wolf => 300,
97 }
98 }
99
100 fn speed(self) -> f32 {
101 match self {
102 AnimalKind::Deer => 3.0,
103 AnimalKind::Rabbit => 2.5,
104 AnimalKind::Wolf => 3.4,
105 }
106 }
107
108 pub fn name(self) -> &'static str {
109 match self {
110 AnimalKind::Deer => "deer",
111 AnimalKind::Rabbit => "rabbit",
112 AnimalKind::Wolf => "wolf",
113 }
114 }
115 }
116
117 #[derive(Component, Debug, Clone, Copy, PartialEq)]
118 #[require(Transform, Visibility, TilePos, Path, SimPos, AnimalIdle, Health)]
119 pub struct Animal {
120 pub kind: AnimalKind,
121 }
122
123 #[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
124 pub struct Tame;
125
126 #[derive(Component, Debug)]
127 pub struct AnimalIdle(Timer, f32);
128
129 impl Default for AnimalIdle {
130 fn default() -> Self {
131 Self(Timer::from_seconds(IDLE_SECS.start, TimerMode::Once), 0.0)
132 }
133 }
134
135 type Part = (Handle<Mesh>, Handle<ColorMaterial>);
136
137 #[derive(Resource, Default)]
138 pub struct AnimalAssets {
139 deer_body: Part,
140 deer_head: Part,
141 rabbit_body: Part,
142 rabbit_ear: Part,
143 wolf_body: Part,
144 wolf_head: Part,
145 wolf_tail: Part,
146 }
147
148 fn create_animal_assets(
149 mut commands: Commands,
150 mut meshes: ResMut<Assets<Mesh>>,
151 mut materials: ResMut<Assets<ColorMaterial>>,
152 ) {
153 let mut part = |mesh: Mesh, color: Color| (meshes.add(mesh), materials.add(color));
154 let deer = Color::srgb_u8(150, 100, 60);
155 let rabbit = Color::srgb_u8(185, 175, 160);
156 commands.insert_resource(AnimalAssets {
157 deer_body: part(
158 Ellipse::new(TILE_SIZE * 0.42, TILE_SIZE * 0.22).into(),
159 deer,
160 ),
161 deer_head: part(
162 Rectangle::new(TILE_SIZE * 0.2, TILE_SIZE * 0.3).into(),
163 Color::srgb_u8(120, 80, 48),
164 ),
165 rabbit_body: part(
166 Ellipse::new(TILE_SIZE * 0.22, TILE_SIZE * 0.15).into(),
167 rabbit,
168 ),
169 rabbit_ear: part(
170 Rectangle::new(TILE_SIZE * 0.06, TILE_SIZE * 0.2).into(),
171 rabbit,
172 ),
173 wolf_body: part(
174 Ellipse::new(TILE_SIZE * 0.38, TILE_SIZE * 0.17).into(),
175 Color::srgb_u8(95, 95, 102),
176 ),
177 wolf_head: part(
178 Triangle2d::new(
179 Vec2::new(0.0, -TILE_SIZE * 0.13),
180 Vec2::new(0.0, TILE_SIZE * 0.13),
181 Vec2::new(TILE_SIZE * 0.28, 0.0),
182 )
183 .into(),
184 Color::srgb_u8(60, 60, 66),
185 ),
186 wolf_tail: part(
187 Rectangle::new(TILE_SIZE * 0.22, TILE_SIZE * 0.07).into(),
188 Color::srgb_u8(70, 70, 76),
189 ),
190 });
191 }
192
193 fn part_bundle((mesh, material): &Part, transform: Transform) -> impl Bundle {
194 (
195 Mesh2d(mesh.clone()),
196 MeshMaterial2d(material.clone()),
197 transform,
198 )
199 }
200
201 pub fn spawn_animal(
202 commands: &mut Commands,
203 assets: &AnimalAssets,
204 kind: AnimalKind,
205 tile: UVec2,
206 ) -> Entity {
207 let at = |x: f32, y: f32| Transform::from_xyz(x * TILE_SIZE, y * TILE_SIZE, 0.01);
208 let mut entity = commands.spawn((
209 Name::new(kind.name()),
210 Animal { kind },
211 MoveSpeed(kind.speed()),
212 TilePos(tile),
213 sim_pos(WorldMap::tile_center(tile)),
214 Transform::from_translation(WorldMap::tile_center(tile).extend(0.9)),
215 ));
216 entity.with_children(|c| match kind {
217 AnimalKind::Deer => {
218 c.spawn(part_bundle(&assets.deer_body, at(0.0, 0.0)));
219 c.spawn(part_bundle(&assets.deer_head, at(0.38, 0.15)));
220 }
221 AnimalKind::Rabbit => {
222 c.spawn(part_bundle(&assets.rabbit_body, at(0.0, 0.0)));
223 c.spawn(part_bundle(&assets.rabbit_ear, at(0.1, 0.2)));
224 c.spawn(part_bundle(&assets.rabbit_ear, at(0.18, 0.2)));
225 }
226 AnimalKind::Wolf => {
227 c.spawn(part_bundle(&assets.wolf_tail, at(-0.42, 0.06)));
228 c.spawn(part_bundle(&assets.wolf_body, at(0.0, 0.0)));
229 c.spawn(part_bundle(&assets.wolf_head, at(0.3, 0.05)));
230 }
231 });
232 if kind.is_predator() {
233 entity.insert(Predator::default());
234 }
235 entity.id()
236 }
237
238 pub fn wild_tile(map: &WorldMap, rng: &mut SimRng, away_from: UVec2) -> Option<UVec2> {
239 (0..PLACEMENT_ATTEMPTS).find_map(|_| {
240 let tile = IVec2::new(
241 rng.range_i32(0..map.size.x as i32),
242 rng.range_i32(0..map.size.y as i32),
243 );
244 let far = tile.as_vec2().distance(away_from.as_vec2()) >= MIN_DISTANCE_FROM_COLONY;
245 let open = map.move_cost(tile).is_some() && map.plant(tile).is_none();
246 (far && open && !map.get(tile).is_some_and(|t| t.is_water())).then(|| tile.as_uvec2())
247 })
248 }
249
250 fn populate(
251 mut commands: Commands,
252 map: Res<WorldMap>,
253 assets: Res<AnimalAssets>,
254 mut rng: ResMut<SimRng>,
255 existing: Query<Entity, With<Animal>>,
256 ) {
257 for entity in &existing {
258 commands.entity(entity).despawn();
259 }
260 let colony = spawn_tiles(&map, 1)
261 .first()
262 .copied()
263 .unwrap_or(map.size / 2);
264 for kind in AnimalKind::ALL {
265 for _ in 0..kind.population() {
266 if let Some(tile) = wild_tile(&map, &mut rng, colony) {
267 spawn_animal(&mut commands, &assets, kind, tile);
268 }
269 }
270 }
271 }
272
273 pub fn missing_animals(kind: AnimalKind, alive: usize) -> bool {
274 alive < kind.population()
275 }
276
277 fn breed(
278 mut commands: Commands,
279 tick: Res<SimTick>,
280 assets: Res<AnimalAssets>,
281 mut rng: ResMut<SimRng>,
282 tame: Query<(&Animal, &TilePos), With<Tame>>,
283 ) {
284 if !tick.0.is_multiple_of(BREED_INTERVAL_TICKS) || tick.0 == 0 {
285 return;
286 }
287 let mut total = tame.iter().count();
288 for kind in AnimalKind::ALL {
289 let herd: Vec<UVec2> = tame
290 .iter()
291 .filter(|(a, _)| a.kind == kind)
292 .map(|(_, pos)| pos.0)
293 .collect();
294 if herd.len() >= 2 && total < MAX_TAME && rng.range_f32(0.0..1.0) < BREED_CHANCE {
295 let young = spawn_animal(&mut commands, &assets, kind, herd[0]);
296 commands.entity(young).insert(Tame);
297 total += 1;
298 }
299 }
300 }
301
302 fn migrate(
303 mut commands: Commands,
304 tick: Res<SimTick>,
305 map: Res<WorldMap>,
306 assets: Res<AnimalAssets>,
307 mut rng: ResMut<SimRng>,
308 animals: Query<&Animal, Without<Tame>>,
309 zones: Res<Zones>,
310 ) {
311 if !tick.0.is_multiple_of(MIGRATION_INTERVAL_TICKS) || tick.0 == 0 {
312 return;
313 }
314 let colony = zones.home(map.size / 2);
315 for kind in AnimalKind::ALL {
316 if !tick.0.is_multiple_of(kind.migration_ticks()) {
317 continue;
318 }
319 let alive = animals.iter().filter(|a| a.kind == kind).count();
320 if missing_animals(kind, alive)
321 && let Some(tile) = wild_tile(&map, &mut rng, colony)
322 {
323 spawn_animal(&mut commands, &assets, kind, tile);
324 }
325 }
326 }
327
328 pub fn flee_goal(tile: UVec2, threats: &[UVec2]) -> Option<IVec2> {
329 let near: Vec<IVec2> = threats
330 .iter()
331 .map(|t| t.as_ivec2())
332 .filter(|t| (*t - tile.as_ivec2()).abs().max_element() <= FLEE_RANGE)
333 .collect();
334 if near.is_empty() {
335 return None;
336 }
337 let away: Vec2 = near
338 .iter()
339 .map(|t| (tile.as_ivec2() - *t).as_vec2())
340 .sum::<Vec2>()
341 .normalize_or(Vec2::X);
342 Some(tile.as_ivec2() + (away * FLEE_DISTANCE).round().as_ivec2())
343 }
344
345 #[allow(clippy::type_complexity)]
346 pub fn wander(
347 time: Res<Time>,
348 map: Res<WorldMap>,
349 zones: Res<Zones>,
350 mut rng: ResMut<SimRng>,
351 colonists: Query<(&TilePos, &CurrentJob), (With<Colonist>, Without<Animal>)>,
352 mut animals: Query<
353 (
354 Entity,
355 &TilePos,
356 &mut Path,
357 &mut AnimalIdle,
358 Has<Tame>,
359 Option<&Predator>,
360 ),
361 With<Animal>,
362 >,
363 ) {
364 let threats: Vec<UVec2> = colonists
365 .iter()
366 .map(|(t, _)| t.0)
367 .chain(animals.iter().filter(|a| a.5.is_some()).map(|a| a.1.0))
368 .collect();
369 let being_tamed: Vec<Entity> = colonists
370 .iter()
371 .filter_map(|(_, job)| match job.0?.task {
372 Task::Tame { animal, .. } => Some(animal),
373 _ => None,
374 })
375 .collect();
376 let home = zones.home(map.size / 2).as_ivec2();
377 for (entity, tile, mut path, mut idle, tame, predator) in &mut animals {
378 if predator.is_some_and(|p| p.target.is_some()) {
379 continue;
380 }
381 if being_tamed.contains(&entity) {
382 path.0.truncate(1);
383 continue;
384 }
385 if tame {
386 if !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() {
387 continue;
388 }
389 let goal = home
390 + IVec2::new(
391 rng.range_i32(-PEN_RADIUS..PEN_RADIUS + 1),
392 rng.range_i32(-PEN_RADIUS..PEN_RADIUS + 1),
393 );
394 if map.in_bounds(goal)
395 && let Some(route) = find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH)
396 {
397 path.0 = route.into();
398 }
399 idle.0
400 .set_duration(Duration::from_secs_f32(rng.range_f32(IDLE_SECS)));
401 idle.0.reset();
402 continue;
403 }
404 let threats: &[UVec2] = if predator.is_some() { &[] } else { &threats };
405 idle.1 = (idle.1 - time.delta_secs()).max(0.0);
406 let ready = idle.1 <= 0.0;
407 let heading_into_danger = ready
408 && path
409 .0
410 .back()
411 .is_none_or(|end| flee_goal(*end, threats).is_some());
412 if heading_into_danger && let Some(goal) = flee_goal(tile.0, threats) {
413 let goal = goal.clamp(IVec2::ZERO, map.size.as_ivec2() - 1).as_uvec2();
414 if let Some(route) = find_nearby_path(&map, tile.0, goal, WANDER_SEARCH) {
415 path.0 = route.into();
416 continue;
417 }
418 path.0.truncate(1);
419 idle.1 = FLEE_RETRY_SECS;
420 }
421 if !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() {
422 continue;
423 }
424 let goal = tile.0.as_ivec2()
425 + IVec2::new(
426 rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
427 rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
428 );
429 if map.in_bounds(goal)
430 && let Some(route) = find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH)
431 {
432 path.0 = route.into();
433 }
434 let secs = rng.range_f32(IDLE_SECS);
435 idle.0.set_duration(Duration::from_secs_f32(secs));
436 idle.0.reset();
437 }
438 }
439
440 #[cfg(test)]
441 mod tests {
442 use super::*;
443 use crate::world::Terrain;
444
445 #[test]
446 fn animals_run_away_from_nearby_colonists() {
447 let tile = UVec2::new(10, 10);
448 let goal = flee_goal(tile, &[UVec2::new(8, 10), UVec2::new(30, 30)]).unwrap();
449 assert!(goal.x > 10 + FLEE_RANGE);
450 assert_eq!(
451 flee_goal(tile, &[UVec2::new(10, 10 + FLEE_RANGE as u32 + 1)]),
452 None
453 );
454 }
455
456 #[test]
457 fn wild_animals_appear_away_from_the_colony_on_open_ground() {
458 let mut map = WorldMap::filled(UVec2::splat(80), Terrain::Grass);
459 map.set_plant(UVec2::new(70, 70), Some(crate::world::Plant::Tree));
460 let mut rng = SimRng::new(3);
461 for _ in 0..50 {
462 let tile = wild_tile(&map, &mut rng, UVec2::new(40, 40)).unwrap();
463 assert!(tile.as_vec2().distance(Vec2::splat(40.0)) >= MIN_DISTANCE_FROM_COLONY);
464 assert!(map.plant(tile.as_ivec2()).is_none());
465 }
466 }
467
468 #[test]
469 fn herds_regrow_up_to_their_population() {
470 assert!(missing_animals(AnimalKind::Deer, 3));
471 assert!(!missing_animals(
472 AnimalKind::Deer,
473 AnimalKind::Deer.population()
474 ));
475 }
476
477 // Hunted animals used to stand still for the hunter; now everything wild bolts from people
478 // nearby, which is why hunters shoot from beyond FLEE_RANGE.
479 #[test]
480 fn idle_animals_wander_and_startled_ones_bolt() {
481 let mut app = App::new();
482 app.insert_resource(WorldMap::filled(UVec2::splat(30), Terrain::Grass))
483 .insert_resource(SimRng::new(1))
484 .init_resource::<Time>()
485 .init_resource::<Zones>()
486 .add_systems(Update, wander);
487 let spawn = |app: &mut App, tile: UVec2| {
488 app.world_mut()
489 .spawn((
490 Animal {
491 kind: AnimalKind::Deer,
492 },
493 TilePos(tile),
494 ))
495 .id()
496 };
497 let idle = spawn(&mut app, UVec2::splat(25));
498 let startled = spawn(&mut app, UVec2::splat(10));
499 app.world_mut()
500 .spawn((Colonist { name: "Ada".into() }, TilePos(UVec2::new(9, 10))));
501 app.update();
502 let route = &app.world().get::<Path>(startled).unwrap().0;
503 assert!(
504 route
505 .back()
506 .is_some_and(|end| end.x > 10 + FLEE_RANGE as u32)
507 );
508 for _ in 0..10 {
509 app.world_mut()
510 .resource_mut::<Time>()
511 .advance_by(Duration::from_secs(1));
512 app.update();
513 }
514 assert!(!app.world().get::<Path>(idle).unwrap().0.is_empty());
515 }
516
517 // A boxed-in animal can't reach any wander goal. It used to retry on the very next tick,
518 // forever; once the colony walled in a few animals, those failing searches ran every tick and
519 // made late games ten times slower. Failed attempts now wait out the idle time too.
520 #[test]
521 fn boxed_in_animals_wait_before_trying_again() {
522 let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
523 for d in [-1, 0, 1] {
524 for (x, y) in [(d, -1), (d, 1), (-1, d), (1, d)] {
525 map.set_wall(UVec2::new((10 + x) as u32, (10 + y) as u32), true);
526 }
527 }
528 let mut app = App::new();
529 app.insert_resource(map)
530 .insert_resource(SimRng::new(1))
531 .init_resource::<Time>()
532 .init_resource::<Zones>()
533 .add_systems(Update, wander);
534 let deer = app
535 .world_mut()
536 .spawn((
537 Animal {
538 kind: AnimalKind::Deer,
539 },
540 TilePos(UVec2::splat(10)),
541 ))
542 .id();
543 app.world_mut()
544 .resource_mut::<Time>()
545 .advance_by(Duration::from_secs(10));
546 app.update();
547 let idle = app.world().get::<AnimalIdle>(deer).unwrap();
548 assert!(!idle.0.is_finished());
549 }
550 }