src/threats.rs 10.7 K · 331 lines · raw · history

1 use std::collections::HashSet;
2
3 use bevy::prelude::*;
4
5 use crate::{
6 animals::Animal,
7 colonist::{Colonist, Path},
8 items::{Carrying, ItemAssets, ItemQuery, drop_items},
9 jobs::{CurrentJob, Reservations, Target},
10 mood::Losses,
11 needs::is_sleeping,
12 pathfinding::{find_path_adjacent, is_adjacent},
13 population::ColonyLog,
14 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
15 structures::{Structure, StructureKind},
16 world::{TilePos, WorldMap},
17 };
18
19 pub struct ThreatsPlugin;
20
21 impl Plugin for ThreatsPlugin {
22 fn build(&self, app: &mut App) {
23 app.add_systems(
24 FixedUpdate,
25 (
26 prowl.in_set(SimSystems::Decide),
27 (bite, heal).chain().in_set(SimSystems::Act),
28 ),
29 );
30 }
31 }
32
33 pub const PROWL_RANGE: i32 = 24;
34 pub const COLONIST_DETOUR: i32 = 6;
35 pub const COLONIST_BITE: f32 = 0.25;
36 pub const ANIMAL_BITE: f32 = 0.5;
37 pub const BITE_COOLDOWN_TICKS: u32 = 60;
38 pub const FED_TICKS: u64 = 12 * TICKS_PER_HOUR;
39 pub const INJURED: f32 = 0.5;
40 pub const HURT: f32 = 0.75;
41 const HEAL_PER_DAY: f32 = 0.3;
42 const BED_HEAL_FACTOR: f32 = 3.0;
43 const HEAL_INTERVAL_TICKS: u64 = 60;
44 const PROWL_INTERVAL_TICKS: u64 = 30;
45
46 #[derive(Component, Debug, Clone, Copy, PartialEq)]
47 pub struct Health(pub f32);
48
49 impl Default for Health {
50 fn default() -> Self {
51 Self(1.0)
52 }
53 }
54
55 #[derive(Component, Debug, Clone, Copy, PartialEq)]
56 pub struct Tended {
57 pub until: u64,
58 pub quality: f32,
59 }
60
61 pub const TENDED_TICKS: u64 = 24 * TICKS_PER_HOUR;
62 pub const TEND_WORK: u32 = 240;
63 const TENDED_HEAL_BONUS: f32 = 2.0;
64
65 pub fn tend_quality(doctor_speed: f32, herb: bool) -> f32 {
66 (doctor_speed * if herb { 1.0 } else { 0.5 }).min(2.0)
67 }
68
69 pub fn needs_tending(health: f32, tended: Option<&Tended>, tick: u64) -> bool {
70 health < HURT && tended.is_none_or(|t| tick >= t.until)
71 }
72
73 #[derive(Component, Debug, Default, Clone, Copy, PartialEq)]
74 pub struct Predator {
75 pub target: Option<Entity>,
76 pub fed_until: u64,
77 pub cooldown: u32,
78 }
79
80 #[derive(Clone, Copy, Debug, PartialEq)]
81 pub struct Prey {
82 pub entity: Entity,
83 pub tile: UVec2,
84 pub colonist: bool,
85 }
86
87 pub fn pick_prey(from: UVec2, prey: &[Prey]) -> Option<Prey> {
88 prey.iter()
89 .filter_map(|p| {
90 let distance = (p.tile.as_ivec2() - from.as_ivec2()).abs().max_element();
91 let cost = distance + if p.colonist { COLONIST_DETOUR } else { 0 };
92 (distance <= PROWL_RANGE).then_some((cost, p))
93 })
94 .min_by_key(|(cost, p)| (*cost, p.tile.y, p.tile.x))
95 .map(|(_, p)| *p)
96 }
97
98 #[allow(clippy::type_complexity)]
99 fn prowl(
100 tick: Res<SimTick>,
101 map: Res<WorldMap>,
102 mut wolves: Query<(&TilePos, &mut Path, &mut Predator), With<Animal>>,
103 animals: Query<(Entity, &TilePos), (With<Animal>, Without<Predator>)>,
104 colonists: Query<(Entity, &TilePos), With<Colonist>>,
105 ) {
106 if !tick.0.is_multiple_of(PROWL_INTERVAL_TICKS) {
107 return;
108 }
109 let prey: Vec<Prey> = animals
110 .iter()
111 .map(|(entity, tile)| Prey {
112 entity,
113 tile: tile.0,
114 colonist: false,
115 })
116 .chain(colonists.iter().map(|(entity, tile)| Prey {
117 entity,
118 tile: tile.0,
119 colonist: true,
120 }))
121 .collect();
122 for (tile, mut path, mut predator) in &mut wolves {
123 if tick.0 < predator.fed_until {
124 predator.target = None;
125 continue;
126 }
127 let Some(target) = pick_prey(tile.0, &prey) else {
128 predator.target = None;
129 continue;
130 };
131 predator.target = Some(target.entity);
132 let end = path.0.back().copied().unwrap_or(tile.0);
133 if is_adjacent(end.as_ivec2(), target.tile.as_ivec2()) || end == target.tile {
134 continue;
135 }
136 if let Some(route) = find_path_adjacent(&map, tile.0, target.tile) {
137 let entering = path.0.front().copied();
138 path.0 = entering.into_iter().chain(route).collect();
139 }
140 }
141 }
142
143 #[allow(clippy::too_many_arguments, clippy::type_complexity)]
144 fn bite(
145 mut commands: Commands,
146 tick: Res<SimTick>,
147 item_assets: Res<ItemAssets>,
148 mut log: ResMut<ColonyLog>,
149 mut losses: ResMut<Losses>,
150 mut reservations: ResMut<Reservations>,
151 mut items: ItemQuery,
152 mut wolves: Query<(&TilePos, &mut Predator), With<Animal>>,
153 mut colonists: Query<
154 (&Colonist, &TilePos, &mut Health, &Carrying),
155 (Without<Animal>, Without<Predator>),
156 >,
157 mut prey: Query<(&TilePos, &mut Health), (With<Animal>, Without<Predator>, Without<Colonist>)>,
158 ) {
159 for (tile, mut predator) in &mut wolves {
160 predator.cooldown = predator.cooldown.saturating_sub(1);
161 let Some(target) = predator.target else {
162 continue;
163 };
164 if predator.cooldown > 0 {
165 continue;
166 }
167 let near = |other: &TilePos| {
168 other.0 == tile.0 || is_adjacent(other.0.as_ivec2(), tile.0.as_ivec2())
169 };
170 if let Ok((colonist, pos, mut health, carrying)) = colonists.get_mut(target) {
171 if !near(pos) {
172 continue;
173 }
174 predator.cooldown = BITE_COOLDOWN_TICKS;
175 if health.0 >= 1.0 {
176 log.push(*tick, format!("{} was attacked by a wolf", colonist.name));
177 }
178 health.0 -= COLONIST_BITE;
179 if health.0 <= 0.0 {
180 if let Some(load) = carrying.0 {
181 drop_items(&mut commands, &mut items, &item_assets, pos.0, load);
182 }
183 reservations.release_all(target);
184 commands.entity(target).despawn();
185 losses.0.push(tick.0);
186 log.push(*tick, format!("{} was killed by a wolf", colonist.name));
187 predator.fed_until = tick.0 + FED_TICKS;
188 predator.target = None;
189 }
190 } else if let Ok((pos, mut health)) = prey.get_mut(target) {
191 if !near(pos) {
192 continue;
193 }
194 predator.cooldown = BITE_COOLDOWN_TICKS;
195 health.0 -= ANIMAL_BITE;
196 if health.0 <= 0.0 {
197 reservations.release(Target::Animal(target));
198 commands.entity(target).despawn();
199 predator.fed_until = tick.0 + FED_TICKS;
200 predator.target = None;
201 }
202 } else {
203 predator.target = None;
204 }
205 }
206 }
207
208 pub fn healed(health: f32, ticks: f32, in_bed: bool, tended: f32) -> f32 {
209 let rate = HEAL_PER_DAY / (24.0 * TICKS_PER_HOUR as f32);
210 let rate = if in_bed { rate * BED_HEAL_FACTOR } else { rate };
211 let rate = rate * (1.0 + TENDED_HEAL_BONUS * tended);
212 (health + rate * ticks).min(1.0)
213 }
214
215 #[allow(clippy::type_complexity)]
216 fn heal(
217 tick: Res<SimTick>,
218 structures: Query<(&TilePos, &Structure), Without<Health>>,
219 mut patients: Query<(
220 &mut Health,
221 &TilePos,
222 Option<(&CurrentJob, &Path)>,
223 Option<&Tended>,
224 )>,
225 ) {
226 if !tick.0.is_multiple_of(HEAL_INTERVAL_TICKS) {
227 return;
228 }
229 let beds: HashSet<UVec2> = structures
230 .iter()
231 .filter(|(_, s)| s.kind == StructureKind::Bed)
232 .map(|(pos, _)| pos.0)
233 .collect();
234 for (mut health, tile, job, tended) in &mut patients {
235 if health.0 >= 1.0 {
236 continue;
237 }
238 let in_bed =
239 job.is_some_and(|(job, path)| is_sleeping(job, tile, path) && beds.contains(&tile.0));
240 let tended = tended
241 .filter(|t| tick.0 < t.until)
242 .map_or(0.0, |t| t.quality);
243 health.0 = healed(health.0, HEAL_INTERVAL_TICKS as f32, in_bed, tended);
244 }
245 }
246
247 #[cfg(test)]
248 mod tests {
249 use super::*;
250
251 // Wolves go for the nearest meal but would rather take an animal than a person, unless the
252 // person is much closer.
253 #[test]
254 fn wolves_prefer_animals_to_people() {
255 let e = |i: u32| Entity::from_raw_u32(i).unwrap();
256 let deer = Prey {
257 entity: e(1),
258 tile: UVec2::new(10, 0),
259 colonist: false,
260 };
261 let person = Prey {
262 entity: e(2),
263 tile: UVec2::new(8, 0),
264 colonist: true,
265 };
266 assert_eq!(pick_prey(UVec2::ZERO, &[deer, person]), Some(deer));
267 let close = Prey {
268 tile: UVec2::new(2, 0),
269 ..person
270 };
271 assert_eq!(pick_prey(UVec2::ZERO, &[deer, close]), Some(close));
272 let far = Prey {
273 tile: UVec2::new(PROWL_RANGE as u32 + 1, 0),
274 ..deer
275 };
276 assert_eq!(pick_prey(UVec2::ZERO, &[far]), None);
277 }
278
279 #[test]
280 fn wounds_heal_slowly_and_faster_in_bed() {
281 let day = 24.0 * TICKS_PER_HOUR as f32;
282 assert!((healed(0.5, day, false, 0.0) - 0.8).abs() < 1e-4);
283 assert_eq!(healed(0.5, day, true, 0.0), 1.0);
284 assert!(healed(0.2, day, false, 1.0) > healed(0.2, day, false, 0.5));
285 assert!(tend_quality(1.0, true) > tend_quality(1.0, false));
286 }
287
288 // A hungry wolf stalks the nearest prey, bites it to death and then rests while it digests.
289 #[test]
290 fn wolves_hunt_down_prey_and_then_rest() {
291 use crate::{
292 animals::{AnimalIdle, AnimalKind},
293 colonist::{MoveSpeed, follow_path},
294 sim::sim_pos,
295 world::Terrain,
296 };
297 let mut app = App::new();
298 app.insert_resource(WorldMap::filled(UVec2::splat(30), Terrain::Grass))
299 .init_resource::<SimTick>()
300 .init_resource::<Time>()
301 .init_resource::<ItemAssets>()
302 .init_resource::<ColonyLog>()
303 .init_resource::<Losses>()
304 .init_resource::<Reservations>()
305 .add_systems(Update, (prowl, follow_path, bite).chain());
306 let spawn = |app: &mut App, kind: AnimalKind, tile: UVec2| {
307 app.world_mut()
308 .spawn((
309 Animal { kind },
310 TilePos(tile),
311 sim_pos(WorldMap::tile_center(tile)),
312 MoveSpeed(3.0),
313 AnimalIdle::default(),
314 ))
315 .id()
316 };
317 let wolf = spawn(&mut app, AnimalKind::Wolf, UVec2::new(2, 2));
318 app.world_mut().entity_mut(wolf).insert(Predator::default());
319 let rabbit = spawn(&mut app, AnimalKind::Rabbit, UVec2::new(12, 2));
320 for t in 0..2000 {
321 app.world_mut().resource_mut::<SimTick>().0 = t;
322 app.world_mut()
323 .resource_mut::<Time>()
324 .advance_by(std::time::Duration::from_secs_f64(1.0 / 60.0));
325 app.update();
326 }
327 assert!(app.world().get_entity(rabbit).is_err());
328 let predator = app.world().get::<Predator>(wolf).unwrap();
329 assert!(predator.fed_until > 0 && predator.target.is_none());
330 }
331 }