use crate::data::{AbilityData, ActionMode, AgentData, AttackData, Path, ReadData, TargetData}; use common::{ comp::{ ability::AbilityInput, agent::Psyche, buff::BuffKind, item::{tool::AbilityContext, ItemDesc, ItemTag}, Agent, Alignment, Body, Controller, InputKind, Pos, Scale, }, consts::GRAVITY, terrain::Block, uid::Uid, util::Dir, vol::ReadVol, }; use core::f32::consts::PI; use rand::Rng; use specs::Entity as EcsEntity; use vek::*; pub fn is_dead_or_invulnerable(entity: EcsEntity, read_data: &ReadData) -> bool { is_dead(entity, read_data) || is_invulnerable(entity, read_data) } pub fn is_dead(entity: EcsEntity, read_data: &ReadData) -> bool { let health = read_data.healths.get(entity); health.map_or(false, |a| a.is_dead) } // FIXME: The logic that is used in this function and throughout the code // shouldn't be used to mean that a character is in a safezone. pub fn is_invulnerable(entity: EcsEntity, read_data: &ReadData) -> bool { let buffs = read_data.buffs.get(entity); buffs.map_or(false, |b| b.kinds[BuffKind::Invulnerability].is_some()) } /// Gets alignment of owner if alignment given is `Owned`. /// Returns original alignment if not owned. pub fn try_owner_alignment<'a>( alignment: Option<&'a Alignment>, read_data: &'a ReadData, ) -> Option<&'a Alignment> { if let Some(&Alignment::Owned(owner_uid)) = alignment { if let Some(owner) = get_entity_by_id(owner_uid, read_data) { return read_data.alignments.get(owner); } } alignment } /// Projectile motion: Returns the direction to aim for the projectile to reach /// target position. Does not take any forces but gravity into account. pub fn aim_projectile(speed: f32, pos: Vec3, tgt: Vec3) -> Option { let mut to_tgt = tgt - pos; let dist_sqrd = to_tgt.xy().magnitude_squared(); let u_sqrd = speed.powi(2); to_tgt.z = (u_sqrd - (u_sqrd.powi(2) - GRAVITY * (GRAVITY * dist_sqrd + 2.0 * to_tgt.z * u_sqrd)) .sqrt() .max(0.0)) / GRAVITY; Dir::from_unnormalized(to_tgt) } pub fn get_entity_by_id(uid: Uid, read_data: &ReadData) -> Option { read_data.id_maps.uid_entity(uid) } /// Calculates whether the agent should continue chase or let the target escape. /// /// Will return true when score of letting target escape is higher then the /// score of continuing the pursue, false otherwise. pub fn stop_pursuing( dist_to_target_sqrd: f32, dist_to_home_sqrd: f32, own_health_fraction: f32, target_health_fraction: f32, dur_since_last_attacked: f64, psyche: &Psyche, ) -> bool { should_let_target_escape( dist_to_home_sqrd, dur_since_last_attacked, own_health_fraction, ) > should_continue_to_pursue(dist_to_target_sqrd, psyche, target_health_fraction) } /// Scores the benefit of continuing the pursue in value from 0 to infinity. fn should_continue_to_pursue( dist_to_target_sqrd: f32, psyche: &Psyche, target_health_fraction: f32, ) -> f32 { let aggression_score = (1.0 / psyche.flee_health.max(0.25)) * psyche.aggro_dist.unwrap_or(psyche.sight_dist) * psyche.sight_dist; (100.0 * aggression_score) / (dist_to_target_sqrd * target_health_fraction) } /// Scores the benefit of letting the target escape in a value from 0 to /// infinity. fn should_let_target_escape( dist_to_home_sqrd: f32, dur_since_last_attacked: f64, own_health_fraction: f32, ) -> f32 { (dist_to_home_sqrd / own_health_fraction) * dur_since_last_attacked as f32 * 0.005 } pub fn entity_looks_like_cultist(entity: EcsEntity, read_data: &ReadData) -> bool { let number_of_cultist_items_equipped = read_data.inventories.get(entity).map_or(0, |inv| { inv.equipped_items() .filter(|item| item.tags().contains(&ItemTag::Cultist)) .count() }); number_of_cultist_items_equipped > 2 } // FIXME: `Alignment::Npc` doesn't necessarily mean villager. pub fn is_villager(alignment: Option<&Alignment>) -> bool { alignment.map_or(false, |alignment| matches!(alignment, Alignment::Npc)) } pub fn is_village_guard(entity: EcsEntity, read_data: &ReadData) -> bool { read_data .stats .get(entity) .map_or(false, |stats| stats.name == "Guard") } pub fn are_our_owners_hostile( our_alignment: Option<&Alignment>, their_alignment: Option<&Alignment>, read_data: &ReadData, ) -> bool { try_owner_alignment(our_alignment, read_data).map_or(false, |our_owners_alignment| { try_owner_alignment(their_alignment, read_data).map_or(false, |their_owners_alignment| { our_owners_alignment.hostile_towards(*their_owners_alignment) }) }) } pub fn entities_have_line_of_sight( pos: &Pos, body: Option<&Body>, scale: f32, other_pos: &Pos, other_body: Option<&Body>, other_scale: Option<&Scale>, read_data: &ReadData, ) -> bool { let get_eye_pos = |pos: &Pos, body: Option<&Body>, scale: f32| { let eye_offset = body.map_or(0.0, |b| b.eye_height(scale)); Pos(pos.0.with_z(pos.0.z + eye_offset)) }; let eye_pos = get_eye_pos(pos, body, scale); let other_eye_pos = get_eye_pos(other_pos, other_body, other_scale.map_or(1.0, |s| s.0)); positions_have_line_of_sight(&eye_pos, &other_eye_pos, read_data) } pub fn positions_have_line_of_sight(pos_a: &Pos, pos_b: &Pos, read_data: &ReadData) -> bool { let dist_sqrd = pos_b.0.distance_squared(pos_a.0); read_data .terrain .ray(pos_a.0, pos_b.0) .until(Block::is_opaque) .cast() .0 .powi(2) >= (dist_sqrd - 0.01) } pub fn is_dressed_as_cultist(entity: EcsEntity, read_data: &ReadData) -> bool { read_data .inventories .get(entity) .map_or(false, |inventory| { inventory .equipped_items() .filter(|item| item.tags().contains(&ItemTag::Cultist)) .count() > 2 }) } pub fn get_attacker(entity: EcsEntity, read_data: &ReadData) -> Option { read_data .healths .get(entity) .filter(|health| health.last_change.amount < 0.0) .and_then(|health| health.last_change.damage_by()) .and_then(|damage_contributor| get_entity_by_id(damage_contributor.uid(), read_data)) } impl<'a> AgentData<'a> { pub fn has_buff(&self, read_data: &ReadData, buff: BuffKind) -> bool { read_data .buffs .get(*self.entity) .map_or(false, |b| b.kinds[buff].is_some()) } pub fn extract_ability(&self, input: AbilityInput) -> Option { let context = AbilityContext::from(self.stance, Some(self.inventory), self.combo); AbilityData::from_ability( &self .active_abilities .activate_ability( input, Some(self.inventory), self.skill_set, self.body, Some(self.char_state), &context, ) .map_or(Default::default(), |a| a.0), ) } } // Probably works best for melee (or maybe only for melee considering its // reliance on blocking?) /// Handles whether an agent should attack and how the agent moves around. /// Returns whether the agent should attack (so that individual tactics can /// determine what specific attack to use) pub fn handle_attack_aggression( agent_data: &AgentData, agent: &mut Agent, controller: &mut Controller, attack_data: &AttackData, tgt_data: &TargetData, read_data: &ReadData, rng: &mut impl Rng, timer_pos_timeout_index: usize, timer_guarded_cycle_index: usize, fcounter_guarded_timer_index: usize, icounter_action_mode_index: usize, condition_guarded_defend_index: usize, condition_rolling_breakthrough_index: usize, position_guarded_cover_index: usize, position_flee_index: usize, ) -> bool { if let Some(health) = agent_data.health { agent.combat_state.int_counters[icounter_action_mode_index] = if health.fraction() < 0.1 { agent.combat_state.positions[position_guarded_cover_index] = None; ActionMode::Fleeing as u8 } else if health.fraction() < 0.9 { agent.combat_state.positions[position_flee_index] = None; ActionMode::Guarded as u8 } else { agent.combat_state.positions[position_guarded_cover_index] = None; agent.combat_state.positions[position_flee_index] = None; ActionMode::Reckless as u8 }; } // If agent has not moved, assume agent was unable to move and reset attempted // path positions if occurs for too long if agent_data.vel.0.magnitude_squared() < 1_f32.powi(2) { agent.combat_state.timers[timer_pos_timeout_index] += read_data.dt.0; } else { agent.combat_state.timers[timer_pos_timeout_index] = 0.0; } if agent.combat_state.timers[timer_pos_timeout_index] > 2.0 { agent.combat_state.positions[position_guarded_cover_index] = None; agent.combat_state.positions[position_flee_index] = None; agent.combat_state.timers[timer_pos_timeout_index] = 0.0; } match ActionMode::from_u8(agent.combat_state.int_counters[icounter_action_mode_index]) { ActionMode::Reckless => true, ActionMode::Guarded => { agent.combat_state.timers[timer_guarded_cycle_index] += read_data.dt.0; if agent.combat_state.timers[timer_guarded_cycle_index] > agent.combat_state.counters[fcounter_guarded_timer_index] { agent.combat_state.timers[timer_guarded_cycle_index] = 0.0; agent.combat_state.conditions[condition_guarded_defend_index] ^= true; agent.combat_state.counters[fcounter_guarded_timer_index] = if agent.combat_state.conditions[condition_guarded_defend_index] { rng.gen_range(3.0..6.0) } else { rng.gen_range(6.0..10.0) }; } if let Some(pos) = agent.combat_state.positions[position_guarded_cover_index] { if pos.distance_squared(agent_data.pos.0) < 3_f32.powi(2) { agent.combat_state.positions[position_guarded_cover_index] = None; } } if !agent.combat_state.conditions[condition_guarded_defend_index] { agent.combat_state.positions[position_guarded_cover_index] = None; true } else { if attack_data.dist_sqrd > 10_f32.powi(2) { // Choose random point to either side when looking at target and move // towards it if let Some(pos) = agent.combat_state.positions[position_guarded_cover_index] { if pos.distance_squared(agent_data.pos.0) < 5_f32.powi(2) { agent.combat_state.positions[position_guarded_cover_index] = None; } agent_data.path_toward_target( agent, controller, pos, read_data, Path::Separate, None, ); } else { agent.combat_state.positions[position_guarded_cover_index] = { let rand_dir = { let dir = (tgt_data.pos.0 - agent_data.pos.0) .try_normalized() .unwrap_or(Vec3::unit_x()) .xy(); if rng.gen_bool(0.5) { dir.rotated_z(PI / 2.0 + rng.gen_range(-0.75..0.0)) } else { dir.rotated_z(-PI / 2.0 + rng.gen_range(-0.0..0.75)) } }; let attempted_dist = rng.gen_range(6.0..16.0); let actual_dist = read_data .terrain .ray( agent_data.pos.0 + Vec3::unit_z() * 0.5, agent_data.pos.0 + Vec3::unit_z() * 0.5 + rand_dir * attempted_dist, ) .until(Block::is_solid) .cast() .0 - 1.0; Some(agent_data.pos.0 + rand_dir * actual_dist) }; } } else if let Some(pos) = agent.combat_state.positions[position_guarded_cover_index] { agent_data.path_toward_target( agent, controller, pos, read_data, Path::Separate, None, ); if agent.combat_state.conditions[condition_rolling_breakthrough_index] { controller.push_basic_input(InputKind::Roll); agent.combat_state.conditions[condition_rolling_breakthrough_index] = false; } if tgt_data.char_state.map_or(false, |cs| cs.is_melee_attack()) { controller.push_basic_input(InputKind::Block); } } else { agent.combat_state.positions[position_guarded_cover_index] = { let backwards = (agent_data.pos.0 - tgt_data.pos.0) .try_normalized() .unwrap_or(Vec3::unit_x()) .xy(); let pos = if read_data .terrain .ray( agent_data.pos.0 + Vec3::unit_z() * 0.5, agent_data.pos.0 + Vec3::unit_z() * 0.5 + backwards * 6.0, ) .until(Block::is_solid) .cast() .0 > 5.0 { agent_data.pos.0 + backwards * 5.0 } else { agent.combat_state.conditions[condition_rolling_breakthrough_index] = true; agent_data.pos.0 - backwards * read_data .terrain .ray( agent_data.pos.0 + Vec3::unit_z() * 0.5, agent_data.pos.0 + Vec3::unit_z() * 0.5 - backwards * 10.0, ) .until(Block::is_solid) .cast() .0 - 1.0 }; Some(pos) } } false } }, ActionMode::Fleeing => { if agent.combat_state.conditions[condition_rolling_breakthrough_index] { controller.push_basic_input(InputKind::Roll); agent.combat_state.conditions[condition_rolling_breakthrough_index] = false; } if let Some(pos) = agent.combat_state.positions[position_flee_index] { if let Some(dir) = Dir::from_unnormalized(pos - agent_data.pos.0) { controller.inputs.look_dir = dir; } if pos.distance_squared(agent_data.pos.0) < 5_f32.powi(2) { agent.combat_state.positions[position_flee_index] = None; } agent_data.path_toward_target( agent, controller, pos, read_data, Path::Separate, None, ); } else { agent.combat_state.positions[position_flee_index] = { let rand_dir = { let dir = (agent_data.pos.0 - tgt_data.pos.0) .try_normalized() .unwrap_or(Vec3::unit_x()) .xy(); dir.rotated_z(rng.gen_range(-0.75..0.75)) }; let attempted_dist = rng.gen_range(16.0..26.0); let actual_dist = read_data .terrain .ray( agent_data.pos.0 + Vec3::unit_z() * 0.5, agent_data.pos.0 + Vec3::unit_z() * 0.5 + rand_dir * attempted_dist, ) .until(Block::is_solid) .cast() .0 - 1.0; if actual_dist < 10.0 { let dist = read_data .terrain .ray( agent_data.pos.0 + Vec3::unit_z() * 0.5, agent_data.pos.0 + Vec3::unit_z() * 0.5 - rand_dir * attempted_dist, ) .until(Block::is_solid) .cast() .0 - 1.0; agent.combat_state.conditions[condition_rolling_breakthrough_index] = true; Some(agent_data.pos.0 - rand_dir * dist) } else { Some(agent_data.pos.0 + rand_dir * actual_dist) } }; } false }, } }