Add wild animals and hunting
- 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 | added | +325 -0 |
| src/colonist.rs | +7 -3 | |
| src/jobs/mod.rs | +101 -2 | |
| src/jobs/tests.rs | +51 -0 | |
| src/main.rs | +2 -0 | |
| src/selection.rs | +13 -0 |
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 | } |