From 525630c37a67ec92859d61393b73c37340f52575 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 15 Sep 2022 20:29:12 -0400 Subject: [PATCH] Moved some agent code into separate crate to facilitate hot reloading of that agent code. --- Cargo.lock | 27 + server-cli/src/main.rs | 5 + server/Cargo.toml | 3 + server/agent/Cargo.toml | 22 + server/agent/dyn/Cargo.toml | 14 + server/agent/dyn/src/lib.rs | 14 + server/agent/src/action_nodes.rs | 1506 ++++++++++++++++ server/{src/sys/agent => agent/src}/attack.rs | 7 +- server/{src/sys/agent => agent/src}/consts.rs | 0 server/{src/sys/agent => agent/src}/data.rs | 3 - server/agent/src/lib.rs | 27 + server/{src/sys/agent => agent/src}/util.rs | 20 +- server/dynlib/Cargo.toml | 11 + server/dynlib/src/lib.rs | 272 +++ server/src/sys/agent.rs | 1532 +---------------- server/src/sys/agent/behavior_tree.rs | 76 +- .../sys/agent/behavior_tree/interaction.rs | 8 +- 17 files changed, 1991 insertions(+), 1556 deletions(-) create mode 100644 server/agent/Cargo.toml create mode 100644 server/agent/dyn/Cargo.toml create mode 100644 server/agent/dyn/src/lib.rs create mode 100644 server/agent/src/action_nodes.rs rename server/{src/sys/agent => agent/src}/attack.rs (99%) rename server/{src/sys/agent => agent/src}/consts.rs (100%) rename server/{src/sys/agent => agent/src}/data.rs (97%) create mode 100644 server/agent/src/lib.rs rename server/{src/sys/agent => agent/src}/util.rs (99%) create mode 100644 server/dynlib/Cargo.toml create mode 100644 server/dynlib/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c992deeae9..133aabf2d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6809,9 +6809,26 @@ dependencies = [ "veloren-common-systems", "veloren-network", "veloren-plugin-api", + "veloren-server-agent", "veloren-world", ] +[[package]] +name = "veloren-server-agent" +version = "0.1.0" +dependencies = [ + "itertools", + "lazy_static", + "rand 0.8.5", + "specs", + "tracing", + "vek 0.15.8", + "veloren-common", + "veloren-common-base", + "veloren-common-ecs", + "veloren-server-dynlib", +] + [[package]] name = "veloren-server-cli" version = "0.13.0" @@ -6836,6 +6853,16 @@ dependencies = [ "veloren-server", ] +[[package]] +name = "veloren-server-dynlib" +version = "0.1.0" +dependencies = [ + "find_folder", + "libloading 0.7.3", + "notify", + "tracing", +] + [[package]] name = "veloren-voxygen" version = "0.13.0" diff --git a/server-cli/src/main.rs b/server-cli/src/main.rs index 78b8ff89f2..16742e2c4f 100644 --- a/server-cli/src/main.rs +++ b/server-cli/src/main.rs @@ -87,6 +87,11 @@ fn main() -> io::Result<()> { .unwrap(), ); + #[cfg(feature = "hot-agent")] + { + agent::init(); + } + // Load server settings let mut server_settings = server::Settings::load(&server_data_dir); let mut editable_settings = server::EditableSettings::load(&server_data_dir); diff --git a/server/Cargo.toml b/server/Cargo.toml index 0029f8ed14..e17f640c38 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,6 +10,7 @@ simd = ["vek/platform_intrinsics"] plugins = ["common-state/plugins"] persistent_world = [] hot-reloading = ["common/hot-reloading"] +hot-agent = ["server-agent/use-dyn-lib"] default = ["worldgen", "plugins", "persistent_world", "simd"] @@ -23,6 +24,8 @@ common-net = { package = "veloren-common-net", path = "../common/net" } world = { package = "veloren-world", path = "../world" } network = { package = "veloren-network", path = "../network", features = ["metrics", "compression", "quic"], default-features = false } +server-agent = {package = "veloren-server-agent", path = "agent"} + #inline_tweak = "1.0.8" specs = { version = "0.18", features = ["shred-derive"] } diff --git a/server/agent/Cargo.toml b/server/agent/Cargo.toml new file mode 100644 index 0000000000..1261d0f8cb --- /dev/null +++ b/server/agent/Cargo.toml @@ -0,0 +1,22 @@ +[package] +authors = ["Samuel Keiffer "] +name = "veloren-server-agent" +edition = "2021" +version = "0.1.0" + +[features] +use-dyn-lib = ["server-dynlib"] +be-dyn-lib = [] + +[dependencies] +common = {package = "veloren-common", path = "../../common"} +common-base = { package = "veloren-common-base", path = "../../common/base" } +common-ecs = { package = "veloren-common-ecs", path = "../../common/ecs" } +server-dynlib = {package = "veloren-server-dynlib", path = "../dynlib", optional = true} + +specs = { version = "0.18", features = ["shred-derive"] } +vek = { version = "0.15.8", features = ["serde"] } +rand = { version = "0.8", features = ["small_rng"] } +tracing = "0.1" +itertools = "0.10" +lazy_static = "1.4.0" diff --git a/server/agent/dyn/Cargo.toml b/server/agent/dyn/Cargo.toml new file mode 100644 index 0000000000..7fdb99fa2d --- /dev/null +++ b/server/agent/dyn/Cargo.toml @@ -0,0 +1,14 @@ +[package] +authors = ["Samuel Keiffer "] +edition = "2021" +name = "veloren-server-agent-dyn" +version = "0.1.0" + +[lib] +crate-type = ["dylib"] + +[features] +be-dyn-lib = ["veloren-server-agent/be-dyn-lib"] + +[dependencies] +veloren-server-agent = { path = "../" } diff --git a/server/agent/dyn/src/lib.rs b/server/agent/dyn/src/lib.rs new file mode 100644 index 0000000000..eac9e15708 --- /dev/null +++ b/server/agent/dyn/src/lib.rs @@ -0,0 +1,14 @@ +//! This crate hacks around the inability to dynamically specify the +//! `crate-type` for cargo to build. +//! +//! For more details on the issue this is a decent starting point: https://github.com/rust-lang/cargo/pull/8789 +//! +//! This crate avoids use building the dynamic lib when it isn't needed and the +//! same with the non dynamic build. Additionally, this allows compilation to +//! start earlier since a cdylib doesn't pipeline with it's dependencies. +//! +//! NOTE: the `be-dyn-lib` feature must be used for this crate to be useful, it +//! is not on by default because this causes cargo to switch the feature on in +//! the anim crate when compiling the static lib into voxygen. +#[cfg(feature = "be-dyn-lib")] +pub use veloren_server_agent::*; diff --git a/server/agent/src/action_nodes.rs b/server/agent/src/action_nodes.rs new file mode 100644 index 0000000000..d34f594440 --- /dev/null +++ b/server/agent/src/action_nodes.rs @@ -0,0 +1,1506 @@ +use crate::{ + consts::{ + AVG_FOLLOW_DIST, DEFAULT_ATTACK_RANGE, IDLE_HEALING_ITEM_THRESHOLD, PARTIAL_PATH_DIST, + SEPARATION_BIAS, SEPARATION_DIST, + }, + data::{AgentData, AttackData, Path, ReadData, Tactic, TargetData}, + util::{ + aim_projectile, are_our_owners_hostile, entities_have_line_of_sight, get_attacker, + get_entity_by_id, is_dead_or_invulnerable, is_dressed_as_cultist, is_invulnerable, + is_village_guard, is_villager, + }, +}; +use common::{ + combat::perception_dist_multiplier_from_stealth, + comp::{ + self, + agent::{Sound, SoundKind, Target}, + buff::BuffKind, + inventory::slot::EquipSlot, + item::{ + tool::{AbilitySpec, ToolKind}, + ConsumableKind, Item, ItemDesc, ItemKind, + }, + item_drop, + projectile::ProjectileConstructor, + Agent, Alignment, Body, CharacterState, ControlAction, ControlEvent, Controller, + HealthChange, InputKind, InventoryAction, Pos, UnresolvedChatMsg, UtteranceKind, + }, + effect::{BuffEffect, Effect}, + event::{Emitter, ServerEvent}, + path::TraversalConfig, + states::basic_beam, + terrain::{Block, TerrainGrid}, + time::DayPeriod, + util::Dir, + vol::ReadVol, +}; +use itertools::Itertools; +use rand::{thread_rng, Rng}; +use specs::Entity as EcsEntity; +use vek::*; + +impl<'a> AgentData<'a> { + //////////////////////////////////////// + // Action Nodes + //////////////////////////////////////// + + pub fn glider_fall(&self, controller: &mut Controller) { + controller.push_action(ControlAction::GlideWield); + + let flight_direction = + Vec3::from(self.vel.0.xy().try_normalized().unwrap_or_else(Vec2::zero)); + let flight_ori = Quaternion::from_scalar_and_vec3((1.0, flight_direction)); + + let ori = self.ori.look_vec(); + let look_dir = if ori.z > 0.0 { + flight_ori.rotated_x(-0.1) + } else { + flight_ori.rotated_x(0.1) + }; + + let (_, look_dir) = look_dir.into_scalar_and_vec3(); + controller.inputs.look_dir = Dir::from_unnormalized(look_dir).unwrap_or_else(Dir::forward); + } + + pub fn fly_upward(&self, controller: &mut Controller) { + controller.push_basic_input(InputKind::Fly); + controller.inputs.move_z = 1.0; + } + + /// Directs the entity to path and move toward the target + /// If path is not Full, the entity will path to a location 50 units along + /// the vector between the entity and the target. The speed multiplier + /// multiplies the movement speed by a value less than 1.0. + /// A `None` value implies a multiplier of 1.0. + /// Returns `false` if the pathfinding algorithm fails to return a path + pub fn path_toward_target( + &self, + agent: &mut Agent, + controller: &mut Controller, + tgt_pos: Vec3, + read_data: &ReadData, + path: Path, + speed_multiplier: Option, + ) -> bool { + let partial_path_tgt_pos = |pos_difference: Vec3| { + self.pos.0 + + PARTIAL_PATH_DIST * pos_difference.try_normalized().unwrap_or_else(Vec3::zero) + }; + let pos_difference = tgt_pos - self.pos.0; + let pathing_pos = match path { + Path::Separate => { + let mut sep_vec: Vec3 = Vec3::::zero(); + + for entity in read_data + .cached_spatial_grid + .0 + .in_circle_aabr(self.pos.0.xy(), SEPARATION_DIST) + { + if let (Some(alignment), Some(other_alignment)) = + (self.alignment, read_data.alignments.get(entity)) + { + if Alignment::passive_towards(*alignment, *other_alignment) { + if let (Some(pos), Some(body), Some(other_body)) = ( + read_data.positions.get(entity), + self.body, + read_data.bodies.get(entity), + ) { + let dist_xy = self.pos.0.xy().distance(pos.0.xy()); + let spacing = body.spacing_radius() + other_body.spacing_radius(); + if dist_xy < spacing { + let pos_diff = self.pos.0.xy() - pos.0.xy(); + sep_vec += pos_diff.try_normalized().unwrap_or_else(Vec2::zero) + * ((spacing - dist_xy) / spacing); + } + } + } + } + } + partial_path_tgt_pos( + sep_vec * SEPARATION_BIAS + pos_difference * (1.0 - SEPARATION_BIAS), + ) + }, + Path::Full => tgt_pos, + Path::Partial => partial_path_tgt_pos(pos_difference), + }; + let speed_multiplier = speed_multiplier.unwrap_or(1.0).min(1.0); + if let Some((bearing, speed)) = agent.chaser.chase( + &*read_data.terrain, + self.pos.0, + self.vel.0, + pathing_pos, + TraversalConfig { + min_tgt_dist: 0.25, + ..self.traversal_config + }, + ) { + controller.inputs.move_dir = + bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) * speed * speed_multiplier; + self.jump_if(bearing.z > 1.5, controller); + controller.inputs.move_z = bearing.z; + true + } else { + false + } + } + + pub fn jump_if(&self, condition: bool, controller: &mut Controller) { + if condition { + controller.push_basic_input(InputKind::Jump); + } else { + controller.push_cancel_input(InputKind::Jump) + } + } + + pub fn idle( + &self, + agent: &mut Agent, + controller: &mut Controller, + read_data: &ReadData, + rng: &mut impl Rng, + ) { + // Light lanterns at night + // TODO Add a method to turn on NPC lanterns underground + let lantern_equipped = self + .inventory + .equipped(EquipSlot::Lantern) + .as_ref() + .map_or(false, |item| { + matches!(&*item.kind(), comp::item::ItemKind::Lantern(_)) + }); + let lantern_turned_on = self.light_emitter.is_some(); + let day_period = DayPeriod::from(read_data.time_of_day.0); + // Only emit event for agents that have a lantern equipped + if lantern_equipped && rng.gen_bool(0.001) { + if day_period.is_dark() && !lantern_turned_on { + // Agents with turned off lanterns turn them on randomly once it's + // nighttime and keep them on. + // Only emit event for agents that sill need to + // turn on their lantern. + controller.push_event(ControlEvent::EnableLantern) + } else if lantern_turned_on && day_period.is_light() { + // agents with turned on lanterns turn them off randomly once it's + // daytime and keep them off. + controller.push_event(ControlEvent::DisableLantern) + } + }; + + if let Some(body) = self.body { + let attempt_heal = if matches!(body, Body::Humanoid(_)) { + self.damage < IDLE_HEALING_ITEM_THRESHOLD + } else { + true + }; + if attempt_heal && self.heal_self(agent, controller, true) { + agent.action_state.timer = 0.01; + return; + } + } else { + agent.action_state.timer = 0.01; + return; + } + + agent.action_state.timer = 0.0; + if let Some((travel_to, _destination)) = &agent.rtsim_controller.travel_to { + // If it has an rtsim destination and can fly, then it should. + // If it is flying and bumps something above it, then it should move down. + if self.traversal_config.can_fly + && !read_data + .terrain + .ray(self.pos.0, self.pos.0 + (Vec3::unit_z() * 3.0)) + .until(Block::is_solid) + .cast() + .1 + .map_or(true, |b| b.is_some()) + { + controller.push_basic_input(InputKind::Fly); + } else { + controller.push_cancel_input(InputKind::Fly) + } + + if let Some((bearing, speed)) = agent.chaser.chase( + &*read_data.terrain, + self.pos.0, + self.vel.0, + *travel_to, + TraversalConfig { + min_tgt_dist: 1.25, + ..self.traversal_config + }, + ) { + controller.inputs.move_dir = + bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) + * speed.min(agent.rtsim_controller.speed_factor); + self.jump_if(bearing.z > 1.5 || self.traversal_config.can_fly, controller); + controller.inputs.climb = Some(comp::Climb::Up); + //.filter(|_| bearing.z > 0.1 || self.physics_state.in_liquid().is_some()); + + let height_offset = bearing.z + + if self.traversal_config.can_fly { + // NOTE: costs 4 us (imbris) + let obstacle_ahead = read_data + .terrain + .ray( + self.pos.0 + Vec3::unit_z(), + self.pos.0 + + bearing.try_normalized().unwrap_or_else(Vec3::unit_y) * 80.0 + + Vec3::unit_z(), + ) + .until(Block::is_solid) + .cast() + .1 + .map_or(true, |b| b.is_some()); + + let mut ground_too_close = self + .body + .map(|body| { + #[cfg(feature = "worldgen")] + let height_approx = self.pos.0.z + - read_data + .world + .sim() + .get_alt_approx(self.pos.0.xy().map(|x: f32| x as i32)) + .unwrap_or(0.0); + #[cfg(not(feature = "worldgen"))] + let height_approx = self.pos.0.z; + + height_approx < body.flying_height() + }) + .unwrap_or(false); + + const NUM_RAYS: usize = 5; + + // NOTE: costs 15-20 us (imbris) + for i in 0..=NUM_RAYS { + let magnitude = self.body.map_or(20.0, |b| b.flying_height()); + // Lerp between a line straight ahead and straight down to detect a + // wedge of obstacles we might fly into (inclusive so that both vectors + // are sampled) + if let Some(dir) = Lerp::lerp( + -Vec3::unit_z(), + Vec3::new(bearing.x, bearing.y, 0.0), + i as f32 / NUM_RAYS as f32, + ) + .try_normalized() + { + ground_too_close |= read_data + .terrain + .ray(self.pos.0, self.pos.0 + magnitude * dir) + .until(|b: &Block| b.is_solid() || b.is_liquid()) + .cast() + .1 + .map_or(false, |b| b.is_some()) + } + } + + if obstacle_ahead || ground_too_close { + 5.0 //fly up when approaching obstacles + } else { + -2.0 + } //flying things should slowly come down from the stratosphere + } else { + 0.05 //normal land traveller offset + }; + if let Some(pid) = agent.position_pid_controller.as_mut() { + pid.sp = self.pos.0.z + height_offset * Vec3::unit_z(); + controller.inputs.move_z = pid.calc_err(); + } else { + controller.inputs.move_z = height_offset; + } + // Put away weapon + if rng.gen_bool(0.1) + && matches!( + read_data.char_states.get(*self.entity), + Some(CharacterState::Wielding(_)) + ) + { + controller.push_action(ControlAction::Unwield); + } + } + } else { + agent.bearing += Vec2::new(rng.gen::() - 0.5, rng.gen::() - 0.5) * 0.1 + - agent.bearing * 0.003 + - agent.patrol_origin.map_or(Vec2::zero(), |patrol_origin| { + (self.pos.0 - patrol_origin).xy() * 0.0002 + }); + + // Stop if we're too close to a wall + // or about to walk off a cliff + // NOTE: costs 1 us (imbris) <- before cliff raycast added + agent.bearing *= 0.1 + + if read_data + .terrain + .ray( + self.pos.0 + Vec3::unit_z(), + self.pos.0 + + Vec3::from(agent.bearing) + .try_normalized() + .unwrap_or_else(Vec3::unit_y) + * 5.0 + + Vec3::unit_z(), + ) + .until(Block::is_solid) + .cast() + .1 + .map_or(true, |b| b.is_none()) + && read_data + .terrain + .ray( + self.pos.0 + + Vec3::from(agent.bearing) + .try_normalized() + .unwrap_or_else(Vec3::unit_y), + self.pos.0 + + Vec3::from(agent.bearing) + .try_normalized() + .unwrap_or_else(Vec3::unit_y) + - Vec3::unit_z() * 4.0, + ) + .until(Block::is_solid) + .cast() + .0 + < 3.0 + { + 0.9 + } else { + 0.0 + }; + + if agent.bearing.magnitude_squared() > 0.5f32.powi(2) { + controller.inputs.move_dir = agent.bearing * 0.65; + } + + // Put away weapon + if rng.gen_bool(0.1) + && matches!( + read_data.char_states.get(*self.entity), + Some(CharacterState::Wielding(_)) + ) + { + controller.push_action(ControlAction::Unwield); + } + + if rng.gen::() < 0.0015 { + controller.push_utterance(UtteranceKind::Calm); + } + + // Sit + if rng.gen::() < 0.0035 { + controller.push_action(ControlAction::Sit); + } + } + } + + pub fn follow( + &self, + agent: &mut Agent, + controller: &mut Controller, + terrain: &TerrainGrid, + tgt_pos: &Pos, + ) { + if let Some((bearing, speed)) = agent.chaser.chase( + terrain, + self.pos.0, + self.vel.0, + tgt_pos.0, + TraversalConfig { + min_tgt_dist: AVG_FOLLOW_DIST, + ..self.traversal_config + }, + ) { + let dist_sqrd = self.pos.0.distance_squared(tgt_pos.0); + controller.inputs.move_dir = bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) + * speed.min(0.2 + (dist_sqrd - AVG_FOLLOW_DIST.powi(2)) / 8.0); + self.jump_if(bearing.z > 1.5, controller); + controller.inputs.move_z = bearing.z; + } + } + + pub fn look_toward( + &self, + controller: &mut Controller, + read_data: &ReadData, + target: EcsEntity, + ) -> bool { + if let Some(tgt_pos) = read_data.positions.get(target) { + let eye_offset = self.body.map_or(0.0, |b| b.eye_height()); + let tgt_eye_offset = read_data.bodies.get(target).map_or(0.0, |b| b.eye_height()); + if let Some(dir) = Dir::from_unnormalized( + Vec3::new(tgt_pos.0.x, tgt_pos.0.y, tgt_pos.0.z + tgt_eye_offset) + - Vec3::new(self.pos.0.x, self.pos.0.y, self.pos.0.z + eye_offset), + ) { + controller.inputs.look_dir = dir; + } + true + } else { + false + } + } + + pub fn flee( + &self, + agent: &mut Agent, + controller: &mut Controller, + tgt_pos: &Pos, + terrain: &TerrainGrid, + ) { + if let Some(body) = self.body { + if body.can_strafe() && !self.is_gliding { + controller.push_action(ControlAction::Unwield); + } + } + + if let Some((bearing, speed)) = agent.chaser.chase( + terrain, + self.pos.0, + self.vel.0, + // Away from the target (ironically) + self.pos.0 + + (self.pos.0 - tgt_pos.0) + .try_normalized() + .unwrap_or_else(Vec3::unit_y) + * 50.0, + TraversalConfig { + min_tgt_dist: 1.25, + ..self.traversal_config + }, + ) { + controller.inputs.move_dir = + bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) * speed; + self.jump_if(bearing.z > 1.5, controller); + controller.inputs.move_z = bearing.z; + } + } + + /// Attempt to consume a healing item, and return whether any healing items + /// were queued. Callers should use this to implement a delay so that + /// the healing isn't interrupted. If `relaxed` is `true`, we allow eating + /// food and prioritise healing. + pub fn heal_self( + &self, + _agent: &mut Agent, + controller: &mut Controller, + relaxed: bool, + ) -> bool { + let healing_value = |item: &Item| { + let mut value = 0.0; + + if let ItemKind::Consumable { kind, effects, .. } = &*item.kind() { + if matches!(kind, ConsumableKind::Drink) + || (relaxed && matches!(kind, ConsumableKind::Food)) + { + for effect in effects.iter() { + use BuffKind::*; + match effect { + Effect::Health(HealthChange { amount, .. }) => { + value += *amount; + }, + Effect::Buff(BuffEffect { kind, data, .. }) + if matches!(kind, Regeneration | Saturation | Potion) => + { + value += data.strength + * data.duration.map_or(0.0, |d| d.as_secs() as f32); + }, + _ => {}, + } + } + } + } + value as i32 + }; + + let item = self + .inventory + .slots_with_id() + .filter_map(|(id, slot)| match slot { + Some(item) if healing_value(item) > 0 => Some((id, item)), + _ => None, + }) + .max_by_key(|(_, item)| { + if relaxed { + -healing_value(item) + } else { + healing_value(item) + } + }); + + if let Some((id, _)) = item { + use comp::inventory::slot::Slot; + controller.push_action(ControlAction::InventoryAction(InventoryAction::Use( + Slot::Inventory(id), + ))); + true + } else { + false + } + } + + pub fn choose_target( + &self, + agent: &mut Agent, + controller: &mut Controller, + read_data: &ReadData, + event_emitter: &mut Emitter, + will_ambush: bool, + ) { + agent.action_state.timer = 0.0; + let mut aggro_on = false; + + // Search the area. + // TODO: choose target by more than just distance + let common::CachedSpatialGrid(grid) = self.cached_spatial_grid; + + let entities_nearby = grid + .in_circle_aabr(self.pos.0.xy(), agent.psyche.search_dist()) + .collect_vec(); + + let can_ambush = |entity: EcsEntity, read_data: &ReadData| { + let self_different_from_entity = || { + read_data + .uids + .get(entity) + .map_or(false, |eu| eu != self.uid) + }; + if will_ambush + && self_different_from_entity() + && !self.passive_towards(entity, read_data) + { + let surrounding_humanoids = entities_nearby + .iter() + .filter(|e| read_data.bodies.get(**e).map_or(false, |b| b.is_humanoid())) + .collect_vec(); + surrounding_humanoids.len() == 2 + && surrounding_humanoids.iter().any(|e| **e == entity) + } else { + false + } + }; + + let get_pos = |entity| read_data.positions.get(entity); + let get_enemy = |(entity, attack_target): (EcsEntity, bool)| { + if attack_target { + if self.is_enemy(entity, read_data) { + Some((entity, true)) + } else if can_ambush(entity, read_data) { + controller.clone().push_utterance(UtteranceKind::Ambush); + self.chat_npc_if_allowed_to_speak( + "npc-speech-ambush".to_string(), + agent, + event_emitter, + ); + aggro_on = true; + Some((entity, true)) + } else if self.should_defend(entity, read_data) { + if let Some(attacker) = get_attacker(entity, read_data) { + if !self.passive_towards(attacker, read_data) { + // aggro_on: attack immediately, do not warn/menace. + aggro_on = true; + Some((attacker, true)) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + Some((entity, false)) + } + }; + let is_valid_target = |entity: EcsEntity| match read_data.bodies.get(entity) { + Some(Body::ItemDrop(item)) => { + //If the agent is humanoid, it will pick up all kinds of item drops. If the + // agent isn't humanoid, it will pick up only consumable item drops. + let wants_pickup = matches!(self.body, Some(Body::Humanoid(_))) + || matches!(item, item_drop::Body::Consumable); + + // The agent will attempt to pickup the item if it wants to pick it up and + // is allowed to + let attempt_pickup = wants_pickup + && read_data + .loot_owners + .get(entity) + .map_or(true, |loot_owner| { + loot_owner.can_pickup( + *self.uid, + read_data.groups.get(entity), + self.alignment, + self.body, + None, + ) + }); + + if attempt_pickup { + Some((entity, false)) + } else { + None + } + }, + _ => { + if read_data.healths.get(entity).map_or(false, |health| { + !health.is_dead && !is_invulnerable(entity, read_data) + }) { + Some((entity, true)) + } else { + None + } + }, + }; + + let can_sense_directly_near = + { |e_pos: &Pos| e_pos.0.distance_squared(self.pos.0) < 5_f32.powi(2) }; + + let is_detected = |entity: EcsEntity, e_pos: &Pos| { + let chance = thread_rng().gen_bool(0.3); + + (can_sense_directly_near(e_pos) && chance) + || self.can_see_entity(agent, controller, entity, e_pos, read_data) + }; + + let target = entities_nearby + .iter() + .filter_map(|e| is_valid_target(*e)) + .filter_map(get_enemy) + .filter_map(|(entity, attack_target)| { + get_pos(entity).map(|pos| (entity, pos, attack_target)) + }) + .filter(|(entity, e_pos, _)| is_detected(*entity, e_pos)) + .min_by_key(|(_, e_pos, attack_target)| { + ( + *attack_target, + (e_pos.0.distance_squared(self.pos.0) * 100.0) as i32, + ) + }) + .map(|(entity, _, attack_target)| (entity, attack_target)); + + if agent.target.is_none() && target.is_some() { + if aggro_on { + controller.push_utterance(UtteranceKind::Angry); + } else { + controller.push_utterance(UtteranceKind::Surprised); + } + } + + agent.target = target.map(|(entity, attack_target)| Target { + target: entity, + hostile: attack_target, + selected_at: read_data.time.0, + aggro_on, + }) + } + + pub fn attack( + &self, + agent: &mut Agent, + controller: &mut Controller, + tgt_data: &TargetData, + read_data: &ReadData, + rng: &mut impl Rng, + ) { + let tool_tactic = |tool_kind| match tool_kind { + ToolKind::Bow => Tactic::Bow, + ToolKind::Staff => Tactic::Staff, + ToolKind::Sceptre => Tactic::Sceptre, + ToolKind::Hammer => Tactic::Hammer, + ToolKind::Sword | ToolKind::Blowgun => Tactic::Sword, + ToolKind::Axe => Tactic::Axe, + _ => Tactic::SimpleMelee, + }; + + let tactic = self + .inventory + .equipped(EquipSlot::ActiveMainhand) + .as_ref() + .map(|item| { + if let Some(ability_spec) = item.ability_spec() { + match &*ability_spec { + AbilitySpec::Custom(spec) => match spec.as_str() { + "Oni" | "Sword Simple" => Tactic::Sword, + "Staff Simple" => Tactic::Staff, + "Bow Simple" => Tactic::Bow, + "Stone Golem" => Tactic::StoneGolem, + "Quad Med Quick" => Tactic::CircleCharge { + radius: 3, + circle_time: 2, + }, + "Quad Med Jump" => Tactic::QuadMedJump, + "Quad Med Charge" => Tactic::CircleCharge { + radius: 6, + circle_time: 1, + }, + "Quad Med Basic" => Tactic::QuadMedBasic, + "Asp" | "Maneater" => Tactic::QuadLowRanged, + "Quad Low Breathe" | "Quad Low Beam" | "Basilisk" => { + Tactic::QuadLowBeam + }, + "Organ" => Tactic::OrganAura, + "Quad Low Tail" | "Husk Brute" => Tactic::TailSlap, + "Quad Low Quick" => Tactic::QuadLowQuick, + "Quad Low Basic" => Tactic::QuadLowBasic, + "Theropod Basic" | "Theropod Bird" | "Theropod Small" => { + Tactic::Theropod + }, + // Arthropods + "Antlion" => Tactic::ArthropodMelee, + "Tarantula" | "Horn Beetle" => Tactic::ArthropodAmbush, + "Weevil" | "Black Widow" => Tactic::ArthropodRanged, + "Theropod Charge" => Tactic::CircleCharge { + radius: 6, + circle_time: 1, + }, + "Turret" => Tactic::Turret, + "Haniwa Sentry" => Tactic::RotatingTurret, + "Bird Large Breathe" => Tactic::BirdLargeBreathe, + "Bird Large Fire" => Tactic::BirdLargeFire, + "Bird Large Basic" => Tactic::BirdLargeBasic, + "Mindflayer" => Tactic::Mindflayer, + "Minotaur" => Tactic::Minotaur, + "Clay Golem" => Tactic::ClayGolem, + "Tidal Warrior" => Tactic::TidalWarrior, + "Tidal Totem" + | "Tornado" + | "Gnarling Totem Red" + | "Gnarling Totem Green" + | "Gnarling Totem White" => Tactic::RadialTurret, + "Yeti" => Tactic::Yeti, + "Harvester" => Tactic::Harvester, + "Cardinal" => Tactic::Cardinal, + "Dagon" => Tactic::Dagon, + "Gnarling Dagger" => Tactic::SimpleBackstab, + "Gnarling Blowgun" => Tactic::ElevatedRanged, + "Deadwood" => Tactic::Deadwood, + "Mandragora" => Tactic::Mandragora, + "Wood Golem" => Tactic::WoodGolem, + "Gnarling Chieftain" => Tactic::GnarlingChieftain, + _ => Tactic::SimpleMelee, + }, + AbilitySpec::Tool(tool_kind) => tool_tactic(*tool_kind), + } + } else if let ItemKind::Tool(tool) = &*item.kind() { + tool_tactic(tool.kind) + } else { + Tactic::SimpleMelee + } + }) + .unwrap_or(Tactic::SimpleMelee); + + // Wield the weapon as running towards the target + controller.push_action(ControlAction::Wield); + + let min_attack_dist = (self.body.map_or(0.5, |b| b.max_radius()) + DEFAULT_ATTACK_RANGE) + * self.scale + + tgt_data.body.map_or(0.5, |b| b.max_radius()) * tgt_data.scale.map_or(1.0, |s| s.0); + let dist_sqrd = self.pos.0.distance_squared(tgt_data.pos.0); + let angle = self + .ori + .look_vec() + .angle_between(tgt_data.pos.0 - self.pos.0) + .to_degrees(); + let angle_xy = self + .ori + .look_vec() + .xy() + .angle_between((tgt_data.pos.0 - self.pos.0).xy()) + .to_degrees(); + + let eye_offset = self.body.map_or(0.0, |b| b.eye_height()); + + let tgt_eye_height = tgt_data.body.map_or(0.0, |b| b.eye_height()); + let tgt_eye_offset = tgt_eye_height + + // Special case for jumping attacks to jump at the body + // of the target and not the ground around the target + // For the ranged it is to shoot at the feet and not + // the head to get splash damage + if tactic == Tactic::QuadMedJump { + 1.0 + } else if matches!(tactic, Tactic::QuadLowRanged) { + -1.0 + } else { + 0.0 + }; + + // FIXME: + // 1) Retrieve actual projectile speed! + // We have to assume projectiles are faster than base speed because there are + // skills that increase it, and in most cases this will cause agents to + // overshoot + // + // 2) We use eye_offset-s which isn't actually ideal. + // Some attacks (beam for example) may use different offsets, + // we should probably use offsets from corresponding states. + // + // 3) Should we even have this big switch? + // Not all attacks may want their direction overwritten. + // And this is quite hard to debug when you don't see it in actual + // attack handler. + if let Some(dir) = match self.char_state { + CharacterState::ChargedRanged(c) if dist_sqrd > 0.0 => { + let charge_factor = + c.timer.as_secs_f32() / c.static_data.charge_duration.as_secs_f32(); + let projectile_speed = c.static_data.initial_projectile_speed + + charge_factor * c.static_data.scaled_projectile_speed; + aim_projectile( + projectile_speed, + self.pos.0 + + self.body.map_or(Vec3::zero(), |body| { + body.projectile_offsets(self.ori.look_vec()) + }), + Vec3::new( + tgt_data.pos.0.x, + tgt_data.pos.0.y, + tgt_data.pos.0.z + tgt_eye_offset, + ), + ) + }, + CharacterState::BasicRanged(c) => { + let offset_z = match c.static_data.projectile { + // Aim fireballs at feet instead of eyes for splash damage + ProjectileConstructor::Fireball { + damage: _, + radius: _, + energy_regen: _, + min_falloff: _, + } => 0.0, + _ => tgt_eye_offset, + }; + let projectile_speed = c.static_data.projectile_speed; + aim_projectile( + projectile_speed, + self.pos.0 + + self.body.map_or(Vec3::zero(), |body| { + body.projectile_offsets(self.ori.look_vec()) + }), + Vec3::new( + tgt_data.pos.0.x, + tgt_data.pos.0.y, + tgt_data.pos.0.z + offset_z, + ), + ) + }, + CharacterState::RepeaterRanged(c) => { + let projectile_speed = c.static_data.projectile_speed; + aim_projectile( + projectile_speed, + self.pos.0 + + self.body.map_or(Vec3::zero(), |body| { + body.projectile_offsets(self.ori.look_vec()) + }), + Vec3::new( + tgt_data.pos.0.x, + tgt_data.pos.0.y, + tgt_data.pos.0.z + tgt_eye_offset, + ), + ) + }, + CharacterState::LeapMelee(_) if matches!(tactic, Tactic::Hammer | Tactic::Axe) => { + let direction_weight = match tactic { + Tactic::Hammer => 0.1, + Tactic::Axe => 0.3, + _ => unreachable!("Direction weight called on incorrect tactic."), + }; + + let tgt_pos = tgt_data.pos.0; + let self_pos = self.pos.0; + + let delta_x = (tgt_pos.x - self_pos.x) * direction_weight; + let delta_y = (tgt_pos.y - self_pos.y) * direction_weight; + + Dir::from_unnormalized(Vec3::new(delta_x, delta_y, -1.0)) + }, + CharacterState::BasicBeam(_) => { + let aim_from = self.body.map_or(self.pos.0, |body| { + self.pos.0 + + basic_beam::beam_offsets( + body, + controller.inputs.look_dir, + self.ori.look_vec(), + // Try to match animation by getting some context + self.vel.0 - self.physics_state.ground_vel, + self.physics_state.on_ground, + ) + }); + let aim_to = Vec3::new( + tgt_data.pos.0.x, + tgt_data.pos.0.y, + tgt_data.pos.0.z + tgt_eye_offset, + ); + Dir::from_unnormalized(aim_to - aim_from) + }, + _ => { + let aim_from = Vec3::new(self.pos.0.x, self.pos.0.y, self.pos.0.z + eye_offset); + let aim_to = Vec3::new( + tgt_data.pos.0.x, + tgt_data.pos.0.y, + tgt_data.pos.0.z + tgt_eye_offset, + ); + Dir::from_unnormalized(aim_to - aim_from) + }, + } { + controller.inputs.look_dir = dir; + } + + let attack_data = AttackData { + min_attack_dist, + dist_sqrd, + angle, + angle_xy, + }; + + // Match on tactic. Each tactic has different controls depending on the distance + // from the agent to the target. + match tactic { + Tactic::SimpleMelee => { + self.handle_simple_melee(agent, controller, &attack_data, tgt_data, read_data, rng) + }, + Tactic::Axe => { + self.handle_axe_attack(agent, controller, &attack_data, tgt_data, read_data, rng) + }, + Tactic::Hammer => { + self.handle_hammer_attack(agent, controller, &attack_data, tgt_data, read_data, rng) + }, + Tactic::Sword => { + self.handle_sword_attack(agent, controller, &attack_data, tgt_data, read_data, rng) + }, + Tactic::Bow => { + self.handle_bow_attack(agent, controller, &attack_data, tgt_data, read_data, rng) + }, + Tactic::Staff => { + self.handle_staff_attack(agent, controller, &attack_data, tgt_data, read_data, rng) + }, + Tactic::Sceptre => self.handle_sceptre_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + rng, + ), + Tactic::StoneGolem => { + self.handle_stone_golem_attack(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::CircleCharge { + radius, + circle_time, + } => self.handle_circle_charge_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + radius, + circle_time, + rng, + ), + Tactic::QuadLowRanged => self.handle_quadlow_ranged_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + ), + Tactic::TailSlap => { + self.handle_tail_slap_attack(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::QuadLowQuick => self.handle_quadlow_quick_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + ), + Tactic::QuadLowBasic => self.handle_quadlow_basic_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + ), + Tactic::QuadMedJump => self.handle_quadmed_jump_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + ), + Tactic::QuadMedBasic => self.handle_quadmed_basic_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + ), + Tactic::QuadLowBeam => self.handle_quadlow_beam_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + ), + Tactic::OrganAura => { + self.handle_organ_aura_attack(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::Theropod => { + self.handle_theropod_attack(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::ArthropodMelee => self.handle_arthropod_melee_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + ), + Tactic::ArthropodAmbush => self.handle_arthropod_ambush_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + rng, + ), + Tactic::ArthropodRanged => self.handle_arthropod_ranged_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + ), + Tactic::Turret => { + self.handle_turret_attack(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::FixedTurret => self.handle_fixed_turret_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + ), + Tactic::RotatingTurret => { + self.handle_rotating_turret_attack(agent, controller, tgt_data, read_data) + }, + Tactic::Mindflayer => self.handle_mindflayer_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + rng, + ), + Tactic::BirdLargeFire => self.handle_birdlarge_fire_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + rng, + ), + // Mostly identical to BirdLargeFire but tweaked for flamethrower instead of shockwave + Tactic::BirdLargeBreathe => self.handle_birdlarge_breathe_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + rng, + ), + Tactic::BirdLargeBasic => self.handle_birdlarge_basic_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + ), + Tactic::Minotaur => { + self.handle_minotaur_attack(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::ClayGolem => { + self.handle_clay_golem_attack(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::TidalWarrior => self.handle_tidal_warrior_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + ), + Tactic::RadialTurret => self.handle_radial_turret_attack(controller), + Tactic::Yeti => { + self.handle_yeti_attack(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::Harvester => { + self.handle_harvester_attack(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::Cardinal => self.handle_cardinal_attack( + agent, + controller, + &attack_data, + tgt_data, + read_data, + rng, + ), + Tactic::Dagon => { + self.handle_dagon_attack(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::SimpleBackstab => { + self.handle_simple_backstab(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::ElevatedRanged => { + self.handle_elevated_ranged(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::Deadwood => { + self.handle_deadwood(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::Mandragora => { + self.handle_mandragora(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::WoodGolem => { + self.handle_wood_golem(agent, controller, &attack_data, tgt_data, read_data) + }, + Tactic::GnarlingChieftain => self.handle_gnarling_chieftain( + agent, + controller, + &attack_data, + tgt_data, + read_data, + rng, + ), + } + } + + pub fn handle_sounds_heard( + &self, + agent: &mut Agent, + controller: &mut Controller, + read_data: &ReadData, + rng: &mut impl Rng, + ) { + agent.forget_old_sounds(read_data.time.0); + + if is_invulnerable(*self.entity, read_data) { + self.idle(agent, controller, read_data, rng); + return; + } + + if let Some(sound) = agent.sounds_heard.last() { + let sound_pos = Pos(sound.pos); + let dist_sqrd = self.pos.0.distance_squared(sound_pos.0); + // NOTE: There is an implicit distance requirement given that sound volume + // dissipates as it travels, but we will not want to flee if a sound is super + // loud but heard from a great distance, regardless of how loud it was. + // `is_close` is this limiter. + let is_close = dist_sqrd < 35.0_f32.powi(2); + + let sound_was_loud = sound.vol >= 10.0; + let sound_was_threatening = sound_was_loud + || matches!(sound.kind, SoundKind::Utterance(UtteranceKind::Scream, _)); + + let has_enemy_alignment = matches!(self.alignment, Some(Alignment::Enemy)); + // FIXME: We need to be able to change the name of a guard without breaking this + // logic. The `Mark` enum from common::agent could be used to match with + // `agent::Mark::Guard` + let is_village_guard = read_data + .stats + .get(*self.entity) + .map_or(false, |stats| stats.name == *"Guard".to_string()); + let follows_threatening_sounds = has_enemy_alignment || is_village_guard; + + // TODO: Awareness currently doesn't influence anything. + //agent.awareness += 0.5 * sound.vol; + + if sound_was_threatening && is_close { + if !self.below_flee_health(agent) && follows_threatening_sounds { + self.follow(agent, controller, &read_data.terrain, &sound_pos); + } else if self.below_flee_health(agent) || !follows_threatening_sounds { + self.flee(agent, controller, &sound_pos, &read_data.terrain); + } else { + self.idle(agent, controller, read_data, rng); + } + } else { + self.idle(agent, controller, read_data, rng); + } + } else { + self.idle(agent, controller, read_data, rng); + } + } + + pub fn attack_target_attacker( + &self, + agent: &mut Agent, + read_data: &ReadData, + controller: &mut Controller, + rng: &mut impl Rng, + ) { + if let Some(Target { target, .. }) = agent.target { + if let Some(tgt_health) = read_data.healths.get(target) { + if let Some(by) = tgt_health.last_change.damage_by() { + if let Some(attacker) = get_entity_by_id(by.uid().0, read_data) { + if agent.target.is_none() { + controller.push_utterance(UtteranceKind::Angry); + } + + agent.target = Some(Target::new(attacker, true, read_data.time.0, true)); + + if let Some(tgt_pos) = read_data.positions.get(attacker) { + if is_dead_or_invulnerable(attacker, read_data) { + // FIXME?: Shouldn't target be set to `None`? + // If is dead, then probably. If invulnerable, maybe not. + agent.target = + Some(Target::new(target, false, read_data.time.0, false)); + + self.idle(agent, controller, read_data, rng); + } else { + let target_data = TargetData::new( + tgt_pos, + read_data.bodies.get(target), + read_data.scales.get(target), + ); + if let Some(tgt_name) = + read_data.stats.get(target).map(|stats| stats.name.clone()) + { + agent.add_fight_to_memory(&tgt_name, read_data.time.0) + } + self.attack(agent, controller, &target_data, read_data, rng); + } + } + } + } + } + } + } + + pub fn chat_npc_if_allowed_to_speak( + &self, + msg: impl ToString, + agent: &Agent, + event_emitter: &mut Emitter<'_, ServerEvent>, + ) -> bool { + if agent.allowed_to_speak() { + self.chat_npc(msg, event_emitter); + true + } else { + false + } + } + + pub fn chat_npc(&self, msg: impl ToString, event_emitter: &mut Emitter<'_, ServerEvent>) { + event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( + *self.uid, + msg.to_string(), + ))); + } + + fn emit_scream(&self, time: f64, event_emitter: &mut Emitter<'_, ServerEvent>) { + if let Some(body) = self.body { + event_emitter.emit(ServerEvent::Sound { + sound: Sound::new( + SoundKind::Utterance(UtteranceKind::Scream, *body), + self.pos.0, + 13.0, + time, + ), + }); + } + } + + pub fn cry_out( + &self, + agent: &Agent, + event_emitter: &mut Emitter<'_, ServerEvent>, + read_data: &ReadData, + ) { + let has_enemy_alignment = matches!(self.alignment, Some(Alignment::Enemy)); + + if has_enemy_alignment { + // FIXME: If going to use "cultist + low health + fleeing" string, make sure + // they are each true. + self.chat_npc_if_allowed_to_speak( + "npc-speech-cultist_low_health_fleeing", + agent, + event_emitter, + ); + } else if is_villager(self.alignment) { + self.chat_npc_if_allowed_to_speak( + "npc-speech-villager_under_attack", + agent, + event_emitter, + ); + self.emit_scream(read_data.time.0, event_emitter); + } + } + + pub fn exclaim_relief_about_enemy_dead( + &self, + agent: &Agent, + event_emitter: &mut Emitter<'_, ServerEvent>, + ) { + if is_villager(self.alignment) { + self.chat_npc_if_allowed_to_speak( + "npc-speech-villager_enemy_killed", + agent, + event_emitter, + ); + } + } + + pub fn below_flee_health(&self, agent: &Agent) -> bool { + self.damage.min(1.0) < agent.psyche.flee_health + } + + pub fn is_more_dangerous_than_target( + &self, + entity: EcsEntity, + target: Target, + read_data: &ReadData, + ) -> bool { + let entity_pos = read_data.positions.get(entity); + let target_pos = read_data.positions.get(target.target); + + entity_pos.map_or(false, |entity_pos| { + target_pos.map_or(true, |target_pos| { + // Fuzzy factor that makes it harder for players to cheese enemies by making + // them quickly flip aggro between two players. + // It does this by only switching aggro if the entity is closer to the enemy by + // a specific proportional threshold. + const FUZZY_DIST_COMPARISON: f32 = 0.8; + + let is_target_further = target_pos.0.distance(entity_pos.0) + < target_pos.0.distance(entity_pos.0) * FUZZY_DIST_COMPARISON; + let is_entity_hostile = read_data + .alignments + .get(entity) + .zip(self.alignment) + .map_or(false, |(entity, me)| me.hostile_towards(*entity)); + + // Consider entity more dangerous than target if entity is closer or if target + // had not triggered aggro. + !target.aggro_on || (is_target_further && is_entity_hostile) + }) + }) + } + + fn is_enemy(&self, entity: EcsEntity, read_data: &ReadData) -> bool { + let other_alignment = read_data.alignments.get(entity); + + (entity != *self.entity) + && !self.passive_towards(entity, read_data) + && (are_our_owners_hostile(self.alignment, other_alignment, read_data) + || (is_villager(self.alignment) && is_dressed_as_cultist(entity, read_data))) + } + + fn should_defend(&self, entity: EcsEntity, read_data: &ReadData) -> bool { + let entity_alignment = read_data.alignments.get(entity); + + let we_are_friendly = entity_alignment.map_or(false, |entity_alignment| { + self.alignment.map_or(false, |alignment| { + !alignment.hostile_towards(*entity_alignment) + }) + }); + let we_share_species = read_data.bodies.get(entity).map_or(false, |entity_body| { + self.body.map_or(false, |body| { + entity_body.is_same_species_as(body) + || (entity_body.is_humanoid() && body.is_humanoid()) + }) + }); + let self_owns_entity = + matches!(entity_alignment, Some(Alignment::Owned(ouid)) if *self.uid == *ouid); + + (we_are_friendly && we_share_species) + || (is_village_guard(*self.entity, read_data) && is_villager(entity_alignment)) + || self_owns_entity + } + + fn passive_towards(&self, entity: EcsEntity, read_data: &ReadData) -> bool { + if let (Some(self_alignment), Some(other_alignment)) = + (self.alignment, read_data.alignments.get(entity)) + { + self_alignment.passive_towards(*other_alignment) + } else { + false + } + } + + fn can_see_entity( + &self, + agent: &Agent, + controller: &Controller, + other: EcsEntity, + other_pos: &Pos, + read_data: &ReadData, + ) -> bool { + let other_stealth_multiplier = { + let other_inventory = read_data.inventories.get(other); + let other_char_state = read_data.char_states.get(other); + + perception_dist_multiplier_from_stealth(other_inventory, other_char_state, self.msm) + }; + + let within_sight_dist = { + let sight_dist = agent.psyche.sight_dist * other_stealth_multiplier; + let dist_sqrd = other_pos.0.distance_squared(self.pos.0); + + dist_sqrd < sight_dist.powi(2) + }; + + let within_fov = (other_pos.0 - self.pos.0) + .try_normalized() + .map_or(false, |v| v.dot(*controller.inputs.look_dir) > 0.15); + + let other_body = read_data.bodies.get(other); + + (within_sight_dist) + && within_fov + && entities_have_line_of_sight(self.pos, self.body, other_pos, other_body, read_data) + } + + pub fn menacing( + &self, + agent: &mut Agent, + controller: &mut Controller, + target: EcsEntity, + read_data: &ReadData, + event_emitter: &mut Emitter, + rng: &mut impl Rng, + remembers_fight_with_target: bool, + ) { + let max_move = 0.5; + let move_dir = controller.inputs.move_dir; + let move_dir_mag = move_dir.magnitude(); + let small_chance = rng.gen::() < read_data.dt.0 * 0.25; + let mut chat = |msg: &str| { + self.chat_npc_if_allowed_to_speak(msg.to_string(), agent, event_emitter); + }; + let mut chat_villager_remembers_fighting = || { + let tgt_name = read_data.stats.get(target).map(|stats| stats.name.clone()); + + if let Some(tgt_name) = tgt_name { + chat(format!("{}! How dare you cross me again!", &tgt_name).as_str()); + } else { + chat("You! How dare you cross me again!"); + } + }; + + self.look_toward(controller, read_data, target); + controller.push_action(ControlAction::Wield); + + if move_dir_mag > max_move { + controller.inputs.move_dir = max_move * move_dir / move_dir_mag; + } + + if small_chance { + controller.push_utterance(UtteranceKind::Angry); + if is_villager(self.alignment) { + if remembers_fight_with_target { + chat_villager_remembers_fighting(); + } else if is_dressed_as_cultist(target, read_data) { + chat("npc-speech-villager_cultist_alarm"); + } else { + chat("npc-speech-menacing"); + } + } else { + chat("npc-speech-menacing"); + } + } + } +} diff --git a/server/src/sys/agent/attack.rs b/server/agent/src/attack.rs similarity index 99% rename from server/src/sys/agent/attack.rs rename to server/agent/src/attack.rs index cdc8bce929..fbc0c75f8e 100644 --- a/server/src/sys/agent/attack.rs +++ b/server/agent/src/attack.rs @@ -1,6 +1,7 @@ -use crate::sys::agent::{ - consts::MAX_PATH_DIST, data::Path, util::entities_have_line_of_sight, AgentData, AttackData, - ReadData, TargetData, +use crate::{ + consts::MAX_PATH_DIST, + data::{AgentData, AttackData, Path, ReadData, TargetData}, + util::entities_have_line_of_sight, }; use common::{ comp::{ diff --git a/server/src/sys/agent/consts.rs b/server/agent/src/consts.rs similarity index 100% rename from server/src/sys/agent/consts.rs rename to server/agent/src/consts.rs diff --git a/server/src/sys/agent/data.rs b/server/agent/src/data.rs similarity index 97% rename from server/src/sys/agent/data.rs rename to server/agent/src/data.rs index c6fbb0f67d..4b27278190 100644 --- a/server/src/sys/agent/data.rs +++ b/server/agent/src/data.rs @@ -1,4 +1,3 @@ -use crate::rtsim::Entity as RtSimData; use common::{ comp::{ buff::Buffs, group, item::MaterialStatManifest, ActiveAbilities, Alignment, Body, @@ -17,11 +16,9 @@ use specs::{ shred::ResourceId, Entities, Entity as EcsEntity, Read, ReadExpect, ReadStorage, SystemData, World, }; -use std::sync::Arc; pub struct AgentData<'a> { pub entity: &'a EcsEntity, - pub rtsim_entity: Option<&'a RtSimData>, pub uid: &'a Uid, pub pos: &'a Pos, pub vel: &'a Vel, diff --git a/server/agent/src/lib.rs b/server/agent/src/lib.rs new file mode 100644 index 0000000000..09e2702834 --- /dev/null +++ b/server/agent/src/lib.rs @@ -0,0 +1,27 @@ +#[cfg(all(feature = "be-dyn-lib", feature = "use-dyn-lib"))] +compile_error!("Can't use both \"be-dyn-lib\" and \"use-dyn-lib\" features at once"); + +#[cfg(all(target_os = "windows", feature = "be-dyn-lib"))] +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + +pub mod action_nodes; +pub mod attack; +pub mod consts; +pub mod data; +pub mod util; + +#[cfg(feature = "use-dyn-lib")] +use { + lazy_static::lazy_static, server_dynlib::LoadedLib, std::ffi::CStr, std::sync::Arc, + std::sync::Mutex, +}; + +#[cfg(feature = "use-dyn-lib")] +lazy_static! { + static ref LIB: Arc>> = + server_dynlib::init("veloren-server-agent", "veloren-server-agent-dyn", "agent"); +} + +#[cfg(feature = "use-dyn-lib")] +pub fn init() { lazy_static::initialize(&LIB); } diff --git a/server/src/sys/agent/util.rs b/server/agent/src/util.rs similarity index 99% rename from server/src/sys/agent/util.rs rename to server/agent/src/util.rs index 4ba22a232f..bdccfd4dc9 100644 --- a/server/src/sys/agent/util.rs +++ b/server/agent/src/util.rs @@ -1,4 +1,4 @@ -use crate::sys::agent::{AgentData, ReadData}; +use crate::data::{AgentData, ReadData}; use common::{ comp::{ agent::Psyche, buff::BuffKind, inventory::item::ItemTag, item::ItemDesc, Alignment, Body, @@ -65,15 +65,6 @@ pub fn get_entity_by_id(id: u64, read_data: &ReadData) -> Option { read_data.uid_allocator.retrieve_entity_internal(id) } -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.contains_key(&buff)) - } -} - /// 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 @@ -202,3 +193,12 @@ pub fn get_attacker(entity: EcsEntity, read_data: &ReadData) -> Option 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.contains_key(&buff)) + } +} diff --git a/server/dynlib/Cargo.toml b/server/dynlib/Cargo.toml new file mode 100644 index 0000000000..01a3a381c8 --- /dev/null +++ b/server/dynlib/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "veloren-server-dynlib" +version = "0.1.0" +authors = ["Samuel Keiffer "] +edition = "2021" + +[dependencies] +find_folder = {version = "0.3.0"} +libloading = {version = "0.7"} +notify = {version = "5.0.0"} +tracing = "0.1" diff --git a/server/dynlib/src/lib.rs b/server/dynlib/src/lib.rs new file mode 100644 index 0000000000..d763e1ab03 --- /dev/null +++ b/server/dynlib/src/lib.rs @@ -0,0 +1,272 @@ +use libloading::Library; +use notify::{recommended_watcher, EventKind, RecursiveMode, Watcher}; +use std::{ + process::{Command, Stdio}, + sync::{mpsc, Mutex}, + time::Duration, +}; + +use find_folder::Search; +use std::{ + env, + env::consts::{DLL_PREFIX, DLL_SUFFIX}, + path::{Path, PathBuf}, + sync::Arc, +}; +use tracing::{debug, error, info}; + +// Re-exports +pub use libloading::Symbol; + +/// LoadedLib holds a loaded dynamic library and the location of library file +/// with the appropriate OS specific name and extension i.e. +/// `libvoxygen_anim_dyn_active.dylib`, `voxygen_anim_dyn_active.dll`. +/// +/// # NOTE +/// DOES NOT WORK ON MACOS, due to some limitations with hot-reloading the +/// `.dylib`. +pub struct LoadedLib { + /// Loaded library. + pub lib: Library, + /// Path to the library. + pub lib_path: PathBuf, +} + +impl LoadedLib { + /// Compile and load the dynamic library + /// + /// This is necessary because the very first time you use hot reloading you + /// wont have the library, so you can't load it until you have compiled it! + fn compile_load(dyn_package: &str) -> Self { + #[cfg(target_os = "macos")] + error!("The hot reloading feature does not work on macos."); + + // Compile + if !compile(dyn_package) { + panic!("{} compile failed.", dyn_package); + } else { + info!("{} compile succeeded.", dyn_package); + } + + copy(&LoadedLib::determine_path(dyn_package), dyn_package); + + Self::load(dyn_package) + } + + /// Load a library from disk. + /// + /// Currently this is pretty fragile, it gets the path of where it thinks + /// the dynamic library should be and tries to load it. It will panic if it + /// is missing. + fn load(dyn_package: &str) -> Self { + let lib_path = LoadedLib::determine_path(dyn_package); + + // Try to load the library. + let lib = match unsafe { Library::new(lib_path.clone()) } { + Ok(lib) => lib, + Err(e) => panic!( + "Tried to load dynamic library from {:?}, but it could not be found. A potential \ + reason is we may require a special case for your OS so we can find it. {:?}", + lib_path, e + ), + }; + + Self { lib, lib_path } + } + + /// Determine the path to the dynamic library based on the path of the + /// current executable. + fn determine_path(dyn_package: &str) -> PathBuf { + let current_exe = env::current_exe(); + + // If we got the current_exe, we need to go up a level and then down + // in to debug (in case we were in release or another build dir). + let mut lib_path = match current_exe { + Ok(mut path) => { + // Remove the filename to get the directory. + path.pop(); + + // Search for the debug directory. + let dir = Search::ParentsThenKids(1, 1) + .of(path) + .for_folder("debug") + .expect( + "Could not find the debug build directory relative to the current \ + executable.", + ); + + debug!(?dir, "Found the debug build directory."); + dir + }, + Err(e) => { + panic!( + "Could not determine the path of the current executable, this is needed to \ + hot-reload the dynamic library. {:?}", + e + ); + }, + }; + + // Determine the platform specific path and push it onto our already + // established target/debug dir. + lib_path.push(active_file(dyn_package)); + + lib_path + } +} + +/// Initialise a watcher. +/// +/// This will search for the directory named `package_source_dir` and watch the +/// files within it for any changes. +pub fn init( + package: &'static str, + dyn_package: &'static str, + package_source_dir: &'static str, +) -> Arc>> { + let lib_storage = Arc::new(Mutex::new(Some(LoadedLib::compile_load(dyn_package)))); + + // TODO: use crossbeam + let (reload_send, reload_recv) = mpsc::channel(); + + // Start watcher + let mut watcher = recommended_watcher(move |res| event_fn(res, &reload_send)).unwrap(); + + // Search for the source directory of the package being hot-reloaded. + let watch_dir = Search::Kids(1) + .for_folder(package_source_dir) + .unwrap_or_else(|_| { + panic!( + "Could not find the {} crate directory relative to the current directory", + package_source_dir + ) + }); + + watcher.watch(&watch_dir, RecursiveMode::Recursive).unwrap(); + + // Start reloader that watcher signals + // "Debounces" events since I can't find the option to do this in the latest + // `notify` + let lib_storage_clone = Arc::clone(&lib_storage); + std::thread::Builder::new() + .name(format!("{}_hotreload_watcher", package)) + .spawn(move || { + let mut modified_paths = std::collections::HashSet::new(); + while let Ok(path) = reload_recv.recv() { + modified_paths.insert(path); + // Wait for any additional modify events before reloading + while let Ok(path) = reload_recv.recv_timeout(Duration::from_millis(300)) { + modified_paths.insert(path); + } + + info!( + ?modified_paths, + "Hot reloading {} because files in `{}` modified.", package, package_source_dir + ); + + hotreload(dyn_package, &lib_storage_clone); + } + }) + .unwrap(); + + // Let the watcher live forever + std::mem::forget(watcher); + + lib_storage +} + +fn compiled_file(dyn_package: &str) -> String { dyn_lib_file(dyn_package, false) } + +fn active_file(dyn_package: &str) -> String { dyn_lib_file(dyn_package, true) } + +fn dyn_lib_file(dyn_package: &str, active: bool) -> String { + format!( + "{}{}{}{}", + DLL_PREFIX, + dyn_package.replace('-', "_"), + if active { "_active" } else { "" }, + DLL_SUFFIX + ) +} + +/// Event function to hotreload the dynamic library +/// +/// This is called by the watcher to filter for modify events on `.rs` files +/// before sending them back. +fn event_fn(res: notify::Result, sender: &mpsc::Sender) { + match res { + Ok(event) => { + if let EventKind::Modify(_) = event.kind { + event + .paths + .iter() + .filter(|p| p.extension().map(|e| e == "rs").unwrap_or(false)) + .map(|p| p.to_string_lossy().into_owned()) + // Signal reloader + .for_each(|p| { let _ = sender.send(p); }); + } + }, + Err(e) => error!(?e, "hotreload watcher error."), + } +} + +/// Hotreload the dynamic library +/// +/// This will reload the dynamic library by first internally calling compile +/// and then reloading the library. +fn hotreload(dyn_package: &str, loaded_lib: &Mutex>) { + // Do nothing if recompile failed. + if compile(dyn_package) { + let mut lock = loaded_lib.lock().unwrap(); + + // Close lib. + let loaded_lib = lock.take().unwrap(); + loaded_lib.lib.close().unwrap(); + copy(&loaded_lib.lib_path, dyn_package); + + // Open new lib. + *lock = Some(LoadedLib::load(dyn_package)); + + info!("Updated {}.", dyn_package); + } +} + +/// Recompile the dyn package +/// +/// Returns `false` if the compile failed. +fn compile(dyn_package: &str) -> bool { + let output = Command::new("cargo") + .stderr(Stdio::inherit()) + .stdout(Stdio::inherit()) + .arg("build") + .arg("--package") + .arg(dyn_package) + .arg("--features") + .arg(format!("{}/be-dyn-lib", dyn_package)) + .output() + .unwrap(); + + output.status.success() +} + +/// Copy the lib file, so we have an `_active` copy. +/// +/// We do this for all OS's although it is only strictly necessary for windows. +/// The reason we do this is to make the code easier to understand and debug. +fn copy(lib_path: &Path, dyn_package: &str) { + // Use the platform specific names. + let lib_compiled_path = lib_path.with_file_name(compiled_file(dyn_package)); + let lib_output_path = lib_path.with_file_name(active_file(dyn_package)); + + // Get the path to where the lib was compiled to. + debug!(?lib_compiled_path, ?lib_output_path, "Moving."); + + // Copy the library file from where it is output, to where we are going to + // load it from i.e. lib_path. + std::fs::copy(&lib_compiled_path, &lib_output_path).unwrap_or_else(|err| { + panic!( + "Failed to rename dynamic library from {:?} to {:?}. {:?}", + lib_compiled_path, lib_output_path, err + ) + }); +} diff --git a/server/src/sys/agent.rs b/server/src/sys/agent.rs index cc4424bccd..4ae65d35fa 100644 --- a/server/src/sys/agent.rs +++ b/server/src/sys/agent.rs @@ -1,58 +1,27 @@ -pub mod attack; pub mod behavior_tree; -pub mod consts; -pub mod data; -pub mod util; +pub use server_agent::{action_nodes, attack, consts, data, util}; use crate::{ rtsim::RtSim, sys::agent::{ behavior_tree::{BehaviorData, BehaviorTree}, - consts::{ - AVG_FOLLOW_DIST, DEFAULT_ATTACK_RANGE, IDLE_HEALING_ITEM_THRESHOLD, PARTIAL_PATH_DIST, - SEPARATION_BIAS, SEPARATION_DIST, - }, - data::{AgentData, AttackData, Path, ReadData, Tactic, TargetData}, - util::{ - aim_projectile, are_our_owners_hostile, entities_have_line_of_sight, get_attacker, - get_entity_by_id, is_dead_or_invulnerable, is_dressed_as_cultist, is_invulnerable, - is_village_guard, is_villager, - }, + data::{AgentData, ReadData}, }, }; use common::{ - combat::perception_dist_multiplier_from_stealth, comp::{ - self, - agent::{Sound, SoundKind, Target}, - buff::BuffKind, - inventory::slot::EquipSlot, - item::{ - tool::{AbilitySpec, ToolKind}, - ConsumableKind, Item, ItemDesc, ItemKind, - }, - item_drop, - projectile::ProjectileConstructor, - Agent, Alignment, Body, CharacterState, ControlAction, ControlEvent, Controller, Health, - HealthChange, InputKind, InventoryAction, Pos, Scale, UnresolvedChatMsg, UtteranceKind, + self, inventory::slot::EquipSlot, item::ItemDesc, Agent, Alignment, Body, CharacterState, + Controller, Health, InputKind, Scale, }, - effect::{BuffEffect, Effect}, - event::{Emitter, EventBus, ServerEvent}, + event::{EventBus, ServerEvent}, path::TraversalConfig, rtsim::RtSimEvent, - states::basic_beam, - terrain::{Block, TerrainGrid}, - time::DayPeriod, - util::Dir, - vol::ReadVol, }; use common_base::prof_span; use common_ecs::{Job, Origin, ParMode, Phase, System}; -use itertools::Itertools; -use rand::{thread_rng, Rng}; +use rand::thread_rng; use rayon::iter::ParallelIterator; -use specs::{Entity as EcsEntity, Join, ParJoin, Read, WriteExpect, WriteStorage}; -use vek::*; +use specs::{Join, ParJoin, Read, WriteExpect, WriteStorage}; /// This system will allow NPCs to modify their controller #[derive(Default)] @@ -205,7 +174,6 @@ impl<'a> System<'a> for Sys { // Package all this agent's data into a convenient struct let data = AgentData { entity: &entity, - rtsim_entity, uid, pos, vel, @@ -248,6 +216,7 @@ impl<'a> System<'a> for Sys { // inputs. let mut behavior_data = BehaviorData { agent, + rtsim_entity, agent_data: data, read_data: &read_data, event_emitter: &mut event_emitter, @@ -281,1488 +250,3 @@ impl<'a> System<'a> for Sys { } } } - -impl<'a> AgentData<'a> { - //////////////////////////////////////// - // Action Nodes - //////////////////////////////////////// - - fn glider_fall(&self, controller: &mut Controller) { - controller.push_action(ControlAction::GlideWield); - - let flight_direction = - Vec3::from(self.vel.0.xy().try_normalized().unwrap_or_else(Vec2::zero)); - let flight_ori = Quaternion::from_scalar_and_vec3((1.0, flight_direction)); - - let ori = self.ori.look_vec(); - let look_dir = if ori.z > 0.0 { - flight_ori.rotated_x(-0.1) - } else { - flight_ori.rotated_x(0.1) - }; - - let (_, look_dir) = look_dir.into_scalar_and_vec3(); - controller.inputs.look_dir = Dir::from_unnormalized(look_dir).unwrap_or_else(Dir::forward); - } - - fn fly_upward(&self, controller: &mut Controller) { - controller.push_basic_input(InputKind::Fly); - controller.inputs.move_z = 1.0; - } - - fn idle( - &self, - agent: &mut Agent, - controller: &mut Controller, - read_data: &ReadData, - rng: &mut impl Rng, - ) { - // Light lanterns at night - // TODO Add a method to turn on NPC lanterns underground - let lantern_equipped = self - .inventory - .equipped(EquipSlot::Lantern) - .as_ref() - .map_or(false, |item| { - matches!(&*item.kind(), comp::item::ItemKind::Lantern(_)) - }); - let lantern_turned_on = self.light_emitter.is_some(); - let day_period = DayPeriod::from(read_data.time_of_day.0); - // Only emit event for agents that have a lantern equipped - if lantern_equipped && rng.gen_bool(0.001) { - if day_period.is_dark() && !lantern_turned_on { - // Agents with turned off lanterns turn them on randomly once it's - // nighttime and keep them on. - // Only emit event for agents that sill need to - // turn on their lantern. - controller.push_event(ControlEvent::EnableLantern) - } else if lantern_turned_on && day_period.is_light() { - // agents with turned on lanterns turn them off randomly once it's - // daytime and keep them off. - controller.push_event(ControlEvent::DisableLantern) - } - }; - - if let Some(body) = self.body { - let attempt_heal = if matches!(body, Body::Humanoid(_)) { - self.damage < IDLE_HEALING_ITEM_THRESHOLD - } else { - true - }; - if attempt_heal && self.heal_self(agent, controller, true) { - agent.action_state.timer = 0.01; - return; - } - } else { - agent.action_state.timer = 0.01; - return; - } - - agent.action_state.timer = 0.0; - if let Some((travel_to, _destination)) = &agent.rtsim_controller.travel_to { - // If it has an rtsim destination and can fly, then it should. - // If it is flying and bumps something above it, then it should move down. - if self.traversal_config.can_fly - && !read_data - .terrain - .ray(self.pos.0, self.pos.0 + (Vec3::unit_z() * 3.0)) - .until(Block::is_solid) - .cast() - .1 - .map_or(true, |b| b.is_some()) - { - controller.push_basic_input(InputKind::Fly); - } else { - controller.push_cancel_input(InputKind::Fly) - } - - if let Some((bearing, speed)) = agent.chaser.chase( - &*read_data.terrain, - self.pos.0, - self.vel.0, - *travel_to, - TraversalConfig { - min_tgt_dist: 1.25, - ..self.traversal_config - }, - ) { - controller.inputs.move_dir = - bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) - * speed.min(agent.rtsim_controller.speed_factor); - self.jump_if(bearing.z > 1.5 || self.traversal_config.can_fly, controller); - controller.inputs.climb = Some(comp::Climb::Up); - //.filter(|_| bearing.z > 0.1 || self.physics_state.in_liquid().is_some()); - - let height_offset = bearing.z - + if self.traversal_config.can_fly { - // NOTE: costs 4 us (imbris) - let obstacle_ahead = read_data - .terrain - .ray( - self.pos.0 + Vec3::unit_z(), - self.pos.0 - + bearing.try_normalized().unwrap_or_else(Vec3::unit_y) * 80.0 - + Vec3::unit_z(), - ) - .until(Block::is_solid) - .cast() - .1 - .map_or(true, |b| b.is_some()); - - let mut ground_too_close = self - .body - .map(|body| { - #[cfg(feature = "worldgen")] - let height_approx = self.pos.0.z - - read_data - .world - .sim() - .get_alt_approx(self.pos.0.xy().map(|x: f32| x as i32)) - .unwrap_or(0.0); - #[cfg(not(feature = "worldgen"))] - let height_approx = self.pos.0.z; - - height_approx < body.flying_height() - }) - .unwrap_or(false); - - const NUM_RAYS: usize = 5; - - // NOTE: costs 15-20 us (imbris) - for i in 0..=NUM_RAYS { - let magnitude = self.body.map_or(20.0, |b| b.flying_height()); - // Lerp between a line straight ahead and straight down to detect a - // wedge of obstacles we might fly into (inclusive so that both vectors - // are sampled) - if let Some(dir) = Lerp::lerp( - -Vec3::unit_z(), - Vec3::new(bearing.x, bearing.y, 0.0), - i as f32 / NUM_RAYS as f32, - ) - .try_normalized() - { - ground_too_close |= read_data - .terrain - .ray(self.pos.0, self.pos.0 + magnitude * dir) - .until(|b: &Block| b.is_solid() || b.is_liquid()) - .cast() - .1 - .map_or(false, |b| b.is_some()) - } - } - - if obstacle_ahead || ground_too_close { - 5.0 //fly up when approaching obstacles - } else { - -2.0 - } //flying things should slowly come down from the stratosphere - } else { - 0.05 //normal land traveller offset - }; - if let Some(pid) = agent.position_pid_controller.as_mut() { - pid.sp = self.pos.0.z + height_offset * Vec3::unit_z(); - controller.inputs.move_z = pid.calc_err(); - } else { - controller.inputs.move_z = height_offset; - } - // Put away weapon - if rng.gen_bool(0.1) - && matches!( - read_data.char_states.get(*self.entity), - Some(CharacterState::Wielding(_)) - ) - { - controller.push_action(ControlAction::Unwield); - } - } - } else { - agent.bearing += Vec2::new(rng.gen::() - 0.5, rng.gen::() - 0.5) * 0.1 - - agent.bearing * 0.003 - - agent.patrol_origin.map_or(Vec2::zero(), |patrol_origin| { - (self.pos.0 - patrol_origin).xy() * 0.0002 - }); - - // Stop if we're too close to a wall - // or about to walk off a cliff - // NOTE: costs 1 us (imbris) <- before cliff raycast added - agent.bearing *= 0.1 - + if read_data - .terrain - .ray( - self.pos.0 + Vec3::unit_z(), - self.pos.0 - + Vec3::from(agent.bearing) - .try_normalized() - .unwrap_or_else(Vec3::unit_y) - * 5.0 - + Vec3::unit_z(), - ) - .until(Block::is_solid) - .cast() - .1 - .map_or(true, |b| b.is_none()) - && read_data - .terrain - .ray( - self.pos.0 - + Vec3::from(agent.bearing) - .try_normalized() - .unwrap_or_else(Vec3::unit_y), - self.pos.0 - + Vec3::from(agent.bearing) - .try_normalized() - .unwrap_or_else(Vec3::unit_y) - - Vec3::unit_z() * 4.0, - ) - .until(Block::is_solid) - .cast() - .0 - < 3.0 - { - 0.9 - } else { - 0.0 - }; - - if agent.bearing.magnitude_squared() > 0.5f32.powi(2) { - controller.inputs.move_dir = agent.bearing * 0.65; - } - - // Put away weapon - if rng.gen_bool(0.1) - && matches!( - read_data.char_states.get(*self.entity), - Some(CharacterState::Wielding(_)) - ) - { - controller.push_action(ControlAction::Unwield); - } - - if rng.gen::() < 0.0015 { - controller.push_utterance(UtteranceKind::Calm); - } - - // Sit - if rng.gen::() < 0.0035 { - controller.push_action(ControlAction::Sit); - } - } - } - - pub fn follow( - &self, - agent: &mut Agent, - controller: &mut Controller, - terrain: &TerrainGrid, - tgt_pos: &Pos, - ) { - if let Some((bearing, speed)) = agent.chaser.chase( - terrain, - self.pos.0, - self.vel.0, - tgt_pos.0, - TraversalConfig { - min_tgt_dist: AVG_FOLLOW_DIST, - ..self.traversal_config - }, - ) { - let dist_sqrd = self.pos.0.distance_squared(tgt_pos.0); - controller.inputs.move_dir = bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) - * speed.min(0.2 + (dist_sqrd - AVG_FOLLOW_DIST.powi(2)) / 8.0); - self.jump_if(bearing.z > 1.5, controller); - controller.inputs.move_z = bearing.z; - } - } - - fn look_toward( - &self, - controller: &mut Controller, - read_data: &ReadData, - target: EcsEntity, - ) -> bool { - if let Some(tgt_pos) = read_data.positions.get(target) { - let eye_offset = self.body.map_or(0.0, |b| b.eye_height()); - let tgt_eye_offset = read_data.bodies.get(target).map_or(0.0, |b| b.eye_height()); - if let Some(dir) = Dir::from_unnormalized( - Vec3::new(tgt_pos.0.x, tgt_pos.0.y, tgt_pos.0.z + tgt_eye_offset) - - Vec3::new(self.pos.0.x, self.pos.0.y, self.pos.0.z + eye_offset), - ) { - controller.inputs.look_dir = dir; - } - true - } else { - false - } - } - - fn menacing( - &self, - agent: &mut Agent, - controller: &mut Controller, - target: EcsEntity, - read_data: &ReadData, - event_emitter: &mut Emitter, - rng: &mut impl Rng, - ) { - let max_move = 0.5; - let move_dir = controller.inputs.move_dir; - let move_dir_mag = move_dir.magnitude(); - let small_chance = rng.gen::() < read_data.dt.0 * 0.25; - let mut chat = |msg: &str| { - self.chat_npc_if_allowed_to_speak(msg.to_string(), agent, event_emitter); - }; - let mut chat_villager_remembers_fighting = || { - let tgt_name = read_data.stats.get(target).map(|stats| stats.name.clone()); - - if let Some(tgt_name) = tgt_name { - chat(format!("{}! How dare you cross me again!", &tgt_name).as_str()); - } else { - chat("You! How dare you cross me again!"); - } - }; - - self.look_toward(controller, read_data, target); - controller.push_action(ControlAction::Wield); - - if move_dir_mag > max_move { - controller.inputs.move_dir = max_move * move_dir / move_dir_mag; - } - - if small_chance { - controller.push_utterance(UtteranceKind::Angry); - if is_villager(self.alignment) { - if self.remembers_fight_with(target, read_data) { - chat_villager_remembers_fighting(); - } else if is_dressed_as_cultist(target, read_data) { - chat("npc-speech-villager_cultist_alarm"); - } else { - chat("npc-speech-menacing"); - } - } else { - chat("npc-speech-menacing"); - } - } - - // Remember target. - self.rtsim_entity.is_some().then(|| { - read_data - .stats - .get(target) - .map(|stats| agent.add_fight_to_memory(&stats.name, read_data.time.0)) - }); - } - - fn flee( - &self, - agent: &mut Agent, - controller: &mut Controller, - tgt_pos: &Pos, - terrain: &TerrainGrid, - ) { - if let Some(body) = self.body { - if body.can_strafe() && !self.is_gliding { - controller.push_action(ControlAction::Unwield); - } - } - - if let Some((bearing, speed)) = agent.chaser.chase( - terrain, - self.pos.0, - self.vel.0, - // Away from the target (ironically) - self.pos.0 - + (self.pos.0 - tgt_pos.0) - .try_normalized() - .unwrap_or_else(Vec3::unit_y) - * 50.0, - TraversalConfig { - min_tgt_dist: 1.25, - ..self.traversal_config - }, - ) { - controller.inputs.move_dir = - bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) * speed; - self.jump_if(bearing.z > 1.5, controller); - controller.inputs.move_z = bearing.z; - } - } - - /// Attempt to consume a healing item, and return whether any healing items - /// were queued. Callers should use this to implement a delay so that - /// the healing isn't interrupted. If `relaxed` is `true`, we allow eating - /// food and prioritise healing. - fn heal_self(&self, _agent: &mut Agent, controller: &mut Controller, relaxed: bool) -> bool { - let healing_value = |item: &Item| { - let mut value = 0.0; - - if let ItemKind::Consumable { kind, effects, .. } = &*item.kind() { - if matches!(kind, ConsumableKind::Drink) - || (relaxed && matches!(kind, ConsumableKind::Food)) - { - for effect in effects.iter() { - use BuffKind::*; - match effect { - Effect::Health(HealthChange { amount, .. }) => { - value += *amount; - }, - Effect::Buff(BuffEffect { kind, data, .. }) - if matches!(kind, Regeneration | Saturation | Potion) => - { - value += data.strength - * data.duration.map_or(0.0, |d| d.as_secs() as f32); - }, - _ => {}, - } - } - } - } - value as i32 - }; - - let item = self - .inventory - .slots_with_id() - .filter_map(|(id, slot)| match slot { - Some(item) if healing_value(item) > 0 => Some((id, item)), - _ => None, - }) - .max_by_key(|(_, item)| { - if relaxed { - -healing_value(item) - } else { - healing_value(item) - } - }); - - if let Some((id, _)) = item { - use comp::inventory::slot::Slot; - controller.push_action(ControlAction::InventoryAction(InventoryAction::Use( - Slot::Inventory(id), - ))); - true - } else { - false - } - } - - fn choose_target( - &self, - agent: &mut Agent, - controller: &mut Controller, - read_data: &ReadData, - event_emitter: &mut Emitter, - ) { - agent.action_state.timer = 0.0; - let mut aggro_on = false; - - // Search the area. - // TODO: choose target by more than just distance - let common::CachedSpatialGrid(grid) = self.cached_spatial_grid; - - let entities_nearby = grid - .in_circle_aabr(self.pos.0.xy(), agent.psyche.search_dist()) - .collect_vec(); - - let can_ambush = |entity: EcsEntity, read_data: &ReadData| { - let self_different_from_entity = || { - read_data - .uids - .get(entity) - .map_or(false, |eu| eu != self.uid) - }; - if self.will_ambush() - && self_different_from_entity() - && !self.passive_towards(entity, read_data) - { - let surrounding_humanoids = entities_nearby - .iter() - .filter(|e| read_data.bodies.get(**e).map_or(false, |b| b.is_humanoid())) - .collect_vec(); - surrounding_humanoids.len() == 2 - && surrounding_humanoids.iter().any(|e| **e == entity) - } else { - false - } - }; - - let get_pos = |entity| read_data.positions.get(entity); - let get_enemy = |(entity, attack_target): (EcsEntity, bool)| { - if attack_target { - if self.is_enemy(entity, read_data) { - Some((entity, true)) - } else if can_ambush(entity, read_data) { - controller.clone().push_utterance(UtteranceKind::Ambush); - self.chat_npc_if_allowed_to_speak( - "npc-speech-ambush".to_string(), - agent, - event_emitter, - ); - aggro_on = true; - Some((entity, true)) - } else if self.should_defend(entity, read_data) { - if let Some(attacker) = get_attacker(entity, read_data) { - if !self.passive_towards(attacker, read_data) { - // aggro_on: attack immediately, do not warn/menace. - aggro_on = true; - Some((attacker, true)) - } else { - None - } - } else { - None - } - } else { - None - } - } else { - Some((entity, false)) - } - }; - let is_valid_target = |entity: EcsEntity| match read_data.bodies.get(entity) { - Some(Body::ItemDrop(item)) => { - //If the agent is humanoid, it will pick up all kinds of item drops. If the - // agent isn't humanoid, it will pick up only consumable item drops. - let wants_pickup = matches!(self.body, Some(Body::Humanoid(_))) - || matches!(item, item_drop::Body::Consumable); - - // The agent will attempt to pickup the item if it wants to pick it up and - // is allowed to - let attempt_pickup = wants_pickup - && read_data - .loot_owners - .get(entity) - .map_or(true, |loot_owner| { - loot_owner.can_pickup( - *self.uid, - read_data.groups.get(entity), - self.alignment, - self.body, - None, - ) - }); - - if attempt_pickup { - Some((entity, false)) - } else { - None - } - }, - _ => { - if read_data.healths.get(entity).map_or(false, |health| { - !health.is_dead && !is_invulnerable(entity, read_data) - }) { - Some((entity, true)) - } else { - None - } - }, - }; - - let can_sense_directly_near = - { |e_pos: &Pos| e_pos.0.distance_squared(self.pos.0) < 5_f32.powi(2) }; - - let is_detected = |entity: EcsEntity, e_pos: &Pos| { - let chance = thread_rng().gen_bool(0.3); - - (can_sense_directly_near(e_pos) && chance) - || self.can_see_entity(agent, controller, entity, e_pos, read_data) - }; - - let target = entities_nearby - .iter() - .filter_map(|e| is_valid_target(*e)) - .filter_map(get_enemy) - .filter_map(|(entity, attack_target)| { - get_pos(entity).map(|pos| (entity, pos, attack_target)) - }) - .filter(|(entity, e_pos, _)| is_detected(*entity, e_pos)) - .min_by_key(|(_, e_pos, attack_target)| { - ( - *attack_target, - (e_pos.0.distance_squared(self.pos.0) * 100.0) as i32, - ) - }) - .map(|(entity, _, attack_target)| (entity, attack_target)); - - if agent.target.is_none() && target.is_some() { - if aggro_on { - controller.push_utterance(UtteranceKind::Angry); - } else { - controller.push_utterance(UtteranceKind::Surprised); - } - } - - agent.target = target.map(|(entity, attack_target)| Target { - target: entity, - hostile: attack_target, - selected_at: read_data.time.0, - aggro_on, - }) - } - - fn attack( - &self, - agent: &mut Agent, - controller: &mut Controller, - tgt_data: &TargetData, - read_data: &ReadData, - rng: &mut impl Rng, - ) { - let tool_tactic = |tool_kind| match tool_kind { - ToolKind::Bow => Tactic::Bow, - ToolKind::Staff => Tactic::Staff, - ToolKind::Sceptre => Tactic::Sceptre, - ToolKind::Hammer => Tactic::Hammer, - ToolKind::Sword | ToolKind::Blowgun => Tactic::Sword, - ToolKind::Axe => Tactic::Axe, - _ => Tactic::SimpleMelee, - }; - - let tactic = self - .inventory - .equipped(EquipSlot::ActiveMainhand) - .as_ref() - .map(|item| { - if let Some(ability_spec) = item.ability_spec() { - match &*ability_spec { - AbilitySpec::Custom(spec) => match spec.as_str() { - "Oni" | "Sword Simple" => Tactic::Sword, - "Staff Simple" => Tactic::Staff, - "Bow Simple" => Tactic::Bow, - "Stone Golem" => Tactic::StoneGolem, - "Quad Med Quick" => Tactic::CircleCharge { - radius: 3, - circle_time: 2, - }, - "Quad Med Jump" => Tactic::QuadMedJump, - "Quad Med Charge" => Tactic::CircleCharge { - radius: 6, - circle_time: 1, - }, - "Quad Med Basic" => Tactic::QuadMedBasic, - "Asp" | "Maneater" => Tactic::QuadLowRanged, - "Quad Low Breathe" | "Quad Low Beam" | "Basilisk" => { - Tactic::QuadLowBeam - }, - "Organ" => Tactic::OrganAura, - "Quad Low Tail" | "Husk Brute" => Tactic::TailSlap, - "Quad Low Quick" => Tactic::QuadLowQuick, - "Quad Low Basic" => Tactic::QuadLowBasic, - "Theropod Basic" | "Theropod Bird" | "Theropod Small" => { - Tactic::Theropod - }, - // Arthropods - "Antlion" => Tactic::ArthropodMelee, - "Tarantula" | "Horn Beetle" => Tactic::ArthropodAmbush, - "Weevil" | "Black Widow" => Tactic::ArthropodRanged, - "Theropod Charge" => Tactic::CircleCharge { - radius: 6, - circle_time: 1, - }, - "Turret" => Tactic::Turret, - "Haniwa Sentry" => Tactic::RotatingTurret, - "Bird Large Breathe" => Tactic::BirdLargeBreathe, - "Bird Large Fire" => Tactic::BirdLargeFire, - "Bird Large Basic" => Tactic::BirdLargeBasic, - "Mindflayer" => Tactic::Mindflayer, - "Minotaur" => Tactic::Minotaur, - "Clay Golem" => Tactic::ClayGolem, - "Tidal Warrior" => Tactic::TidalWarrior, - "Tidal Totem" - | "Tornado" - | "Gnarling Totem Red" - | "Gnarling Totem Green" - | "Gnarling Totem White" => Tactic::RadialTurret, - "Yeti" => Tactic::Yeti, - "Harvester" => Tactic::Harvester, - "Cardinal" => Tactic::Cardinal, - "Dagon" => Tactic::Dagon, - "Gnarling Dagger" => Tactic::SimpleBackstab, - "Gnarling Blowgun" => Tactic::ElevatedRanged, - "Deadwood" => Tactic::Deadwood, - "Mandragora" => Tactic::Mandragora, - "Wood Golem" => Tactic::WoodGolem, - "Gnarling Chieftain" => Tactic::GnarlingChieftain, - _ => Tactic::SimpleMelee, - }, - AbilitySpec::Tool(tool_kind) => tool_tactic(*tool_kind), - } - } else if let ItemKind::Tool(tool) = &*item.kind() { - tool_tactic(tool.kind) - } else { - Tactic::SimpleMelee - } - }) - .unwrap_or(Tactic::SimpleMelee); - - // Wield the weapon as running towards the target - controller.push_action(ControlAction::Wield); - - let min_attack_dist = (self.body.map_or(0.5, |b| b.max_radius()) + DEFAULT_ATTACK_RANGE) - * self.scale - + tgt_data.body.map_or(0.5, |b| b.max_radius()) * tgt_data.scale.map_or(1.0, |s| s.0); - let dist_sqrd = self.pos.0.distance_squared(tgt_data.pos.0); - let angle = self - .ori - .look_vec() - .angle_between(tgt_data.pos.0 - self.pos.0) - .to_degrees(); - let angle_xy = self - .ori - .look_vec() - .xy() - .angle_between((tgt_data.pos.0 - self.pos.0).xy()) - .to_degrees(); - - let eye_offset = self.body.map_or(0.0, |b| b.eye_height()); - - let tgt_eye_height = tgt_data.body.map_or(0.0, |b| b.eye_height()); - let tgt_eye_offset = tgt_eye_height + - // Special case for jumping attacks to jump at the body - // of the target and not the ground around the target - // For the ranged it is to shoot at the feet and not - // the head to get splash damage - if tactic == Tactic::QuadMedJump { - 1.0 - } else if matches!(tactic, Tactic::QuadLowRanged) { - -1.0 - } else { - 0.0 - }; - - // FIXME: - // 1) Retrieve actual projectile speed! - // We have to assume projectiles are faster than base speed because there are - // skills that increase it, and in most cases this will cause agents to - // overshoot - // - // 2) We use eye_offset-s which isn't actually ideal. - // Some attacks (beam for example) may use different offsets, - // we should probably use offsets from corresponding states. - // - // 3) Should we even have this big switch? - // Not all attacks may want their direction overwritten. - // And this is quite hard to debug when you don't see it in actual - // attack handler. - if let Some(dir) = match self.char_state { - CharacterState::ChargedRanged(c) if dist_sqrd > 0.0 => { - let charge_factor = - c.timer.as_secs_f32() / c.static_data.charge_duration.as_secs_f32(); - let projectile_speed = c.static_data.initial_projectile_speed - + charge_factor * c.static_data.scaled_projectile_speed; - aim_projectile( - projectile_speed, - self.pos.0 - + self.body.map_or(Vec3::zero(), |body| { - body.projectile_offsets(self.ori.look_vec()) - }), - Vec3::new( - tgt_data.pos.0.x, - tgt_data.pos.0.y, - tgt_data.pos.0.z + tgt_eye_offset, - ), - ) - }, - CharacterState::BasicRanged(c) => { - let offset_z = match c.static_data.projectile { - // Aim fireballs at feet instead of eyes for splash damage - ProjectileConstructor::Fireball { - damage: _, - radius: _, - energy_regen: _, - min_falloff: _, - } => 0.0, - _ => tgt_eye_offset, - }; - let projectile_speed = c.static_data.projectile_speed; - aim_projectile( - projectile_speed, - self.pos.0 - + self.body.map_or(Vec3::zero(), |body| { - body.projectile_offsets(self.ori.look_vec()) - }), - Vec3::new( - tgt_data.pos.0.x, - tgt_data.pos.0.y, - tgt_data.pos.0.z + offset_z, - ), - ) - }, - CharacterState::RepeaterRanged(c) => { - let projectile_speed = c.static_data.projectile_speed; - aim_projectile( - projectile_speed, - self.pos.0 - + self.body.map_or(Vec3::zero(), |body| { - body.projectile_offsets(self.ori.look_vec()) - }), - Vec3::new( - tgt_data.pos.0.x, - tgt_data.pos.0.y, - tgt_data.pos.0.z + tgt_eye_offset, - ), - ) - }, - CharacterState::LeapMelee(_) if matches!(tactic, Tactic::Hammer | Tactic::Axe) => { - let direction_weight = match tactic { - Tactic::Hammer => 0.1, - Tactic::Axe => 0.3, - _ => unreachable!("Direction weight called on incorrect tactic."), - }; - - let tgt_pos = tgt_data.pos.0; - let self_pos = self.pos.0; - - let delta_x = (tgt_pos.x - self_pos.x) * direction_weight; - let delta_y = (tgt_pos.y - self_pos.y) * direction_weight; - - Dir::from_unnormalized(Vec3::new(delta_x, delta_y, -1.0)) - }, - CharacterState::BasicBeam(_) => { - let aim_from = self.body.map_or(self.pos.0, |body| { - self.pos.0 - + basic_beam::beam_offsets( - body, - controller.inputs.look_dir, - self.ori.look_vec(), - // Try to match animation by getting some context - self.vel.0 - self.physics_state.ground_vel, - self.physics_state.on_ground, - ) - }); - let aim_to = Vec3::new( - tgt_data.pos.0.x, - tgt_data.pos.0.y, - tgt_data.pos.0.z + tgt_eye_offset, - ); - Dir::from_unnormalized(aim_to - aim_from) - }, - _ => { - let aim_from = Vec3::new(self.pos.0.x, self.pos.0.y, self.pos.0.z + eye_offset); - let aim_to = Vec3::new( - tgt_data.pos.0.x, - tgt_data.pos.0.y, - tgt_data.pos.0.z + tgt_eye_offset, - ); - Dir::from_unnormalized(aim_to - aim_from) - }, - } { - controller.inputs.look_dir = dir; - } - - let attack_data = AttackData { - min_attack_dist, - dist_sqrd, - angle, - angle_xy, - }; - - // Match on tactic. Each tactic has different controls depending on the distance - // from the agent to the target. - match tactic { - Tactic::SimpleMelee => { - self.handle_simple_melee(agent, controller, &attack_data, tgt_data, read_data, rng) - }, - Tactic::Axe => { - self.handle_axe_attack(agent, controller, &attack_data, tgt_data, read_data, rng) - }, - Tactic::Hammer => { - self.handle_hammer_attack(agent, controller, &attack_data, tgt_data, read_data, rng) - }, - Tactic::Sword => { - self.handle_sword_attack(agent, controller, &attack_data, tgt_data, read_data, rng) - }, - Tactic::Bow => { - self.handle_bow_attack(agent, controller, &attack_data, tgt_data, read_data, rng) - }, - Tactic::Staff => { - self.handle_staff_attack(agent, controller, &attack_data, tgt_data, read_data, rng) - }, - Tactic::Sceptre => self.handle_sceptre_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - rng, - ), - Tactic::StoneGolem => { - self.handle_stone_golem_attack(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::CircleCharge { - radius, - circle_time, - } => self.handle_circle_charge_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - radius, - circle_time, - rng, - ), - Tactic::QuadLowRanged => self.handle_quadlow_ranged_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - ), - Tactic::TailSlap => { - self.handle_tail_slap_attack(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::QuadLowQuick => self.handle_quadlow_quick_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - ), - Tactic::QuadLowBasic => self.handle_quadlow_basic_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - ), - Tactic::QuadMedJump => self.handle_quadmed_jump_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - ), - Tactic::QuadMedBasic => self.handle_quadmed_basic_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - ), - Tactic::QuadLowBeam => self.handle_quadlow_beam_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - ), - Tactic::OrganAura => { - self.handle_organ_aura_attack(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::Theropod => { - self.handle_theropod_attack(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::ArthropodMelee => self.handle_arthropod_melee_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - ), - Tactic::ArthropodAmbush => self.handle_arthropod_ambush_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - rng, - ), - Tactic::ArthropodRanged => self.handle_arthropod_ranged_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - ), - Tactic::Turret => { - self.handle_turret_attack(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::FixedTurret => self.handle_fixed_turret_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - ), - Tactic::RotatingTurret => { - self.handle_rotating_turret_attack(agent, controller, tgt_data, read_data) - }, - Tactic::Mindflayer => self.handle_mindflayer_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - rng, - ), - Tactic::BirdLargeFire => self.handle_birdlarge_fire_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - rng, - ), - // Mostly identical to BirdLargeFire but tweaked for flamethrower instead of shockwave - Tactic::BirdLargeBreathe => self.handle_birdlarge_breathe_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - rng, - ), - Tactic::BirdLargeBasic => self.handle_birdlarge_basic_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - ), - Tactic::Minotaur => { - self.handle_minotaur_attack(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::ClayGolem => { - self.handle_clay_golem_attack(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::TidalWarrior => self.handle_tidal_warrior_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - ), - Tactic::RadialTurret => self.handle_radial_turret_attack(controller), - Tactic::Yeti => { - self.handle_yeti_attack(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::Harvester => { - self.handle_harvester_attack(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::Cardinal => self.handle_cardinal_attack( - agent, - controller, - &attack_data, - tgt_data, - read_data, - rng, - ), - Tactic::Dagon => { - self.handle_dagon_attack(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::SimpleBackstab => { - self.handle_simple_backstab(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::ElevatedRanged => { - self.handle_elevated_ranged(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::Deadwood => { - self.handle_deadwood(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::Mandragora => { - self.handle_mandragora(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::WoodGolem => { - self.handle_wood_golem(agent, controller, &attack_data, tgt_data, read_data) - }, - Tactic::GnarlingChieftain => self.handle_gnarling_chieftain( - agent, - controller, - &attack_data, - tgt_data, - read_data, - rng, - ), - } - } - - fn handle_sounds_heard( - &self, - agent: &mut Agent, - controller: &mut Controller, - read_data: &ReadData, - rng: &mut impl Rng, - ) { - agent.forget_old_sounds(read_data.time.0); - - if is_invulnerable(*self.entity, read_data) { - self.idle(agent, controller, read_data, rng); - return; - } - - if let Some(sound) = agent.sounds_heard.last() { - let sound_pos = Pos(sound.pos); - let dist_sqrd = self.pos.0.distance_squared(sound_pos.0); - // NOTE: There is an implicit distance requirement given that sound volume - // dissipates as it travels, but we will not want to flee if a sound is super - // loud but heard from a great distance, regardless of how loud it was. - // `is_close` is this limiter. - let is_close = dist_sqrd < 35.0_f32.powi(2); - - let sound_was_loud = sound.vol >= 10.0; - let sound_was_threatening = sound_was_loud - || matches!(sound.kind, SoundKind::Utterance(UtteranceKind::Scream, _)); - - let has_enemy_alignment = matches!(self.alignment, Some(Alignment::Enemy)); - // FIXME: We need to be able to change the name of a guard without breaking this - // logic. The `Mark` enum from common::agent could be used to match with - // `agent::Mark::Guard` - let is_village_guard = read_data - .stats - .get(*self.entity) - .map_or(false, |stats| stats.name == *"Guard".to_string()); - let follows_threatening_sounds = has_enemy_alignment || is_village_guard; - - // TODO: Awareness currently doesn't influence anything. - //agent.awareness += 0.5 * sound.vol; - - if sound_was_threatening && is_close { - if !self.below_flee_health(agent) && follows_threatening_sounds { - self.follow(agent, controller, &read_data.terrain, &sound_pos); - } else if self.below_flee_health(agent) || !follows_threatening_sounds { - self.flee(agent, controller, &sound_pos, &read_data.terrain); - } else { - self.idle(agent, controller, read_data, rng); - } - } else { - self.idle(agent, controller, read_data, rng); - } - } else { - self.idle(agent, controller, read_data, rng); - } - } - - fn attack_target_attacker( - &self, - agent: &mut Agent, - read_data: &ReadData, - controller: &mut Controller, - rng: &mut impl Rng, - ) { - if let Some(Target { target, .. }) = agent.target { - if let Some(tgt_health) = read_data.healths.get(target) { - if let Some(by) = tgt_health.last_change.damage_by() { - if let Some(attacker) = get_entity_by_id(by.uid().0, read_data) { - if agent.target.is_none() { - controller.push_utterance(UtteranceKind::Angry); - } - - agent.target = Some(Target::new(attacker, true, read_data.time.0, true)); - - if let Some(tgt_pos) = read_data.positions.get(attacker) { - if is_dead_or_invulnerable(attacker, read_data) { - // FIXME?: Shouldn't target be set to `None`? - // If is dead, then probably. If invulnerable, maybe not. - agent.target = - Some(Target::new(target, false, read_data.time.0, false)); - - self.idle(agent, controller, read_data, rng); - } else { - let target_data = TargetData::new( - tgt_pos, - read_data.bodies.get(target), - read_data.scales.get(target), - ); - if let Some(tgt_name) = - read_data.stats.get(target).map(|stats| stats.name.clone()) - { - agent.add_fight_to_memory(&tgt_name, read_data.time.0) - } - self.attack(agent, controller, &target_data, read_data, rng); - } - } - } - } - } - } - } - - /// Directs the entity to path and move toward the target - /// If path is not Full, the entity will path to a location 50 units along - /// the vector between the entity and the target. The speed multiplier - /// multiplies the movement speed by a value less than 1.0. - /// A `None` value implies a multiplier of 1.0. - /// Returns `false` if the pathfinding algorithm fails to return a path - fn path_toward_target( - &self, - agent: &mut Agent, - controller: &mut Controller, - tgt_pos: Vec3, - read_data: &ReadData, - path: Path, - speed_multiplier: Option, - ) -> bool { - let partial_path_tgt_pos = |pos_difference: Vec3| { - self.pos.0 - + PARTIAL_PATH_DIST * pos_difference.try_normalized().unwrap_or_else(Vec3::zero) - }; - let pos_difference = tgt_pos - self.pos.0; - let pathing_pos = match path { - Path::Separate => { - let mut sep_vec: Vec3 = Vec3::::zero(); - - for entity in read_data - .cached_spatial_grid - .0 - .in_circle_aabr(self.pos.0.xy(), SEPARATION_DIST) - { - if let (Some(alignment), Some(other_alignment)) = - (self.alignment, read_data.alignments.get(entity)) - { - if Alignment::passive_towards(*alignment, *other_alignment) { - if let (Some(pos), Some(body), Some(other_body)) = ( - read_data.positions.get(entity), - self.body, - read_data.bodies.get(entity), - ) { - let dist_xy = self.pos.0.xy().distance(pos.0.xy()); - let spacing = body.spacing_radius() + other_body.spacing_radius(); - if dist_xy < spacing { - let pos_diff = self.pos.0.xy() - pos.0.xy(); - sep_vec += pos_diff.try_normalized().unwrap_or_else(Vec2::zero) - * ((spacing - dist_xy) / spacing); - } - } - } - } - } - partial_path_tgt_pos( - sep_vec * SEPARATION_BIAS + pos_difference * (1.0 - SEPARATION_BIAS), - ) - }, - Path::Full => tgt_pos, - Path::Partial => partial_path_tgt_pos(pos_difference), - }; - let speed_multiplier = speed_multiplier.unwrap_or(1.0).min(1.0); - if let Some((bearing, speed)) = agent.chaser.chase( - &*read_data.terrain, - self.pos.0, - self.vel.0, - pathing_pos, - TraversalConfig { - min_tgt_dist: 0.25, - ..self.traversal_config - }, - ) { - controller.inputs.move_dir = - bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) * speed * speed_multiplier; - self.jump_if(bearing.z > 1.5, controller); - controller.inputs.move_z = bearing.z; - true - } else { - false - } - } - - fn chat_npc_if_allowed_to_speak( - &self, - msg: impl ToString, - agent: &Agent, - event_emitter: &mut Emitter<'_, ServerEvent>, - ) -> bool { - if agent.allowed_to_speak() { - self.chat_npc(msg, event_emitter); - true - } else { - false - } - } - - fn jump_if(&self, condition: bool, controller: &mut Controller) { - if condition { - controller.push_basic_input(InputKind::Jump); - } else { - controller.push_cancel_input(InputKind::Jump) - } - } - - fn chat_npc(&self, msg: impl ToString, event_emitter: &mut Emitter<'_, ServerEvent>) { - event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( - *self.uid, - msg.to_string(), - ))); - } - - fn emit_scream(&self, time: f64, event_emitter: &mut Emitter<'_, ServerEvent>) { - if let Some(body) = self.body { - event_emitter.emit(ServerEvent::Sound { - sound: Sound::new( - SoundKind::Utterance(UtteranceKind::Scream, *body), - self.pos.0, - 13.0, - time, - ), - }); - } - } - - fn cry_out( - &self, - agent: &Agent, - event_emitter: &mut Emitter<'_, ServerEvent>, - read_data: &ReadData, - ) { - let has_enemy_alignment = matches!(self.alignment, Some(Alignment::Enemy)); - - if has_enemy_alignment { - // FIXME: If going to use "cultist + low health + fleeing" string, make sure - // they are each true. - self.chat_npc_if_allowed_to_speak( - "npc-speech-cultist_low_health_fleeing", - agent, - event_emitter, - ); - } else if is_villager(self.alignment) { - self.chat_npc_if_allowed_to_speak( - "npc-speech-villager_under_attack", - agent, - event_emitter, - ); - self.emit_scream(read_data.time.0, event_emitter); - } - } - - fn exclaim_relief_about_enemy_dead( - &self, - agent: &Agent, - event_emitter: &mut Emitter<'_, ServerEvent>, - ) { - if is_villager(self.alignment) { - self.chat_npc_if_allowed_to_speak( - "npc-speech-villager_enemy_killed", - agent, - event_emitter, - ); - } - } - - fn below_flee_health(&self, agent: &Agent) -> bool { - self.damage.min(1.0) < agent.psyche.flee_health - } - - fn is_more_dangerous_than_target( - &self, - entity: EcsEntity, - target: Target, - read_data: &ReadData, - ) -> bool { - let entity_pos = read_data.positions.get(entity); - let target_pos = read_data.positions.get(target.target); - - entity_pos.map_or(false, |entity_pos| { - target_pos.map_or(true, |target_pos| { - // Fuzzy factor that makes it harder for players to cheese enemies by making - // them quickly flip aggro between two players. - // It does this by only switching aggro if the entity is closer to the enemy by - // a specific proportional threshold. - const FUZZY_DIST_COMPARISON: f32 = 0.8; - - let is_target_further = target_pos.0.distance(entity_pos.0) - < target_pos.0.distance(entity_pos.0) * FUZZY_DIST_COMPARISON; - let is_entity_hostile = read_data - .alignments - .get(entity) - .zip(self.alignment) - .map_or(false, |(entity, me)| me.hostile_towards(*entity)); - - // Consider entity more dangerous than target if entity is closer or if target - // had not triggered aggro. - !target.aggro_on || (is_target_further && is_entity_hostile) - }) - }) - } - - fn remembers_fight_with(&self, other: EcsEntity, read_data: &ReadData) -> bool { - let name = || read_data.stats.get(other).map(|stats| stats.name.clone()); - - self.rtsim_entity.map_or(false, |rtsim_entity| { - name().map_or(false, |name| { - rtsim_entity.brain.remembers_fight_with_character(&name) - }) - }) - } - - fn is_enemy(&self, entity: EcsEntity, read_data: &ReadData) -> bool { - let other_alignment = read_data.alignments.get(entity); - - (entity != *self.entity) - && !self.passive_towards(entity, read_data) - && (are_our_owners_hostile(self.alignment, other_alignment, read_data) - || self.remembers_fight_with(entity, read_data) - || (is_villager(self.alignment) && is_dressed_as_cultist(entity, read_data))) - } - - fn will_ambush(&self) -> bool { - self.health - .map_or(false, |h| h.current() / h.maximum() > 0.7) - && self - .rtsim_entity - .map_or(false, |re| re.brain.personality.will_ambush) - } - - fn should_defend(&self, entity: EcsEntity, read_data: &ReadData) -> bool { - let entity_alignment = read_data.alignments.get(entity); - - let we_are_friendly = entity_alignment.map_or(false, |entity_alignment| { - self.alignment.map_or(false, |alignment| { - !alignment.hostile_towards(*entity_alignment) - }) - }); - let we_share_species = read_data.bodies.get(entity).map_or(false, |entity_body| { - self.body.map_or(false, |body| { - entity_body.is_same_species_as(body) - || (entity_body.is_humanoid() && body.is_humanoid()) - }) - }); - let self_owns_entity = - matches!(entity_alignment, Some(Alignment::Owned(ouid)) if *self.uid == *ouid); - - (we_are_friendly && we_share_species) - || (is_village_guard(*self.entity, read_data) && is_villager(entity_alignment)) - || self_owns_entity - } - - fn passive_towards(&self, entity: EcsEntity, read_data: &ReadData) -> bool { - if let (Some(self_alignment), Some(other_alignment)) = - (self.alignment, read_data.alignments.get(entity)) - { - self_alignment.passive_towards(*other_alignment) - } else { - false - } - } - - fn can_see_entity( - &self, - agent: &Agent, - controller: &Controller, - other: EcsEntity, - other_pos: &Pos, - read_data: &ReadData, - ) -> bool { - let other_stealth_multiplier = { - let other_inventory = read_data.inventories.get(other); - let other_char_state = read_data.char_states.get(other); - - perception_dist_multiplier_from_stealth(other_inventory, other_char_state, self.msm) - }; - - let within_sight_dist = { - let sight_dist = agent.psyche.sight_dist * other_stealth_multiplier; - let dist_sqrd = other_pos.0.distance_squared(self.pos.0); - - dist_sqrd < sight_dist.powi(2) - }; - - let within_fov = (other_pos.0 - self.pos.0) - .try_normalized() - .map_or(false, |v| v.dot(*controller.inputs.look_dir) > 0.15); - - let other_body = read_data.bodies.get(other); - - (within_sight_dist) - && within_fov - && entities_have_line_of_sight(self.pos, self.body, other_pos, other_body, read_data) - } -} diff --git a/server/src/sys/agent/behavior_tree.rs b/server/src/sys/agent/behavior_tree.rs index 0d07272125..42424e4cc2 100644 --- a/server/src/sys/agent/behavior_tree.rs +++ b/server/src/sys/agent/behavior_tree.rs @@ -1,3 +1,4 @@ +use crate::rtsim::Entity as RtSimEntity; use common::{ comp::{ agent::{ @@ -10,7 +11,10 @@ use common::{ path::TraversalConfig, }; use rand::{prelude::ThreadRng, Rng}; -use specs::saveload::{Marker, MarkerAllocator}; +use specs::{ + saveload::{Marker, MarkerAllocator}, + Entity as EcsEntity, +}; use vek::Vec2; use self::interaction::{ @@ -34,6 +38,7 @@ mod interaction; pub struct BehaviorData<'a, 'b, 'c> { pub agent: &'a mut Agent, pub agent_data: AgentData<'a>, + pub rtsim_entity: Option<&'a RtSimEntity>, pub read_data: &'a ReadData<'a>, pub event_emitter: &'a mut Emitter<'c, ServerEvent>, pub controller: &'a mut Controller, @@ -240,10 +245,8 @@ fn target_if_attacked(bdata: &mut BehaviorData) -> bool { } // Remember this attack if we're an RtSim entity - if let Some(attacker_stats) = bdata - .agent_data - .rtsim_entity - .and(bdata.read_data.stats.get(attacker)) + if let Some(attacker_stats) = + bdata.rtsim_entity.and(bdata.read_data.stats.get(attacker)) { bdata .agent @@ -281,11 +284,7 @@ fn untarget_if_dead(bdata: &mut BehaviorData) -> bool { if let Some(tgt_health) = bdata.read_data.healths.get(target) { // If target is dead, forget them if tgt_health.is_dead { - if let Some(tgt_stats) = bdata - .agent_data - .rtsim_entity - .and(bdata.read_data.stats.get(target)) - { + if let Some(tgt_stats) = bdata.rtsim_entity.and(bdata.read_data.stats.get(target)) { bdata.agent.forget_enemy(&tgt_stats.name); } bdata.agent.target = None; @@ -461,6 +460,7 @@ fn handle_timed_events(bdata: &mut BehaviorData) -> bool { bdata.controller, bdata.read_data, bdata.event_emitter, + will_ambush(bdata.rtsim_entity, &bdata.agent_data), ); } else { bdata.agent_data.handle_sounds_heard( @@ -503,6 +503,7 @@ fn do_combat(bdata: &mut BehaviorData) -> bool { let BehaviorData { agent, agent_data, + rtsim_entity, read_data, event_emitter, controller, @@ -581,7 +582,13 @@ fn do_combat(bdata: &mut BehaviorData) -> bool { read_data.time.0 - selected_at > RETARGETING_THRESHOLD_SECONDS; if !in_aggro_range && is_time_to_retarget { - agent_data.choose_target(agent, controller, read_data, event_emitter); + agent_data.choose_target( + agent, + controller, + read_data, + event_emitter, + will_ambush(*rtsim_entity, agent_data), + ); } if aggro_on { @@ -595,10 +602,55 @@ fn do_combat(bdata: &mut BehaviorData) -> bool { tgt_name.map(|tgt_name| agent.add_fight_to_memory(&tgt_name, read_data.time.0)); agent_data.attack(agent, controller, &target_data, read_data, rng); } else { - agent_data.menacing(agent, controller, target, read_data, event_emitter, rng); + agent_data.menacing( + agent, + controller, + target, + read_data, + event_emitter, + rng, + remembers_fight_with(*rtsim_entity, read_data, target), + ); + remember_fight(*rtsim_entity, read_data, agent, target); } } } } false } + +fn will_ambush(rtsim_entity: Option<&RtSimEntity>, agent_data: &AgentData) -> bool { + agent_data + .health + .map_or(false, |h| h.current() / h.maximum() > 0.7) + && rtsim_entity.map_or(false, |re| re.brain.personality.will_ambush) +} + +fn remembers_fight_with( + rtsim_entity: Option<&RtSimEntity>, + read_data: &ReadData, + other: EcsEntity, +) -> bool { + let name = || read_data.stats.get(other).map(|stats| stats.name.clone()); + + rtsim_entity.map_or(false, |rtsim_entity| { + name().map_or(false, |name| { + rtsim_entity.brain.remembers_fight_with_character(&name) + }) + }) +} + +/// Remember target. +fn remember_fight( + rtsim_entity: Option<&RtSimEntity>, + read_data: &ReadData, + agent: &mut Agent, + target: EcsEntity, +) { + rtsim_entity.is_some().then(|| { + read_data + .stats + .get(target) + .map(|stats| agent.add_fight_to_memory(&stats.name, read_data.time.0)) + }); +} diff --git a/server/src/sys/agent/behavior_tree/interaction.rs b/server/src/sys/agent/behavior_tree/interaction.rs index b49a4bdc31..719b6c04d5 100644 --- a/server/src/sys/agent/behavior_tree/interaction.rs +++ b/server/src/sys/agent/behavior_tree/interaction.rs @@ -90,7 +90,7 @@ pub fn handle_inbox_talk(bdata: &mut BehaviorData) -> bool { match subject { Subject::Regular => { if let (Some((_travel_to, destination_name)), Some(rtsim_entity)) = - (&agent.rtsim_controller.travel_to, &agent_data.rtsim_entity) + (&agent.rtsim_controller.travel_to, &bdata.rtsim_entity) { let personality = &rtsim_entity.brain.personality; let standard_response_msg = || -> String { @@ -166,7 +166,7 @@ pub fn handle_inbox_talk(bdata: &mut BehaviorData) -> bool { ); } else { let default_msg = "npc-speech-merchant_busy"; - let msg = agent_data.rtsim_entity.map_or(default_msg, |e| { + let msg = bdata.rtsim_entity.map_or(default_msg, |e| { if e.brain .personality .personality_traits @@ -181,7 +181,7 @@ pub fn handle_inbox_talk(bdata: &mut BehaviorData) -> bool { } } else { let mut rng = thread_rng(); - if let Some(extreme_trait) = agent_data + if let Some(extreme_trait) = bdata .rtsim_entity .and_then(|e| e.brain.personality.random_chat_trait(&mut rng)) { @@ -250,7 +250,7 @@ pub fn handle_inbox_talk(bdata: &mut BehaviorData) -> bool { } }, Subject::Mood => { - if let Some(rtsim_entity) = agent_data.rtsim_entity { + if let Some(rtsim_entity) = bdata.rtsim_entity { if !rtsim_entity.brain.remembers_mood() { // TODO: the following code will need a rework to // implement more mood contexts