Added menacing, made agent code less hacky

This commit is contained in:
Joshua Barretto 2021-07-31 20:33:28 +01:00
parent 7acca36629
commit 422e1c30f4
8 changed files with 356 additions and 244 deletions

View File

@ -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

View File

@ -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!",
],
}
)

View File

@ -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<f32>,
}
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<f32>) -> 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)
},

View File

@ -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(); }

View File

@ -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

View File

@ -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.
}

View File

@ -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<ServerEvent>| {
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::<f32>() < 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<Target> {
fn build_target(target: EcsEntity, is_hostile: bool, time: f64, aggro_on: bool) -> Option<Target> {
Some(Target {
target,
hostile: is_hostile,
selected_at: time,
aggro_on,
})
}

View File

@ -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,