From 422e1c30f480afa15d28674db6c7217a6ecd393d Mon Sep 17 00:00:00 2001 From: Joshua Barretto Date: Sat, 31 Jul 2021 20:33:28 +0100 Subject: [PATCH] Added menacing, made agent code less hacky --- CHANGELOG.md | 1 + assets/voxygen/i18n/en/_manifest.ron | 14 +- common/src/comp/agent.rs | 146 +++++---- common/src/comp/controller.rs | 5 +- server/src/events/interaction.rs | 4 +- server/src/settings.rs | 1 + server/src/sys/agent.rs | 427 ++++++++++++++++----------- server/src/sys/terrain.rs | 2 +- 8 files changed, 356 insertions(+), 244 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dd827d282..7fe6a1ebd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pets are now saved on logout 🐕 🦎 🐼 - Dualwielded, one-handed swords as starting weapons (Will be replaced by daggers in the future!) - Healing sceptre crafting recipe +- NPCs can now warn players before engaging in combat ### Changed diff --git a/assets/voxygen/i18n/en/_manifest.ron b/assets/voxygen/i18n/en/_manifest.ron index 087c8d13ef..07d2ef3c07 100644 --- a/assets/voxygen/i18n/en/_manifest.ron +++ b/assets/voxygen/i18n/en/_manifest.ron @@ -42,7 +42,7 @@ "You can type /region or /r to only chat with players a couple of hundred blocks around you.", "Admins can use the /build command to enter build mode.", "You can type /group or /g to only chat with players in your current group.", - "To send private messages type /tell followed by a player name and your message.", + "To send private messages type /tell followed by a player name and your message.", "Keep an eye out for food, chests and other loot spread all around the world!", "Inventory filled with food? Try crafting better food from it!", "Wondering what there is to do? Try out one of the dungeons marked on the map!", @@ -52,7 +52,7 @@ "Press 'L-Shift' to open your Glider and conquer the skies.", "Veloren is still in Pre-Alpha. We do our best to improve it every day!", "If you want to join the dev team or just have a chat with us, join our Discord server.", - "You can toggle showing your amount of health on the healthbar in the settings.", + "You can toggle showing your amount of health on the healthbar in the settings.", "Sit near a campfire (with the 'K' key) to slowly recover from your injuries.", "Need more bags or better armor to continue your journey? Press 'C' to open the crafting menu!", "Try jumping when rolling through creatures.", @@ -228,6 +228,14 @@ "I have destroyed my enemy!", "Finally at peace!", "... now what was I doing?", - ] + ], + "npc.speech.menacing": [ + "I'm warning you!", + "Any closer and I'll attack!", + "You don't scare me!", + "Get away from here!", + "Turn around if you want to live!", + "You're not welcome here!", + ], } ) diff --git a/common/src/comp/agent.rs b/common/src/comp/agent.rs index ba42292175..7226c44a9c 100644 --- a/common/src/comp/agent.rs +++ b/common/src/comp/agent.rs @@ -15,7 +15,6 @@ use super::dialogue::Subject; pub const DEFAULT_INTERACTION_TIME: f32 = 1.0; pub const TRADE_INTERACTION_TIME: f32 = 300.0; -pub const MAX_LISTEN_DIST: f32 = 100.0; #[derive(Copy, Clone, Debug, PartialEq, Deserialize)] pub enum Alignment { @@ -43,6 +42,8 @@ impl Alignment { // Always attacks pub fn hostile_towards(self, other: Alignment) -> bool { match (self, other) { + (Alignment::Passive, _) => false, + (_, Alignment::Passive) => false, (Alignment::Enemy, Alignment::Enemy) => false, (Alignment::Enemy, Alignment::Wild) => false, (Alignment::Wild, Alignment::Enemy) => false, @@ -167,85 +168,107 @@ impl Behavior { #[derive(Clone, Debug, Default)] pub struct Psyche { - pub aggro: f32, // 0.0 = always flees, 1.0 = always attacks, 0.5 = flee at 50% health + /// The proportion of health below which entities will start fleeing. + /// 0.0 = never flees, 1.0 = always flees, 0.5 = flee at 50% health. + pub flee_health: f32, + /// The distance below which the agent will see enemies if it has line of + /// sight. + pub sight_dist: f32, + /// The distance below which the agent can hear enemies without seeing them. + pub listen_dist: f32, + /// The distance below which the agent will attack enemies. Should be lower + /// than `sight_dist`. `None` implied that the agent is always aggro + /// towards enemies that it is aware of. + pub aggro_dist: Option, } impl<'a> From<&'a Body> for Psyche { fn from(body: &'a Body) -> Self { Self { - aggro: match body { + flee_health: match body { Body::Humanoid(humanoid) => match humanoid.species { - humanoid::Species::Danari => 0.9, - humanoid::Species::Dwarf => 0.8, - humanoid::Species::Elf => 0.7, - humanoid::Species::Human => 0.6, - humanoid::Species::Orc => 0.9, - humanoid::Species::Undead => 0.9, + humanoid::Species::Danari => 0.1, + humanoid::Species::Dwarf => 0.2, + humanoid::Species::Elf => 0.3, + humanoid::Species::Human => 0.4, + humanoid::Species::Orc => 0.1, + humanoid::Species::Undead => 0.1, }, Body::QuadrupedSmall(quadruped_small) => match quadruped_small.species { quadruped_small::Species::Pig => 0.5, - quadruped_small::Species::Fox => 0.3, + quadruped_small::Species::Fox => 0.7, quadruped_small::Species::Sheep => 0.5, - quadruped_small::Species::Boar => 0.8, - quadruped_small::Species::Jackalope => 0.4, - quadruped_small::Species::Skunk => 0.6, - quadruped_small::Species::Cat => 0.2, - quadruped_small::Species::Batfox => 0.6, - quadruped_small::Species::Raccoon => 0.4, - quadruped_small::Species::Quokka => 0.4, - quadruped_small::Species::Dodarock => 0.9, - quadruped_small::Species::Holladon => 1.0, - quadruped_small::Species::Hyena => 0.4, - quadruped_small::Species::Rabbit => 0.1, - quadruped_small::Species::Truffler => 0.8, - quadruped_small::Species::Frog => 0.4, - quadruped_small::Species::Hare => 0.2, + quadruped_small::Species::Boar => 0.2, + quadruped_small::Species::Jackalope => 0.6, + quadruped_small::Species::Skunk => 0.4, + quadruped_small::Species::Cat => 0.8, + quadruped_small::Species::Batfox => 0.4, + quadruped_small::Species::Raccoon => 0.6, + quadruped_small::Species::Quokka => 0.6, + quadruped_small::Species::Dodarock => 0.1, + quadruped_small::Species::Holladon => 0.0, + quadruped_small::Species::Hyena => 0.6, + quadruped_small::Species::Rabbit => 0.9, + quadruped_small::Species::Truffler => 0.2, + quadruped_small::Species::Frog => 0.6, + quadruped_small::Species::Hare => 0.8, quadruped_small::Species::Goat => 0.5, - _ => 0.0, + _ => 1.0, }, Body::QuadrupedMedium(quadruped_medium) => match quadruped_medium.species { - quadruped_medium::Species::Tuskram => 0.7, - quadruped_medium::Species::Frostfang => 0.9, - quadruped_medium::Species::Mouflon => 0.7, - quadruped_medium::Species::Catoblepas => 0.8, - quadruped_medium::Species::Deer => 0.6, - quadruped_medium::Species::Hirdrasil => 0.7, - quadruped_medium::Species::Donkey => 0.7, - quadruped_medium::Species::Camel => 0.7, - quadruped_medium::Species::Zebra => 0.7, - quadruped_medium::Species::Antelope => 0.6, - quadruped_medium::Species::Horse => 0.7, - quadruped_medium::Species::Cattle => 0.7, - quadruped_medium::Species::Darkhound => 0.9, - quadruped_medium::Species::Dreadhorn => 0.8, - quadruped_medium::Species::Snowleopard => 0.7, - quadruped_medium::Species::Llama => 0.6, - quadruped_medium::Species::Alpaca => 0.6, + quadruped_medium::Species::Tuskram => 0.3, + quadruped_medium::Species::Frostfang => 0.1, + quadruped_medium::Species::Mouflon => 0.3, + quadruped_medium::Species::Catoblepas => 0.2, + quadruped_medium::Species::Deer => 0.4, + quadruped_medium::Species::Hirdrasil => 0.3, + quadruped_medium::Species::Donkey => 0.3, + quadruped_medium::Species::Camel => 0.3, + quadruped_medium::Species::Zebra => 0.3, + quadruped_medium::Species::Antelope => 0.4, + quadruped_medium::Species::Horse => 0.3, + quadruped_medium::Species::Cattle => 0.3, + quadruped_medium::Species::Darkhound => 0.1, + quadruped_medium::Species::Dreadhorn => 0.2, + quadruped_medium::Species::Snowleopard => 0.3, + quadruped_medium::Species::Llama => 0.4, + quadruped_medium::Species::Alpaca => 0.4, _ => 0.5, }, Body::QuadrupedLow(quadruped_low) => match quadruped_low.species { - quadruped_low::Species::Salamander => 0.7, - quadruped_low::Species::Monitor => 0.7, - quadruped_low::Species::Asp => 0.9, - quadruped_low::Species::Pangolin => 0.4, - _ => 0.6, + quadruped_low::Species::Salamander => 0.3, + quadruped_low::Species::Monitor => 0.3, + quadruped_low::Species::Asp => 0.1, + quadruped_low::Species::Pangolin => 0.6, + _ => 0.4, }, Body::BipedSmall(_) => 0.5, Body::BirdMedium(_) => 0.5, - Body::BirdLarge(_) => 0.9, - Body::FishMedium(_) => 0.15, - Body::FishSmall(_) => 0.0, - Body::BipedLarge(_) => 1.0, - Body::Object(_) => 1.0, - Body::Golem(_) => 1.0, - Body::Theropod(_) => 1.0, - Body::Dragon(_) => 1.0, - Body::Ship(_) => 1.0, + Body::BirdLarge(_) => 0.1, + Body::FishMedium(_) => 0.85, + Body::FishSmall(_) => 1.0, + Body::BipedLarge(_) => 0.0, + Body::Object(_) => 0.0, + Body::Golem(_) => 0.0, + Body::Theropod(_) => 0.0, + Body::Dragon(_) => 0.0, + Body::Ship(_) => 0.0, + }, + sight_dist: 40.0, + listen_dist: 30.0, + aggro_dist: match body { + Body::Humanoid(_) => Some(20.0), + _ => None, // Always aggressive if detected }, } } } +impl Psyche { + /// The maximum distance that targets might be detected by this agent. + pub fn search_dist(&self) -> f32 { self.sight_dist.max(self.listen_dist) } +} + #[derive(Clone, Debug)] /// Events that affect agent behavior from other entities/players/environment pub enum AgentEvent { @@ -309,8 +332,12 @@ pub enum SoundKind { #[derive(Clone, Copy, Debug)] pub struct Target { pub target: EcsEntity, + /// Whether the target is hostile pub hostile: bool, + /// The time at which the target was selected pub selected_at: f64, + /// Whether the target has come close enough to trigger aggro. + pub aggro_on: bool, } #[allow(clippy::type_complexity)] @@ -345,7 +372,7 @@ impl Agent { } pub fn with_destination(mut self, pos: Vec3) -> Self { - self.psyche = Psyche { aggro: 1.0 }; + self.psyche.flee_health = 0.0; self.rtsim_controller = RtSimController::with_destination(pos); self.behavior.allow(BehaviorCapability::SPEAK); self @@ -369,7 +396,10 @@ impl Agent { Agent { patrol_origin, psyche: if no_flee { - Psyche { aggro: 1.0 } + Psyche { + flee_health: 0.0, + ..Psyche::from(body) + } } else { Psyche::from(body) }, diff --git a/common/src/comp/controller.rs b/common/src/comp/controller.rs index f07608a361..54604d49f1 100644 --- a/common/src/comp/controller.rs +++ b/common/src/comp/controller.rs @@ -242,7 +242,10 @@ impl ControllerInputs { impl Controller { /// Sets all inputs to default - pub fn reset(&mut self) { *self = Self::default(); } + pub fn reset(&mut self) { + self.inputs = Default::default(); + self.queued_inputs = Default::default(); + } pub fn clear_events(&mut self) { self.events.clear(); } diff --git a/server/src/events/interaction.rs b/server/src/events/interaction.rs index 11f1942349..4a3e6b2f0b 100644 --- a/server/src/events/interaction.rs +++ b/server/src/events/interaction.rs @@ -6,7 +6,7 @@ use common::{ assets, comp::{ self, - agent::{AgentEvent, Sound, SoundKind, MAX_LISTEN_DIST}, + agent::{AgentEvent, Sound, SoundKind}, dialogue::Subject, inventory::slot::EquipSlot, item, @@ -408,7 +408,7 @@ pub fn handle_sound(server: &mut Server, sound: &Sound) { let propagated_sound = sound.with_new_vol(sound.vol - vol_dropoff); let can_hear_sound = propagated_sound.vol > 0.00; - let should_hear_sound = agent_dist_sqrd < MAX_LISTEN_DIST.powi(2); + let should_hear_sound = agent_dist_sqrd < agent.psyche.listen_dist.powi(2); if can_hear_sound && should_hear_sound { agent diff --git a/server/src/settings.rs b/server/src/settings.rs index 8df943a502..ce66969277 100644 --- a/server/src/settings.rs +++ b/server/src/settings.rs @@ -159,6 +159,7 @@ impl Settings { max_players: 100, start_time: 9.0 * 3600.0, max_view_distance: None, + safe_spawn: false, client_timeout: Duration::from_secs(180), ..load // Fill in remaining fields from server_settings.ron. } diff --git a/server/src/sys/agent.rs b/server/src/sys/agent.rs index 0ca68b358a..77468133fb 100644 --- a/server/src/sys/agent.rs +++ b/server/src/sys/agent.rs @@ -3,8 +3,7 @@ use common::{ comp::{ self, agent::{ - AgentEvent, Sound, SoundKind, Target, DEFAULT_INTERACTION_TIME, MAX_LISTEN_DIST, - TRADE_INTERACTION_TIME, + AgentEvent, Sound, SoundKind, Target, DEFAULT_INTERACTION_TIME, TRADE_INTERACTION_TIME, }, buff::{BuffKind, Buffs}, compass::{Direction, Distance}, @@ -169,7 +168,6 @@ const PARTIAL_PATH_DIST: f32 = 50.0; const SEPARATION_DIST: f32 = 10.0; const SEPARATION_BIAS: f32 = 0.8; const MAX_FLEE_DIST: f32 = 20.0; -const SEARCH_DIST: f32 = 48.0; const SNEAK_COEFFICIENT: f32 = 0.25; const AVG_FOLLOW_DIST: f32 = 6.0; const RETARGETING_THRESHOLD_SECONDS: f64 = 10.0; @@ -270,7 +268,7 @@ impl<'a> System<'a> for Sys { ) }; - let event_emitter = event_bus.emitter(); + let mut event_emitter = event_bus.emitter(); if !matches!(char_state, CharacterState::LeapMelee(_)) { // Default to looking in orientation direction @@ -381,44 +379,45 @@ impl<'a> System<'a> for Sys { // are the only parts of this tree that should provide // inputs. - let idle = |agent: &mut Agent, controller, mut event_emitter| { - data.idle_tree(agent, controller, &read_data, &mut event_emitter); - }; - - let relax = |agent: &mut Agent, controller, event_emitter| { - agent.target = None; - idle(agent, controller, event_emitter); - }; - - let react_as_pet = - |agent: &mut Agent, target: EcsEntity, controller, event_emitter| { - if let Some(tgt_pos) = read_data.positions.get(target) { - let dist_sqrd = pos.0.distance_squared(tgt_pos.0); - // If really far away drop everything and follow - if dist_sqrd > (2.0 * MAX_FOLLOW_DIST).powi(2) { - agent.bearing = Vec2::zero(); - data.follow(agent, controller, &read_data.terrain, tgt_pos); - // Attack target's attacker - } else if entity_was_attacked(target, &read_data) { - data.attack_target_attacker(agent, &read_data, controller); - // Follow owner if too far away and not - // fighting - } else if dist_sqrd > MAX_FOLLOW_DIST.powi(2) { - data.follow(agent, controller, &read_data.terrain, tgt_pos); - - // Otherwise just idle - } else { - idle(agent, controller, event_emitter); - } - } + let idle = + |agent: &mut Agent, + controller: &mut Controller, + event_emitter: &mut Emitter| { + agent.target = None; + data.idle_tree(agent, controller, &read_data, event_emitter); }; + let react_as_pet = |agent: &mut Agent, + target: EcsEntity, + controller: &mut Controller, + event_emitter| { + if let Some(tgt_pos) = read_data.positions.get(target) { + let dist_sqrd = pos.0.distance_squared(tgt_pos.0); + // If really far away drop everything and follow + if dist_sqrd > (2.0 * MAX_FOLLOW_DIST).powi(2) { + agent.bearing = Vec2::zero(); + data.follow(agent, controller, &read_data.terrain, tgt_pos); + // Attack target's attacker + } else if entity_was_attacked(target, &read_data) { + data.attack_target_attacker(agent, &read_data, controller); + // Follow owner if too far away and not + // fighting + } else if dist_sqrd > MAX_FOLLOW_DIST.powi(2) { + data.follow(agent, controller, &read_data.terrain, tgt_pos); + + // Otherwise just idle + } else { + idle(agent, controller, event_emitter); + } + } + }; + let react_to_target = |agent: &mut Agent, target: EcsEntity, hostile: bool, - controller, - mut event_emitter| { + controller: &mut Controller, + event_emitter| { if let Some(tgt_health) = read_data.healths.get(target) { // If target is dead, leave it if tgt_health.is_dead { @@ -427,69 +426,27 @@ impl<'a> System<'a> for Sys { { rtsim_forget_enemy(&tgt_stats.name, agent); } - relax(agent, controller, event_emitter); + agent.target = None; // If the target is hostile // (either based on alignment or if // the target just attacked) } else if hostile { - data.hostile_tree( - agent, - controller, - &read_data, - &mut event_emitter, - ); + data.hostile_tree(agent, controller, &read_data, event_emitter); // Target is something worth following // methinks } else if let Some(Alignment::Owned(uid)) = data.alignment { if read_data.uids.get(target) == Some(uid) { react_as_pet(agent, target, controller, event_emitter); } else { - relax(agent, controller, event_emitter); + agent.target = None; + idle(agent, controller, event_emitter); }; } else { idle(agent, controller, event_emitter); } } else { - relax(agent, controller, event_emitter); - } - }; - - let react_to_damage = - |agent: &mut Agent, by: Uid, controller, event_emitter| { - if let Some(attacker) = - read_data.uid_allocator.retrieve_entity_internal(by.id()) - { - // If the target is dead or in a safezone, remove the - // target and idle. - if should_stop_attacking(attacker, &read_data) { - relax(agent, controller, event_emitter); - } else if let Some(tgt_pos) = read_data.positions.get(attacker) { - if agent.target.is_none() { - controller.push_event(ControlEvent::Utterance( - UtteranceKind::Angry, - )); - } - - agent.target = Some(Target { - target: attacker, - hostile: true, - selected_at: read_data.time.0, - }); - let target_data = TargetData { - pos: tgt_pos, - body: read_data.bodies.get(attacker), - scale: read_data.scales.get(attacker), - }; - data.attack(agent, controller, &target_data, &read_data); - // Remember this encounter if an RtSim entity - if let Some(tgt_stats) = - data.rtsim_entity.and(read_data.stats.get(attacker)) - { - rtsim_new_enemy(&tgt_stats.name, agent, &read_data); - } - } else { - relax(agent, controller, event_emitter); - } + agent.target = None; + idle(agent, controller, event_emitter); } }; @@ -507,25 +464,75 @@ impl<'a> System<'a> for Sys { data.fly_upward(controller) } else if is_falling_dangerous && data.glider_equipped { data.glider_fall(controller); - } else if let Some(target_info) = agent.target { - let Target { - target, hostile, .. - } = target_info; - react_to_target(agent, target, hostile, controller, event_emitter); } else { // Target an entity that's attacking us if the attack // was recent and we have a health component match health { Some(health) if health.last_change.0 < DAMAGE_MEMORY_DURATION => { if let Some(by) = health.last_change.1.cause.damage_by() { - react_to_damage(agent, by, controller, event_emitter); - } else { - relax(agent, controller, event_emitter); + if let Some(attacker) = + read_data.uid_allocator.retrieve_entity_internal(by.id()) + { + // If the target is dead or in a safezone, remove the + // target and idle. + if should_stop_attacking(attacker, &read_data) { + agent.target = None; + } else if let Some(tgt_pos) = + read_data.positions.get(attacker) + { + if agent.target.is_none() { + controller.push_event(ControlEvent::Utterance( + UtteranceKind::Angry, + )); + } + + // Determine whether the new target should be a priority + // over the old one + // (i.e: because it's either close or because they + // attacked us) + let more_dangerous_than_old_target = + agent.target.map_or(true, |old_tgt| { + if let Some(old_tgt_pos) = + read_data.positions.get(old_tgt.target) + { + !old_tgt.aggro_on + || old_tgt_pos.0.distance_squared(pos.0) + < tgt_pos.0.distance_squared(pos.0) + } else { + true + } + }); + + // Select the attacker as the new target + if more_dangerous_than_old_target { + agent.target = Some(Target { + target: attacker, + hostile: true, + selected_at: read_data.time.0, + aggro_on: true, + }); + } + + // Remember this attack if we're an RtSim entity + if let Some(tgt_stats) = + data.rtsim_entity.and(read_data.stats.get(attacker)) + { + rtsim_new_enemy(&tgt_stats.name, agent, &read_data); + } + } + } } }, - _ => { - idle(agent, controller, event_emitter); - }, + _ => {}, + } + + if let Some(target_info) = agent.target { + let Target { + target, hostile, .. + } = target_info; + react_to_target(agent, target, hostile, controller, &mut event_emitter); + } else { + idle(agent, controller, &mut event_emitter); } } @@ -572,7 +579,7 @@ impl<'a> AgentData<'a> { if agent.target.is_none() && thread_rng().gen_bool(0.1) { if let Some(Alignment::Owned(owner)) = self.alignment { if let Some(owner) = get_entity_by_id(owner.id(), read_data) { - agent.target = build_target(owner, false, read_data.time.0); + agent.target = build_target(owner, false, read_data.time.0, false); } } } @@ -647,19 +654,34 @@ impl<'a> AgentData<'a> { if let Some(Target { target, selected_at, + aggro_on, .. - }) = agent.target + }) = &mut agent.target { + let target = *target; + let selected_at = *selected_at; + if let Some(tgt_pos) = read_data.positions.get(target) { - let dist_sqrd = self.pos.0.distance_squared(tgt_pos.0); - // Should the agent flee? - if 1.0 - agent.psyche.aggro > self.damage && self.flees { + let dist_sq = self.pos.0.distance_squared(tgt_pos.0); + let in_aggro_range = agent + .psyche + .aggro_dist + .map_or(true, |ad| dist_sq < ad.powi(2)); + // If, at any point, the target comes closer than the aggro distance, switch to + // aggro mode + if in_aggro_range { + *aggro_on = true; + } + let aggro_on = *aggro_on; + + if self.damage.min(1.0) < agent.psyche.flee_health && self.flees { + // Should the agent flee? if agent.action_state.timer == 0.0 && can_speak(agent) { - let msg = "npc.speech.villager_under_attack".to_string(); - self.chat_general(msg, event_emitter); + self.chat_general("npc.speech.villager_under_attack", event_emitter); self.emit_villager_alarm(read_data.time.0, event_emitter); agent.action_state.timer = 0.01; - } else if agent.action_state.timer < FLEE_DURATION || dist_sqrd < MAX_FLEE_DIST + } else if agent.action_state.timer < FLEE_DURATION + || dist_sq < MAX_FLEE_DIST.powi(2) { self.flee(agent, controller, &read_data.terrain, tgt_pos); agent.action_state.timer += read_data.dt.0; @@ -668,32 +690,36 @@ impl<'a> AgentData<'a> { agent.target = None; self.idle(agent, controller, read_data); } - - // If not fleeing, attack the hostile entity! + } else if should_stop_attacking(target, read_data) { + if can_speak(agent) { + let msg = "npc.speech.villager_enemy_killed".to_string(); + event_emitter + .emit(ServerEvent::Chat(UnresolvedChatMsg::npc(*self.uid, msg))); + } + agent.target = None; + self.idle(agent, controller, read_data); } else { - // If the hostile entity is dead or has an invulnerability buff (eg, those - // applied in safezones), return to idle - if should_stop_attacking(target, read_data) { - if can_speak(agent) { - let msg = "npc.speech.villager_enemy_killed".to_string(); - event_emitter - .emit(ServerEvent::Chat(UnresolvedChatMsg::npc(*self.uid, msg))); - } - agent.target = None; - self.idle(agent, controller, read_data); - // Choose a new target every 10 seconds, but only for - // enemies - // TODO: This should be more principled. Consider factoring - // health, combat rating, wielded weapon, etc, into the - // decision to change target. - } else if read_data.time.0 - selected_at > RETARGETING_THRESHOLD_SECONDS - && matches!(self.alignment, Some(Alignment::Enemy)) + // Potentially choose a new target + if read_data.time.0 - selected_at > RETARGETING_THRESHOLD_SECONDS + && !in_aggro_range { self.choose_target(agent, controller, read_data, event_emitter); - } else { - // TODO Add utility for attacking vs leaving target alone + } + + if aggro_on { let target_data = build_target_data(target, tgt_pos, read_data); self.attack(agent, controller, &target_data, read_data); + } else { + // If we're not yet aggro-ed, strike a menacing pose + if thread_rng().gen::() < read_data.dt.0 * 0.25 { + self.chat_general_if_can_speak( + agent, + "npc.speech.menacing", + event_emitter, + ); + controller.push_event(ControlEvent::Utterance(UtteranceKind::Angry)); + } + self.menacing(agent, controller, read_data, target, tgt_pos); } } } @@ -969,9 +995,9 @@ impl<'a> AgentData<'a> { Some(AgentEvent::Talk(by, subject)) => { if can_speak(agent) { if let Some(target) = get_entity_by_id(by.id(), read_data) { - agent.target = build_target(target, false, read_data.time.0); + agent.target = build_target(target, false, read_data.time.0, false); - if self.look_toward(controller, read_data, &target) { + if self.look_toward(controller, read_data, target) { controller.actions.push(ControlAction::Stand); controller.actions.push(ControlAction::Talk); controller.push_event(ControlEvent::Utterance(UtteranceKind::Greeting)); @@ -1017,11 +1043,12 @@ impl<'a> AgentData<'a> { }; self.chat_general(msg, event_emitter); } else if agent.behavior.can_trade() { - let msg = "npc.speech.merchant_advertisement".to_string(); - self.chat_general(msg, event_emitter); + self.chat_general( + "npc.speech.merchant_advertisement", + event_emitter, + ); } else { - let msg = "npc.speech.villager".to_string(); - self.chat_general(msg, event_emitter); + self.chat_general("npc.speech.villager", event_emitter); } }, Subject::Trade => { @@ -1031,18 +1058,23 @@ impl<'a> AgentData<'a> { by, InviteKind::Trade, )); - let msg = - "npc.speech.merchant_advertisement".to_string(); - self.chat_general(msg, event_emitter); + self.chat_general( + "npc.speech.merchant_advertisement", + event_emitter, + ); } else { - let msg = "npc.speech.merchant_busy".to_string(); - self.chat_general(msg, event_emitter); + self.chat_general( + "npc.speech.merchant_busy", + event_emitter, + ); } } else { // TODO: maybe make some travellers willing to trade with // simpler goods like potions - let msg = "npc.speech.villager_decline_trade".to_string(); - self.chat_general(msg, event_emitter); + self.chat_general( + "npc.speech.villager_decline_trade", + event_emitter, + ); } }, Subject::Mood => { @@ -1156,7 +1188,7 @@ impl<'a> AgentData<'a> { controller.actions.push(ControlAction::Stand); controller.actions.push(ControlAction::Talk); if let Some(target) = get_entity_by_id(with.id(), read_data) { - agent.target = build_target(target, false, read_data.time.0); + agent.target = build_target(target, false, read_data.time.0, false); } controller .events @@ -1167,22 +1199,28 @@ impl<'a> AgentData<'a> { controller .events .push(ControlEvent::InviteResponse(InviteResponse::Decline)); - let msg = "npc.speech.merchant_busy".to_string(); - self.chat_general_if_can_speak(agent, msg, event_emitter); + self.chat_general_if_can_speak( + agent, + "npc.speech.merchant_busy", + event_emitter, + ); } } else { // TODO: Provide a hint where to find the closest merchant? controller .events .push(ControlEvent::InviteResponse(InviteResponse::Decline)); - let msg = "npc.speech.villager_decline_trade".to_string(); - self.chat_general_if_can_speak(agent, msg, event_emitter); + self.chat_general_if_can_speak( + agent, + "npc.speech.villager_decline_trade", + event_emitter, + ); } }, Some(AgentEvent::TradeAccepted(with)) => { if !agent.behavior.is(BehaviorState::TRADING) { if let Some(target) = get_entity_by_id(with.id(), read_data) { - agent.target = build_target(target, false, read_data.time.0); + agent.target = build_target(target, false, read_data.time.0, false); } agent.behavior.set(BehaviorState::TRADING); agent.behavior.set(BehaviorState::TRADING_ISSUER); @@ -1192,12 +1230,13 @@ impl<'a> AgentData<'a> { if agent.behavior.is(BehaviorState::TRADING) { match result { TradeResult::Completed => { - let msg = "npc.speech.merchant_trade_successful".to_string(); - self.chat_general(msg, event_emitter); + self.chat_general( + "npc.speech.merchant_trade_successful", + event_emitter, + ); }, _ => { - let msg = "npc.speech.merchant_trade_declined".to_string(); - self.chat_general(msg, event_emitter); + self.chat_general("npc.speech.merchant_trade_declined", event_emitter); }, } agent.behavior.unset(BehaviorState::TRADING); @@ -1268,7 +1307,7 @@ impl<'a> AgentData<'a> { // No new events, continue looking towards the last // interacting player for some time if let Some(Target { target, .. }) = &agent.target { - self.look_toward(controller, read_data, target); + self.look_toward(controller, read_data, *target); } else { agent.action_state.timer = 0.0; } @@ -1281,14 +1320,11 @@ impl<'a> AgentData<'a> { &self, controller: &mut Controller, read_data: &ReadData, - target: &EcsEntity, + target: EcsEntity, ) -> bool { - if let Some(tgt_pos) = read_data.positions.get(*target) { + 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()); + 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), @@ -1301,6 +1337,24 @@ impl<'a> AgentData<'a> { } } + fn menacing( + &self, + _agent: &mut Agent, + controller: &mut Controller, + read_data: &ReadData, + target: EcsEntity, + _tgt_pos: &Pos, + ) { + self.look_toward(controller, read_data, target); + controller.actions.push(ControlAction::Wield); + + let max_move = 0.5; + let move_dir_mag = controller.inputs.move_dir.magnitude(); + if move_dir_mag > max_move { + controller.inputs.move_dir = max_move * controller.inputs.move_dir / move_dir_mag; + } + } + fn flee( &self, agent: &mut Agent, @@ -1431,34 +1485,41 @@ impl<'a> AgentData<'a> { }) }; - let in_search_area = |e_pos: &Pos, e_char_state: Option<&CharacterState>| { - let mut search_dist = SEARCH_DIST; - if e_char_state.map_or(false, CharacterState::is_stealthy) { - // TODO: make sneak more effective based on a stat like e_stats.fitness - search_dist *= SNEAK_COEFFICIENT; - }; + let max_search_dist = agent.psyche.search_dist(); + let max_sight_dist = agent.psyche.sight_dist; + let max_listen_dist = agent.psyche.listen_dist; + let in_sight_dist = |e_pos: &Pos, e_char_state: Option<&CharacterState>| { + let search_dist = max_sight_dist + * if e_char_state.map_or(false, CharacterState::is_stealthy) { + // TODO: make sneak more effective based on a stat like e_stats.fitness + SNEAK_COEFFICIENT + } else { + 1.0 + }; e_pos.0.distance_squared(self.pos.0) < search_dist.powi(2) }; - let within_view = |e_pos: &Pos| { + let within_fov = |e_pos: &Pos| { (e_pos.0 - self.pos.0) .try_normalized() .map_or(true, |v| v.dot(*controller.inputs.look_dir) > 0.15) }; - let in_listen_area = |e_pos: &Pos, e_char_state: Option<&CharacterState>| { - let mut listen_dist = MAX_LISTEN_DIST; - if e_char_state.map_or(false, CharacterState::is_stealthy) { - // TODO: make sneak more effective based on a stat like e_stats.fitness - listen_dist *= SNEAK_COEFFICIENT; - } + let in_listen_dist = |e_pos: &Pos, e_char_state: Option<&CharacterState>| { + let listen_dist = max_listen_dist + * if e_char_state.map_or(false, CharacterState::is_stealthy) { + // TODO: make sneak more effective based on a stat like e_stats.fitness + SNEAK_COEFFICIENT + } else { + 1.0 + }; // TODO implement proper sound system for agents e_pos.0.distance_squared(self.pos.0) < listen_dist.powi(2) }; let within_reach = |e_pos: &Pos, e_char_state: Option<&CharacterState>| { - (in_search_area(e_pos, e_char_state) && within_view(e_pos)) - || in_listen_area(e_pos, e_char_state) + (in_sight_dist(e_pos, e_char_state) && within_fov(e_pos)) + || in_listen_dist(e_pos, e_char_state) }; let owners_hostile = |e_alignment: Option<&Alignment>| { @@ -1594,9 +1655,10 @@ impl<'a> AgentData<'a> { // TODO choose target by more than just distance let common::CachedSpatialGrid(grid) = self.cached_spatial_grid; let target = grid - .in_circle_aabr(self.pos.0.xy(), SEARCH_DIST) + .in_circle_aabr(self.pos.0.xy(), max_search_dist) .filter_map(worth_choosing) .filter_map(possible_target) + // TODO: This seems expensive. Cache this to avoid recomputing each tick .filter(|(_, e_pos)| can_see_them(e_pos)) .min_by_key(|(_, e_pos)| (e_pos.0.distance_squared(self.pos.0) * 100.0) as i32) .map(|(e, _)| e); @@ -1609,6 +1671,7 @@ impl<'a> AgentData<'a> { target, hostile: true, selected_at: read_data.time.0, + aggro_on: false, }); } @@ -4017,11 +4080,13 @@ impl<'a> AgentData<'a> { } else if is_village_guard { self.follow(agent, controller, &read_data.terrain, &sound_pos); } else if is_neutral { - let aggro = agent.psyche.aggro; + let flee_health = agent.psyche.flee_health; let close_enough = dist_sqrd < 35.0_f32.powi(2); let sound_was_loud = sound.vol >= 10.0; - if close_enough && (aggro <= 0.5 || (aggro <= 0.7 && sound_was_loud)) { + if close_enough + && (flee_health <= 0.7 || (flee_health <= 0.5 && sound_was_loud)) + { self.flee(agent, controller, &read_data.terrain, &sound_pos); } else { self.idle(agent, controller, read_data); @@ -4048,11 +4113,11 @@ impl<'a> AgentData<'a> { controller.push_event(ControlEvent::Utterance(UtteranceKind::Angry)); } - agent.target = build_target(attacker, true, read_data.time.0); + agent.target = build_target(attacker, true, read_data.time.0, true); if let Some(tgt_pos) = read_data.positions.get(attacker) { if should_stop_attacking(attacker, read_data) { - agent.target = build_target(target, false, read_data.time.0); + agent.target = build_target(target, false, read_data.time.0, false); self.idle(agent, controller, read_data); } else { @@ -4154,11 +4219,11 @@ impl<'a> AgentData<'a> { fn chat_general_if_can_speak( &self, agent: &Agent, - msg: String, + msg: impl ToString, event_emitter: &mut Emitter<'_, ServerEvent>, ) -> bool { if can_speak(agent) { - event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc(*self.uid, msg))); + self.chat_general(msg, event_emitter); true } else { false @@ -4177,8 +4242,11 @@ impl<'a> AgentData<'a> { } } - fn chat_general(&self, msg: String, event_emitter: &mut Emitter<'_, ServerEvent>) { - event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc(*self.uid, msg))); + fn chat_general(&self, msg: impl ToString, event_emitter: &mut Emitter<'_, ServerEvent>) { + event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( + *self.uid, + msg.to_string(), + ))); } fn emit_villager_alarm(&self, time: f64, event_emitter: &mut Emitter<'_, ServerEvent>) { @@ -4304,11 +4372,12 @@ fn entity_was_attacked(entity: EcsEntity, read_data: &ReadData) -> bool { } } -fn build_target(target: EcsEntity, is_hostile: bool, time: f64) -> Option { +fn build_target(target: EcsEntity, is_hostile: bool, time: f64, aggro_on: bool) -> Option { Some(Target { target, hostile: is_hostile, selected_at: time, + aggro_on, }) } diff --git a/server/src/sys/terrain.rs b/server/src/sys/terrain.rs index 0f3ea5e3a5..589b742592 100644 --- a/server/src/sys/terrain.rs +++ b/server/src/sys/terrain.rs @@ -265,7 +265,7 @@ impl<'a> System<'a> for Sys { let poise = comp::Poise::new(body); let can_speak = match body { - comp::Body::Humanoid(_) => alignment == comp::Alignment::Npc, + comp::Body::Humanoid(_) => true, comp::Body::BirdMedium(bird_medium) => match bird_medium.species { // Parrots like to have a word in this, too... bird_medium::Species::Parrot => alignment == comp::Alignment::Npc,