diff --git a/CHANGELOG.md b/CHANGELOG.md index f7b124f829..06727e531e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Trading over long distances using ghost characters or client-side exploits is no longer possible - Merchant cost percentages displayed as floored, whole numbers - Bodies of water no longer contain black chunks on the voxel minimap. +- Agents can flee once again, and more appropriately ## [0.11.0] - 2021-09-11 diff --git a/assets/voxygen/i18n/en/npc.ron b/assets/voxygen/i18n/en/npc.ron index b2f5683c05..1b508fc50a 100644 --- a/assets/voxygen/i18n/en/npc.ron +++ b/assets/voxygen/i18n/en/npc.ron @@ -186,5 +186,13 @@ "Turn around if you want to live!", "You're not welcome here!", ], + "npc.speech.cultist_low_health_fleeing": [ + "Retreat for the cause!", + "Retreat!", + "Curse you!", + "I will curse you in the afterlife!", + "I must rest!", + "They're too strong!", + ] } ) diff --git a/common/src/comp/agent.rs b/common/src/comp/agent.rs index 556537ed63..89255e99a9 100644 --- a/common/src/comp/agent.rs +++ b/common/src/comp/agent.rs @@ -1,5 +1,8 @@ use crate::{ - comp::{humanoid, quadruped_low, quadruped_medium, quadruped_small, ship, Body, UtteranceKind}, + comp::{ + biped_small, bird_medium, humanoid, quadruped_low, quadruped_medium, quadruped_small, ship, + Body, UtteranceKind, + }, path::Chaser, rtsim::RtSimController, trade::{PendingTrade, ReducedInventory, SiteId, SitePrices, TradeId, TradeResult}, @@ -189,72 +192,82 @@ impl<'a> From<&'a Body> for Psyche { Self { flee_health: match body { Body::Humanoid(humanoid) => match humanoid.species { - humanoid::Species::Danari => 0.1, - humanoid::Species::Dwarf => 0.2, - humanoid::Species::Elf => 0.3, + humanoid::Species::Danari => 0.4, + humanoid::Species::Dwarf => 0.3, + humanoid::Species::Elf => 0.4, humanoid::Species::Human => 0.4, - humanoid::Species::Orc => 0.1, - humanoid::Species::Undead => 0.1, + humanoid::Species::Orc => 0.3, + humanoid::Species::Undead => 0.3, }, Body::QuadrupedSmall(quadruped_small) => match quadruped_small.species { quadruped_small::Species::Pig => 0.5, quadruped_small::Species::Fox => 0.7, - quadruped_small::Species::Sheep => 0.5, - quadruped_small::Species::Boar => 0.2, - quadruped_small::Species::Jackalope => 0.6, + quadruped_small::Species::Sheep => 0.6, + quadruped_small::Species::Boar => 0.1, + quadruped_small::Species::Jackalope => 0.0, quadruped_small::Species::Skunk => 0.4, - quadruped_small::Species::Cat => 0.8, - quadruped_small::Species::Batfox => 0.4, + quadruped_small::Species::Cat => 0.9, + quadruped_small::Species::Batfox => 0.1, quadruped_small::Species::Raccoon => 0.6, - quadruped_small::Species::Quokka => 0.6, - quadruped_small::Species::Dodarock => 0.1, + quadruped_small::Species::Dodarock => 0.0, quadruped_small::Species::Holladon => 0.0, - quadruped_small::Species::Hyena => 0.6, - quadruped_small::Species::Rabbit => 0.9, + quadruped_small::Species::Hyena => 0.2, + quadruped_small::Species::Dog => 0.8, + quadruped_small::Species::Rabbit => 0.7, quadruped_small::Species::Truffler => 0.2, - quadruped_small::Species::Frog => 0.6, - quadruped_small::Species::Hare => 0.8, + quadruped_small::Species::Hare => 0.3, quadruped_small::Species::Goat => 0.5, + quadruped_small::Species::Porcupine => 0.7, + quadruped_small::Species::Turtle => 0.7, + // FIXME: This is to balance for enemy rats in dunegeons + // Normal rats should probably always flee. + quadruped_small::Species::Rat => 0.0, + quadruped_small::Species::Beaver => 0.7, _ => 1.0, }, Body::QuadrupedMedium(quadruped_medium) => match quadruped_medium.species { - 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, + quadruped_medium::Species::Bonerattler => 0.0, + quadruped_medium::Species::Tiger => 0.1, + _ => 0.3, }, Body::QuadrupedLow(quadruped_low) => match quadruped_low.species { - quadruped_low::Species::Salamander => 0.3, + quadruped_low::Species::Salamander => 0.2, quadruped_low::Species::Monitor => 0.3, - quadruped_low::Species::Asp => 0.1, quadruped_low::Species::Pangolin => 0.6, - _ => 0.4, + quadruped_low::Species::Tortoise => 0.2, + quadruped_low::Species::Rocksnapper => 0.05, + quadruped_low::Species::Asp => 0.05, + _ => 0.0, + }, + Body::BipedSmall(biped_small) => match biped_small.species { + biped_small::Species::Gnarling => 0.2, + biped_small::Species::Adlet => 0.2, + biped_small::Species::Haniwa => 0.1, + biped_small::Species::Sahagin => 0.1, + biped_small::Species::Myrmidon => 0.0, + biped_small::Species::Husk => 0.0, + _ => 0.5, + }, + Body::BirdMedium(bird_medium) => match bird_medium.species { + bird_medium::Species::Goose => 0.4, + bird_medium::Species::Peacock => 0.4, + bird_medium::Species::Eagle => 0.3, + bird_medium::Species::Parrot => 0.8, + _ => 0.5, }, - Body::BipedSmall(_) => 0.5, - Body::BirdMedium(_) => 0.5, Body::BirdLarge(_) => 0.1, - Body::FishMedium(_) => 0.85, Body::FishSmall(_) => 1.0, + Body::FishMedium(_) => 0.75, Body::BipedLarge(_) => 0.0, Body::Object(_) => 0.0, Body::Golem(_) => 0.0, Body::Theropod(_) => 0.0, - Body::Dragon(_) => 0.0, Body::Ship(_) => 0.0, + Body::Dragon(_) => 0.0, }, sight_dist: 40.0, listen_dist: 30.0, @@ -477,16 +490,14 @@ impl Agent { self } - pub fn with_no_flee(mut self, no_flee: bool) -> Self { - if no_flee { - self.set_no_flee(); + pub fn with_no_flee_if(mut self, condition: bool) -> Self { + if condition { + self.psyche.flee_health = 0.0; } self } - pub fn set_no_flee(&mut self) { self.psyche.flee_health = 0.0; } - - // TODO: Get rid of this method, it does weird things + // FIXME: Only one of *three* things in this method sets a location. pub fn with_destination(mut self, pos: Vec3) -> Self { self.psyche.flee_health = 0.0; self.rtsim_controller = RtSimController::with_destination(pos); diff --git a/common/src/comp/body.rs b/common/src/comp/body.rs index fcd04410d9..066e3680d5 100644 --- a/common/src/comp/body.rs +++ b/common/src/comp/body.rs @@ -511,7 +511,24 @@ impl Body { quadruped_small::Species::Holladon => 80, quadruped_small::Species::Hyena => 45, quadruped_small::Species::Truffler => 45, - _ => 40, + quadruped_small::Species::Fox => 15, + quadruped_small::Species::Cat => 25, + quadruped_small::Species::Quokka => 10, + // FIXME: I would have set rats to 5, but that makes enemy ones in dungeons too + // easy. Put this back when the two types of rats are distinguishable. + quadruped_small::Species::Rat => 20, + quadruped_small::Species::Jackalope => 30, + quadruped_small::Species::Hare => 15, + quadruped_small::Species::Rabbit => 10, + quadruped_small::Species::Frog => 5, + quadruped_small::Species::Axolotl => 5, + quadruped_small::Species::Gecko => 5, + quadruped_small::Species::Squirrel => 10, + quadruped_small::Species::Porcupine => 15, + quadruped_small::Species::Beaver => 15, + quadruped_small::Species::Dog => 30, + quadruped_small::Species::Sheep => 30, + _ => 20, }, Body::QuadrupedMedium(quadruped_medium) => match quadruped_medium.species { quadruped_medium::Species::Grolgar => 90, @@ -519,9 +536,9 @@ impl Body { quadruped_medium::Species::Tiger => 70, quadruped_medium::Species::Lion => 90, quadruped_medium::Species::Tarasque => 150, - quadruped_medium::Species::Wolf => 55, + quadruped_medium::Species::Wolf => 45, quadruped_medium::Species::Frostfang => 40, - quadruped_medium::Species::Mouflon => 50, + quadruped_medium::Species::Mouflon => 40, quadruped_medium::Species::Catoblepas => 100, quadruped_medium::Species::Bonerattler => 50, quadruped_medium::Species::Deer => 50, @@ -545,21 +562,20 @@ impl Body { _ => 70, }, Body::BirdMedium(bird_medium) => match bird_medium.species { - bird_medium::Species::Chicken => 30, - bird_medium::Species::Duck => 30, bird_medium::Species::Goose => 30, - bird_medium::Species::Parrot => 25, bird_medium::Species::Peacock => 35, bird_medium::Species::Eagle => 45, - _ => 25, + bird_medium::Species::Owl => 45, + bird_medium::Species::Duck => 10, + _ => 15, }, - Body::FishMedium(_) => 25, + Body::FishMedium(_) => 15, Body::Dragon(_) => 500, Body::BirdLarge(bird_large) => match bird_large.species { bird_large::Species::Roc => 280, _ => 300, }, - Body::FishSmall(_) => 2, + Body::FishSmall(_) => 3, Body::BipedLarge(biped_large) => match biped_large.species { biped_large::Species::Ogre => 320, biped_large::Species::Cyclops => 320, diff --git a/common/src/states/basic_summon.rs b/common/src/states/basic_summon.rs index f5f9768a2f..1a203ade97 100644 --- a/common/src/states/basic_summon.rs +++ b/common/src/states/basic_summon.rs @@ -180,7 +180,7 @@ impl CharacterBehavior for Data { agent: Some( comp::Agent::from_body(&body) .with_behavior(Behavior::from(BehaviorCapability::SPEAK)) - .with_no_flee(true), + .with_no_flee_if(true), ), alignment: comp::Alignment::Owned(*data.uid), scale: self diff --git a/common/src/states/utils.rs b/common/src/states/utils.rs index cf47605a51..710b0e353c 100644 --- a/common/src/states/utils.rs +++ b/common/src/states/utils.rs @@ -38,10 +38,17 @@ impl Body { quadruped_small::Species::Axolotl => 70.0, quadruped_small::Species::Pig => 70.0, quadruped_small::Species::Sheep => 70.0, - quadruped_small::Species::Cat => 70.0, quadruped_small::Species::Truffler => 70.0, quadruped_small::Species::Fungome => 70.0, quadruped_small::Species::Goat => 80.0, + quadruped_small::Species::Raccoon => 100.0, + quadruped_small::Species::Dodarock => 80.0, + quadruped_small::Species::Frog => 150.0, + quadruped_small::Species::Porcupine => 100.0, + quadruped_small::Species::Beaver => 100.0, + quadruped_small::Species::Rabbit => 110.0, + quadruped_small::Species::Cat => 150.0, + quadruped_small::Species::Quokka => 100.0, _ => 125.0, }, Body::QuadrupedMedium(quadruped_medium) => match quadruped_medium.species { @@ -111,7 +118,7 @@ impl Body { quadruped_low::Species::Asp => 110.0, quadruped_low::Species::Tortoise => 60.0, quadruped_low::Species::Rocksnapper => 70.0, - quadruped_low::Species::Pangolin => 120.0, + quadruped_low::Species::Pangolin => 90.0, quadruped_low::Species::Maneater => 80.0, quadruped_low::Species::Sandshark => 160.0, quadruped_low::Species::Hakulaq => 140.0, diff --git a/server/src/sys/agent.rs b/server/src/sys/agent.rs index 41a18b9eeb..2b48d79844 100644 --- a/server/src/sys/agent.rs +++ b/server/src/sys/agent.rs @@ -67,7 +67,6 @@ struct AgentData<'a> { alignment: Option<&'a Alignment>, traversal_config: TraversalConfig, scale: f32, - flees: bool, damage: f32, light_emitter: Option<&'a LightEmitter>, glider_equipped: bool, @@ -319,10 +318,6 @@ impl<'a> System<'a> for Sys { can_climb: body.map_or(false, Body::can_climb), can_fly: body.map_or(false, |b| b.fly_thrust().is_some()), }; - - let flees = alignment.map_or(true, |a| { - !matches!(a, Alignment::Enemy | Alignment::Owned(_)) - }); let health_fraction = health.map_or(1.0, Health::fraction); let rtsim_entity = read_data .rtsim_entities @@ -356,7 +351,6 @@ impl<'a> System<'a> for Sys { alignment: alignment.as_ref(), traversal_config, scale, - flees, damage: health_fraction, light_emitter, glider_equipped, @@ -398,19 +392,15 @@ impl<'a> System<'a> for Sys { 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(); + let too_far_away = dist_sqrd > (MAX_FOLLOW_DIST).powi(2); + + // If too far away, then follow + if too_far_away { data.follow(agent, controller, &read_data.terrain, tgt_pos); - // Attack target's attacker + // Else, attack target's attacker (if there is one) } 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 + // Otherwise, just idle } else { idle(agent, controller, event_emitter); } @@ -424,7 +414,7 @@ impl<'a> System<'a> for Sys { controller: &mut Controller, event_emitter| { if let Some(tgt_health) = read_data.healths.get(target) { - // If target is dead, leave it + // If target is dead, forget them if tgt_health.is_dead { if let Some(tgt_stats) = data.rtsim_entity.and(read_data.stats.get(target)) @@ -432,13 +422,10 @@ impl<'a> System<'a> for Sys { rtsim_forget_enemy(&tgt_stats.name, agent); } agent.target = None; - // If the target is hostile - // (either based on alignment or if - // the target just attacked) + // Else, if target is hostile, hostile tree } else if hostile { data.hostile_tree(agent, controller, &read_data, event_emitter); - // Target is something worth following - // methinks + // Else, if owned, act as pet to them } 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); @@ -481,9 +468,9 @@ impl<'a> System<'a> for Sys { if let Some(attacker) = read_data.uid_allocator.retrieve_entity_internal(by.uid().0) { - // If the target is dead or in a safezone, remove the - // target and idle. - if should_stop_attacking(attacker, &read_data) { + // If target is dead or invulnerable (for now, this only + // means safezone), untarget them and idle. + if is_dead_or_invulnerable(attacker, &read_data) { agent.target = None; } else if let Some(tgt_pos) = read_data.positions.get(attacker) @@ -495,9 +482,8 @@ impl<'a> System<'a> for Sys { } // 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) + // 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) = @@ -606,8 +592,9 @@ impl<'a> AgentData<'a> { decrement_awareness(agent); forget_old_sounds(agent, read_data); + let small_chance = thread_rng().gen_bool(0.1); // Set owner if no target - if agent.target.is_none() && thread_rng().gen_bool(0.1) { + if agent.target.is_none() && small_chance { 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, false); @@ -641,7 +628,9 @@ impl<'a> AgentData<'a> { } // If we receive a new interaction, start the interaction timer - if can_speak(agent) && self.recv_interaction(agent, controller, read_data, event_emitter) { + if allowed_to_speak(agent) + && self.recv_interaction(agent, controller, read_data, event_emitter) + { agent.timer.start(read_data.time.0, TimerAction::Interact); } @@ -714,22 +703,23 @@ impl<'a> AgentData<'a> { .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) { - self.chat_general("npc.speech.villager_under_attack", event_emitter); - self.emit_scream(read_data.time.0, event_emitter); + let should_flee = self.damage.min(1.0) < agent.psyche.flee_health; + if should_flee { + let has_opportunity_to_flee = agent.action_state.timer < FLEE_DURATION; + let within_flee_distance = dist_sq < MAX_FLEE_DIST.powi(2); + + // FIXME: Using the action state timer to see if an agent is allowed to speak is + // a hack. + if agent.action_state.timer == 0.0 { + self.cry_out(agent, read_data.time.0, event_emitter); agent.action_state.timer = 0.01; - } else if agent.action_state.timer < FLEE_DURATION - || dist_sq < MAX_FLEE_DIST.powi(2) - { + } else if within_flee_distance && has_opportunity_to_flee { self.flee(agent, controller, &read_data.terrain, tgt_pos); agent.action_state.timer += read_data.dt.0; } else { @@ -737,36 +727,30 @@ impl<'a> AgentData<'a> { agent.target = None; self.idle(agent, controller, read_data); } - } 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))); - } + } else if is_dead(target, read_data) { + self.exclaim_relief_about_enemy_dead(agent, event_emitter); + agent.target = None; + self.idle(agent, controller, read_data); + } else if is_invulnerable(target, read_data) { agent.target = None; self.idle(agent, controller, read_data); } else { - // Potentially choose a new target - if read_data.time.0 - selected_at > RETARGETING_THRESHOLD_SECONDS - && !in_aggro_range - { + let is_time_to_retarget = + read_data.time.0 - selected_at > RETARGETING_THRESHOLD_SECONDS; + + if !in_aggro_range && is_time_to_retarget { self.choose_target(agent, controller, read_data, event_emitter); } + // FIXME: This check being a pre-requisite to attack() prevents agents from + // supporting their buddies when necessary. For example, village guards will + // literally watch villagers and other guards be slaughtered until these victims + // get within aggro range (which is what aggro_on checks) or get too far. 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); + self.menacing(agent, target, controller, read_data, event_emitter); } } } @@ -1040,7 +1024,7 @@ impl<'a> AgentData<'a> { let msg = agent.inbox.pop_front(); match msg { Some(AgentEvent::Talk(by, subject)) => { - if can_speak(agent) { + if allowed_to_speak(agent) { if let Some(target) = get_entity_by_id(by.id(), read_data) { agent.target = build_target(target, false, read_data.time.0, false); @@ -1088,25 +1072,25 @@ impl<'a> AgentData<'a> { destination_name ) }; - self.chat_general(msg, event_emitter); + self.chat_npc(msg, event_emitter); } else if agent.behavior.can_trade() { if !agent.behavior.is(BehaviorState::TRADING) { controller.events.push(ControlEvent::InitiateInvite( by, InviteKind::Trade, )); - self.chat_general( + self.chat_npc( "npc.speech.merchant_advertisement", event_emitter, ); } else { - self.chat_general( + self.chat_npc( "npc.speech.merchant_busy", event_emitter, ); } } else { - self.chat_general("npc.speech.villager", event_emitter); + self.chat_npc("npc.speech.villager", event_emitter); } }, Subject::Trade => { @@ -1116,12 +1100,12 @@ impl<'a> AgentData<'a> { by, InviteKind::Trade, )); - self.chat_general( + self.chat_npc( "npc.speech.merchant_advertisement", event_emitter, ); } else { - self.chat_general( + self.chat_npc( "npc.speech.merchant_busy", event_emitter, ); @@ -1129,7 +1113,7 @@ impl<'a> AgentData<'a> { } else { // TODO: maybe make some travellers willing to trade with // simpler goods like potions - self.chat_general( + self.chat_npc( "npc.speech.villager_decline_trade", event_emitter, ); @@ -1181,7 +1165,7 @@ impl<'a> AgentData<'a> { MemoryItem::Mood { state } => state.describe(), _ => "".to_string(), }; - self.chat_general(msg, event_emitter); + self.chat_npc(msg, event_emitter); } } }, @@ -1195,7 +1179,7 @@ impl<'a> AgentData<'a> { "{} ? I think it's {} {} from here!", location.name, dist, dir ); - self.chat_general(msg, event_emitter); + self.chat_npc(msg, event_emitter); } }, Subject::Person(person) => { @@ -1230,7 +1214,7 @@ impl<'a> AgentData<'a> { person.name() ) }; - self.chat_general(msg, event_emitter); + self.chat_npc(msg, event_emitter); } }, Subject::Work => {}, @@ -1257,9 +1241,9 @@ impl<'a> AgentData<'a> { controller .events .push(ControlEvent::InviteResponse(InviteResponse::Decline)); - self.chat_general_if_can_speak( - agent, + self.chat_npc_if_allowed_to_speak( "npc.speech.merchant_busy", + agent, event_emitter, ); } @@ -1268,9 +1252,9 @@ impl<'a> AgentData<'a> { controller .events .push(ControlEvent::InviteResponse(InviteResponse::Decline)); - self.chat_general_if_can_speak( - agent, + self.chat_npc_if_allowed_to_speak( "npc.speech.villager_decline_trade", + agent, event_emitter, ); } @@ -1288,13 +1272,10 @@ impl<'a> AgentData<'a> { if agent.behavior.is(BehaviorState::TRADING) { match result { TradeResult::Completed => { - self.chat_general( - "npc.speech.merchant_trade_successful", - event_emitter, - ); + self.chat_npc("npc.speech.merchant_trade_successful", event_emitter); }, _ => { - self.chat_general("npc.speech.merchant_trade_declined", event_emitter); + self.chat_npc("npc.speech.merchant_trade_declined", event_emitter); }, } agent.behavior.unset(BehaviorState::TRADING); @@ -1390,19 +1371,27 @@ impl<'a> AgentData<'a> { fn menacing( &self, - _agent: &mut Agent, + agent: &Agent, + target: EcsEntity, controller: &mut Controller, read_data: &ReadData, - target: EcsEntity, - _tgt_pos: &Pos, + event_emitter: &mut Emitter, ) { + let max_move = 0.5; + let move_dir = controller.inputs.move_dir; + let move_dir_mag = move_dir.magnitude(); + let small_chance = thread_rng().gen::() < read_data.dt.0 * 0.25; + 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; + controller.inputs.move_dir = max_move * move_dir / move_dir_mag; + } + + if small_chance { + self.chat_npc_if_allowed_to_speak("npc.speech.menacing", agent, event_emitter); + controller.push_event(ControlEvent::Utterance(UtteranceKind::Angry)); } } @@ -1573,7 +1562,7 @@ impl<'a> AgentData<'a> { || in_listen_dist(e_pos, e_char_state, e_inventory) }; - let owners_hostile = |e_alignment: Option<&Alignment>| { + let is_owner_hostile = |e_alignment: Option<&Alignment>| { try_owner_alignment(self.alignment, read_data).map_or(false, |owner_alignment| { try_owner_alignment(e_alignment, read_data).map_or(false, |e_owner_alignment| { owner_alignment.hostile_towards(*e_owner_alignment) @@ -1581,17 +1570,18 @@ impl<'a> AgentData<'a> { }) }; - let guard_duty = |e_health: &Health, e_alignment: Option<&Alignment>| { - // I'm a guard and a villager is in distress - let other_is_npc = matches!(e_alignment, Some(Alignment::Npc)); - let remembers_damage = read_data.time.0 - e_health.last_change.time.0 < 5.0; - let need_help = read_data.stats.get(*self.entity).map_or(false, |stats| { - stats.name == "Guard" && other_is_npc && remembers_damage - }); - + let guard_defending_villager = |e_health: &Health, e_alignment: Option<&Alignment>| { + let i_am_a_guard = read_data + .stats + .get(*self.entity) + .map_or(false, |stats| stats.name == "Guard"); + let other_is_a_villager = matches!(e_alignment, Some(Alignment::Npc)); + let villager_has_taken_damage = e_health.last_change.time.0 < 5.0; let attacker_of = |health: &Health| health.last_change.damage_by(); - need_help + let i_should_defend = i_am_a_guard && other_is_a_villager && villager_has_taken_damage; + + i_should_defend .then(|| { attacker_of(e_health) .and_then(|damage_contributor| { @@ -1617,15 +1607,14 @@ impl<'a> AgentData<'a> { .remembers_fight_with_character(&target_stats.name) { rtsim_new_enemy(&target_stats.name, agent, read_data); - if can_speak(agent) { - let message = format!( + self.chat_npc_if_allowed_to_speak( + format!( "{}! How dare you cross me again!", target_stats.name.clone() - ); - event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( - *self.uid, message, - ))); - } + ), + agent, + event_emitter, + ); true } else { false @@ -1651,12 +1640,13 @@ impl<'a> AgentData<'a> { if self.rtsim_entity.is_some() { rtsim_new_enemy(&target_stats.name, agent, read_data); } - if can_speak(agent) { - let message = "npc.speech.villager_cultist_alarm".to_string(); - event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( - *self.uid, message, - ))); - } + + self.chat_npc_if_allowed_to_speak( + "npc.speech.villager_cultist_alarm", + agent, + event_emitter, + ); + true } else { false @@ -1677,13 +1667,13 @@ impl<'a> AgentData<'a> { let can_target = within_reach(e_pos, e_char_state, e_inventory) && entity != *self.entity && !e_health.is_dead - && !invulnerability_is_in_buffs(read_data.buffs.get(entity)); + && !is_invulnerable(entity, read_data); if !can_target { None - } else if owners_hostile(e_alignment) { + } else if is_owner_hostile(e_alignment) { Some((entity, *e_pos)) - } else if let Some(villain_info) = guard_duty(e_health, e_alignment) { + } else if let Some(villain_info) = guard_defending_villager(e_health, e_alignment) { Some(villain_info) } else if rtsim_remember(e_stats, agent, event_emitter) || npc_sees_cultist(e_stats, e_inventory, agent, event_emitter) @@ -3453,8 +3443,9 @@ impl<'a> AgentData<'a> { read_data: &ReadData, ) { if attack_data.dist_sqrd > 30.0_f32.powi(2) { - // If random chance and can see target - if thread_rng().gen_bool(0.05) + let small_chance = thread_rng().gen_bool(0.05); + + if small_chance && can_see_tgt( &*read_data.terrain, self.pos, @@ -4166,20 +4157,20 @@ impl<'a> AgentData<'a> { controller: &mut Controller, read_data: &ReadData, ) { - // Currently this means that we are in a safezone - if invulnerability_is_in_buffs(read_data.buffs.get(*self.entity)) { + if is_invulnerable(*self.entity, read_data) { self.idle(agent, controller, read_data); return; } if let Some(sound) = agent.sounds_heard.last() { - // FIXME: Perhaps name should be a field of the agent, not stats if let Some(agent_stats) = read_data.stats.get(*self.entity) { let sound_pos = Pos(sound.pos); let dist_sqrd = self.pos.0.distance_squared(sound_pos.0); + // 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 = agent_stats.name == *"Guard".to_string(); - let is_neutral = self.flees && !is_village_guard; let is_enemy = matches!(self.alignment, Some(Alignment::Enemy)); if is_enemy { @@ -4193,7 +4184,7 @@ impl<'a> AgentData<'a> { } } else if is_village_guard { self.follow(agent, controller, &read_data.terrain, &sound_pos); - } else if is_neutral { + } else if !is_village_guard { 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; @@ -4230,7 +4221,7 @@ impl<'a> AgentData<'a> { 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) { + if is_dead_or_invulnerable(attacker, read_data) { agent.target = build_target(target, false, read_data.time.0, false); self.idle(agent, controller, read_data); @@ -4330,14 +4321,14 @@ impl<'a> AgentData<'a> { } } - fn chat_general_if_can_speak( + fn chat_npc_if_allowed_to_speak( &self, - agent: &Agent, msg: impl ToString, + agent: &Agent, event_emitter: &mut Emitter<'_, ServerEvent>, ) -> bool { - if can_speak(agent) { - self.chat_general(msg, event_emitter); + if allowed_to_speak(agent) { + self.chat_npc(msg, event_emitter); true } else { false @@ -4356,7 +4347,7 @@ impl<'a> AgentData<'a> { } } - fn chat_general(&self, msg: impl ToString, event_emitter: &mut Emitter<'_, ServerEvent>) { + fn chat_npc(&self, msg: impl ToString, event_emitter: &mut Emitter<'_, ServerEvent>) { event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( *self.uid, msg.to_string(), @@ -4375,6 +4366,43 @@ impl<'a> AgentData<'a> { }); } } + + fn cry_out(&self, agent: &Agent, time: f64, event_emitter: &mut Emitter<'_, ServerEvent>) { + let is_enemy = matches!(self.alignment, Some(Alignment::Enemy)); + // FIXME: This is not necessarily a "villager" + let is_villager = matches!(self.alignment, Some(Alignment::Npc)); + + if is_enemy { + self.chat_npc_if_allowed_to_speak( + "npc.speech.cultist_low_health_fleeing", + agent, + event_emitter, + ); + } else if is_villager { + self.chat_npc_if_allowed_to_speak( + "npc.speech.villager_under_attack", + agent, + event_emitter, + ); + self.emit_scream(time, event_emitter); + } + } + + fn exclaim_relief_about_enemy_dead( + &self, + agent: &Agent, + event_emitter: &mut Emitter<'_, ServerEvent>, + ) { + let is_villager = matches!(self.alignment, Some(Alignment::Npc)); + + if is_villager { + self.chat_npc_if_allowed_to_speak( + "npc.speech.villager_enemy_killed", + agent, + event_emitter, + ); + } + } } fn rtsim_new_enemy(target_name: &str, agent: &mut Agent, read_data: &ReadData) { @@ -4406,17 +4434,21 @@ fn can_see_tgt(terrain: &TerrainGrid, pos: &Pos, tgt_pos: &Pos, dist_sqrd: f32) >= dist_sqrd } -// If target is dead or has invulnerability buff, returns true -fn should_stop_attacking(target: EcsEntity, read_data: &ReadData) -> bool { - let health = read_data.healths.get(target); - let buffs = read_data.buffs.get(target); +fn is_dead_or_invulnerable(entity: EcsEntity, read_data: &ReadData) -> bool { + is_dead(entity, read_data) || is_invulnerable(entity, read_data) +} - health.map_or(true, |a| a.is_dead) || invulnerability_is_in_buffs(buffs) +fn is_dead(entity: EcsEntity, read_data: &ReadData) -> bool { + let health = read_data.healths.get(entity); + + health.map_or(false, |a| a.is_dead) } // FIXME: The logic that is used in this function and throughout the code // shouldn't be used to mean that a character is in a safezone. -fn invulnerability_is_in_buffs(buffs: Option<&Buffs>) -> bool { +fn is_invulnerable(entity: EcsEntity, read_data: &ReadData) -> bool { + let buffs = read_data.buffs.get(entity); + buffs.map_or(false, |b| b.kinds.contains_key(&BuffKind::Invulnerability)) } @@ -4515,7 +4547,7 @@ fn build_target_data<'a>( } } -fn can_speak(agent: &Agent) -> bool { agent.behavior.can(BehaviorCapability::SPEAK) } +fn allowed_to_speak(agent: &Agent) -> bool { agent.behavior.can(BehaviorCapability::SPEAK) } fn get_entity_by_id(id: u64, read_data: &ReadData) -> Option { read_data.uid_allocator.retrieve_entity_internal(id) diff --git a/server/src/sys/terrain.rs b/server/src/sys/terrain.rs index acafdf5fc6..868ed6270b 100644 --- a/server/src/sys/terrain.rs +++ b/server/src/sys/terrain.rs @@ -471,10 +471,10 @@ impl NpcData { let health = Some(comp::Health::new(body, health_scaling.unwrap_or(0))); let poise = comp::Poise::new(body); + // Allow Humanoid, BirdMedium, and Parrot to speak let can_speak = match body { 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, _ => false, }, @@ -495,13 +495,13 @@ impl NpcData { .with_trade_site(trade_for_site), ) .with_patrol_origin(pos) - .with_no_flee(!matches!(agent_mark, Some(agent::Mark::Guard))) + .with_no_flee_if(matches!(agent_mark, Some(agent::Mark::Guard))) }); let agent = if matches!(alignment, comp::Alignment::Enemy) && matches!(body, comp::Body::Humanoid(_)) { - agent.map(|a| a.with_aggro_no_warn()) + agent.map(|a| a.with_aggro_no_warn().with_no_flee_if(true)) } else { agent };