use bevy::prelude::*; use crate::{ climate::day_of, colonist::{Colonist, MoveSpeed, Path}, economy::in_colony, items::{Carrying, Item, ItemAssets, ItemQuery, drop_items}, jobs::Reservations, mood::Losses, pathfinding::{find_path, find_path_adjacent_within, is_adjacent}, population::{ColonyLog, edge_tile}, rng::SimRng, sim::{SimSystems, SimTick, TICKS_PER_HOUR, sim_pos}, threats::Health, world::{TILE_SIZE, TilePos, WorldMap}, worldgen::WorldGenerated, zones::Zones, }; pub struct RaidsPlugin; impl Plugin for RaidsPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_systems(Startup, create_raider_assets) .add_systems(Update, clear_raiders.run_if(on_message::)) .add_systems( FixedUpdate, ( raider_plans.in_set(SimSystems::Decide), (raid_arrivals, raider_attacks, raider_deaths) .chain() .in_set(SimSystems::Act), ), ); } } pub const FIRST_RAID_DAY: u64 = 10; pub const RAID_GAP_DAYS: u64 = 6; pub const RAID_CHANCE: f32 = 0.5; const RAID_HOUR_TICK: u64 = 9 * TICKS_PER_HOUR; const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR; pub const RAIDER_HIT: f32 = 0.2; const HIT_COOLDOWN_TICKS: u32 = 60; pub const RAIDER_FLEE_HEALTH: f32 = 0.4; const PLAN_INTERVAL_TICKS: u64 = 20; const RAIDER_SEARCH: usize = 6000; const CLOSE_IN: i32 = 20; const RAIDER_SPEED: f32 = 3.2; const ARRIVAL_ATTEMPTS: usize = 10; #[derive(Component, Debug, Default, Clone, Copy, PartialEq)] #[require(Transform, Visibility, TilePos, Path, Health)] pub struct Raider { pub loot: Option, pub fleeing: bool, pub cooldown: u32, } #[derive(Resource, Default, Debug)] pub struct RaidState { pub last_day: Option, } #[derive(Resource, Default)] pub struct RaiderAssets { body: (Handle, Handle), blade: (Handle, Handle), } fn create_raider_assets( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, ) { commands.insert_resource(RaiderAssets { body: ( meshes.add(Circle::new(TILE_SIZE * 0.33)), materials.add(Color::srgb_u8(110, 20, 20)), ), blade: ( meshes.add(Rectangle::new(TILE_SIZE * 0.08, TILE_SIZE * 0.45)), materials.add(Color::srgb_u8(30, 30, 34)), ), }); } fn clear_raiders(mut commands: Commands, raiders: Query>) { for raider in &raiders { commands.entity(raider).despawn(); } } pub fn raid_size(colonists: usize) -> usize { (2 + colonists / 12).min(8) } pub fn raid_due(day: u64, last: Option) -> bool { day >= FIRST_RAID_DAY && last.is_none_or(|last| day >= last + RAID_GAP_DAYS) } pub fn spawn_raider(commands: &mut Commands, assets: &RaiderAssets, tile: UVec2) -> Entity { let part = |(mesh, material): &(Handle, Handle), at: Transform| { (Mesh2d(mesh.clone()), MeshMaterial2d(material.clone()), at) }; commands .spawn(( Name::new("Raider"), Raider::default(), MoveSpeed(RAIDER_SPEED), TilePos(tile), sim_pos(WorldMap::tile_center(tile)), Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)), children![ part(&assets.body, Transform::default()), part( &assets.blade, Transform::from_xyz(TILE_SIZE * 0.3, 0.0, 0.01) .with_rotation(Quat::from_rotation_z(0.6)), ), ], )) .id() } #[allow(clippy::too_many_arguments)] fn raid_arrivals( mut commands: Commands, tick: Res, map: Res, zones: Res, assets: Res, mut rng: ResMut, mut state: ResMut, mut log: ResMut, colonists: Query<(), With>, ) { if tick.0 % DAY_TICKS != RAID_HOUR_TICK || !zones.has_stockpile() { return; } let day = day_of(*tick); if !raid_due(day, state.last_day) || rng.range_f32(0.0..1.0) >= RAID_CHANCE { return; } let home = zones.home(map.size / 2); let Some((tile, route)) = (0..ARRIVAL_ATTEMPTS).find_map(|_| { let tile = edge_tile(&map, &mut rng)?; find_path(&map, tile, home).map(|route| (tile, route)) }) else { return; }; let size = raid_size(colonists.iter().count()); for _ in 0..size { let raider = spawn_raider(&mut commands, &assets, tile); commands.entity(raider).insert(Path(route.clone().into())); } state.last_day = Some(day); log.push(*tick, format!("{size} raiders are coming!")); } pub fn nearest_edge(map: &WorldMap, tile: UVec2) -> UVec2 { let max = map.size - UVec2::ONE; let options = [ (tile.x, UVec2::new(0, tile.y)), (max.x - tile.x, UVec2::new(max.x, tile.y)), (tile.y, UVec2::new(tile.x, 0)), (max.y - tile.y, UVec2::new(tile.x, max.y)), ]; options .into_iter() .min_by_key(|(d, _)| *d) .map(|(_, t)| t) .unwrap() } fn at_edge(map: &WorldMap, tile: UVec2) -> bool { tile.x == 0 || tile.y == 0 || tile.x == map.size.x - 1 || tile.y == map.size.y - 1 } #[allow(clippy::type_complexity, clippy::too_many_arguments)] fn raider_plans( mut commands: Commands, tick: Res, map: Res, zones: Res, mut log: ResMut, items: Query<(Entity, &Item, &TilePos), Without>, mut raiders: Query<(Entity, &TilePos, &mut Path, &mut Raider, &Health)>, ) { if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) { return; } let home = zones.home(map.size / 2); let mut taken = Vec::new(); for (entity, tile, mut path, mut raider, health) in &mut raiders { if health.0 < RAIDER_FLEE_HEALTH { raider.fleeing = true; } if raider.fleeing || raider.loot.is_some() { if at_edge(&map, tile.0) && path.0.len() <= 1 { let message = match raider.loot { Some(loot) => format!("A raider got away with {} {:?}", loot.count, loot.kind), None => "A raider fled".to_string(), }; log.push(*tick, message); commands.entity(entity).despawn(); continue; } let heading_out = path.0.back().is_some_and(|end| at_edge(&map, *end)); if !heading_out { let exit = nearest_edge(&map, tile.0); if let Some(route) = find_path(&map, tile.0, exit) { path.0 = route.into(); } } continue; } let target = items .iter() .filter(|(e, item, pos)| item.count > 0 && in_colony(home, pos.0) && !taken.contains(e)) .min_by_key(|(_, _, pos)| { let d = pos.0.as_ivec2() - tile.0.as_ivec2(); (d.length_squared(), pos.0.y, pos.0.x) }); let Some((item_entity, item, pos)) = target else { raider.fleeing = true; continue; }; if pos.0 == tile.0 || is_adjacent(pos.0.as_ivec2(), tile.0.as_ivec2()) { raider.loot = Some(*item); taken.push(item_entity); commands.entity(item_entity).despawn(); path.0.clear(); continue; } let end = path.0.back().copied().unwrap_or(tile.0); let far = (pos.0.as_ivec2() - tile.0.as_ivec2()).abs().max_element() > CLOSE_IN; if is_adjacent(end.as_ivec2(), pos.0.as_ivec2()) || (far && !path.0.is_empty()) { continue; } let search = if far { usize::MAX } else { RAIDER_SEARCH }; match find_path_adjacent_within(&map, tile.0, pos.0, search) { Some(route) => path.0 = route.into(), None if path.0.is_empty() => raider.fleeing = true, None => {} } } } #[allow(clippy::type_complexity, clippy::too_many_arguments)] fn raider_attacks( mut commands: Commands, tick: Res, item_assets: Res, mut log: ResMut, mut losses: ResMut, mut reservations: ResMut, mut items: ItemQuery, mut raiders: Query<(&TilePos, &mut Raider), Without>, mut colonists: Query<(Entity, &Colonist, &TilePos, &mut Health, &Carrying), Without>, ) { for (tile, mut raider) in &mut raiders { raider.cooldown = raider.cooldown.saturating_sub(1); if raider.cooldown > 0 || raider.fleeing { continue; } let victim = colonists.iter_mut().find(|(_, _, pos, health, _)| { health.0 > 0.0 && (pos.0 == tile.0 || is_adjacent(pos.0.as_ivec2(), tile.0.as_ivec2())) }); let Some((entity, colonist, pos, mut health, carrying)) = victim else { continue; }; raider.cooldown = HIT_COOLDOWN_TICKS; health.0 -= RAIDER_HIT; if health.0 <= 0.0 { if let Some(load) = carrying.0 { drop_items(&mut commands, &mut items, &item_assets, pos.0, load); } reservations.release_all(entity); commands.entity(entity).despawn(); losses.0.push(tick.0); log.push(*tick, format!("{} was killed by raiders", colonist.name)); } } } fn raider_deaths( mut commands: Commands, tick: Res, item_assets: Res, mut log: ResMut, mut items: ItemQuery, raiders: Query<(Entity, &TilePos, &Raider, &Health)>, ) { for (entity, tile, raider, health) in &raiders { if health.0 > 0.0 { continue; } if let Some(loot) = raider.loot { drop_items(&mut commands, &mut items, &item_assets, tile.0, loot); } commands.entity(entity).despawn(); log.push(*tick, "A raider was killed".to_string()); } } #[cfg(test)] mod tests { use super::*; // Raids start once the colony has had time to settle, then come at most every few days, // and grow with the colony. #[test] fn raids_wait_for_a_settled_colony_and_grow_with_it() { assert!(!raid_due(FIRST_RAID_DAY - 1, None)); assert!(raid_due(FIRST_RAID_DAY, None)); assert!(!raid_due(20, Some(20 - RAID_GAP_DAYS + 1))); assert!(raid_due(20, Some(20 - RAID_GAP_DAYS))); assert!(raid_size(40) > raid_size(5)); assert_eq!(raid_size(1000), 8); } #[test] fn fleeing_raiders_head_for_the_nearest_map_edge() { let map = WorldMap::filled(UVec2::new(50, 40), crate::world::Terrain::Grass); assert_eq!(nearest_edge(&map, UVec2::new(3, 20)), UVec2::new(0, 20)); assert_eq!(nearest_edge(&map, UVec2::new(25, 37)), UVec2::new(25, 39)); } // A raider goes for the colony's stores, grabs a stack and makes for the nearest map edge, // hitting any colonist standing next to it on the way. #[test] fn raiders_steal_from_stores_and_hit_colonists() { use crate::items::ItemKind; let map = WorldMap::filled(UVec2::new(40, 40), crate::world::Terrain::Grass); let mut zones = Zones::default(); zones.add_tiles(&map, [UVec2::new(20, 20)]); let mut app = App::new(); app.insert_resource(map) .insert_resource(zones) .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_systems(Update, (raider_plans, raider_attacks).chain()); let world = app.world_mut(); world.spawn((Item::new(ItemKind::Gold, 5), TilePos(UVec2::new(20, 20)))); let raider = world .spawn((Raider::default(), TilePos(UVec2::new(21, 20)))) .id(); let victim = world .spawn(( Colonist { name: "Ada".into() }, TilePos(UVec2::new(22, 20)), Carrying::default(), )) .id(); app.update(); let state = app.world().get::(raider).unwrap(); assert_eq!(state.loot.map(|l| l.count), Some(5)); assert!(app.world().get::(victim).unwrap().0 < 1.0); app.update(); let path = &app.world().get::(raider).unwrap().0; assert!( path.back() .is_some_and(|end| end.x == 0 || end.x == 39 || end.y == 0 || end.y == 39) ); } }