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