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 🐕 🦎 🐼 - Pets are now saved on logout 🐕 🦎 🐼
- Dualwielded, one-handed swords as starting weapons (Will be replaced by daggers in the future!) - Dualwielded, one-handed swords as starting weapons (Will be replaced by daggers in the future!)
- Healing sceptre crafting recipe - Healing sceptre crafting recipe
- NPCs can now warn players before engaging in combat
### Changed ### 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.", "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.", "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.", "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!", "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!", "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!", "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.", "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!", "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.", "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.", "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!", "Need more bags or better armor to continue your journey? Press 'C' to open the crafting menu!",
"Try jumping when rolling through creatures.", "Try jumping when rolling through creatures.",
@ -228,6 +228,14 @@
"I have destroyed my enemy!", "I have destroyed my enemy!",
"Finally at peace!", "Finally at peace!",
"... now what was I doing?", "... 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 DEFAULT_INTERACTION_TIME: f32 = 1.0;
pub const TRADE_INTERACTION_TIME: f32 = 300.0; pub const TRADE_INTERACTION_TIME: f32 = 300.0;
pub const MAX_LISTEN_DIST: f32 = 100.0;
#[derive(Copy, Clone, Debug, PartialEq, Deserialize)] #[derive(Copy, Clone, Debug, PartialEq, Deserialize)]
pub enum Alignment { pub enum Alignment {
@ -43,6 +42,8 @@ impl Alignment {
// Always attacks // Always attacks
pub fn hostile_towards(self, other: Alignment) -> bool { pub fn hostile_towards(self, other: Alignment) -> bool {
match (self, other) { match (self, other) {
(Alignment::Passive, _) => false,
(_, Alignment::Passive) => false,
(Alignment::Enemy, Alignment::Enemy) => false, (Alignment::Enemy, Alignment::Enemy) => false,
(Alignment::Enemy, Alignment::Wild) => false, (Alignment::Enemy, Alignment::Wild) => false,
(Alignment::Wild, Alignment::Enemy) => false, (Alignment::Wild, Alignment::Enemy) => false,
@ -167,85 +168,107 @@ impl Behavior {
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct Psyche { 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 { impl<'a> From<&'a Body> for Psyche {
fn from(body: &'a Body) -> Self { fn from(body: &'a Body) -> Self {
Self { Self {
aggro: match body { flee_health: match body {
Body::Humanoid(humanoid) => match humanoid.species { Body::Humanoid(humanoid) => match humanoid.species {
humanoid::Species::Danari => 0.9, humanoid::Species::Danari => 0.1,
humanoid::Species::Dwarf => 0.8, humanoid::Species::Dwarf => 0.2,
humanoid::Species::Elf => 0.7, humanoid::Species::Elf => 0.3,
humanoid::Species::Human => 0.6, humanoid::Species::Human => 0.4,
humanoid::Species::Orc => 0.9, humanoid::Species::Orc => 0.1,
humanoid::Species::Undead => 0.9, humanoid::Species::Undead => 0.1,
}, },
Body::QuadrupedSmall(quadruped_small) => match quadruped_small.species { Body::QuadrupedSmall(quadruped_small) => match quadruped_small.species {
quadruped_small::Species::Pig => 0.5, 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::Sheep => 0.5,
quadruped_small::Species::Boar => 0.8, quadruped_small::Species::Boar => 0.2,
quadruped_small::Species::Jackalope => 0.4, quadruped_small::Species::Jackalope => 0.6,
quadruped_small::Species::Skunk => 0.6, quadruped_small::Species::Skunk => 0.4,
quadruped_small::Species::Cat => 0.2, quadruped_small::Species::Cat => 0.8,
quadruped_small::Species::Batfox => 0.6, quadruped_small::Species::Batfox => 0.4,
quadruped_small::Species::Raccoon => 0.4, quadruped_small::Species::Raccoon => 0.6,
quadruped_small::Species::Quokka => 0.4, quadruped_small::Species::Quokka => 0.6,
quadruped_small::Species::Dodarock => 0.9, quadruped_small::Species::Dodarock => 0.1,
quadruped_small::Species::Holladon => 1.0, quadruped_small::Species::Holladon => 0.0,
quadruped_small::Species::Hyena => 0.4, quadruped_small::Species::Hyena => 0.6,
quadruped_small::Species::Rabbit => 0.1, quadruped_small::Species::Rabbit => 0.9,
quadruped_small::Species::Truffler => 0.8, quadruped_small::Species::Truffler => 0.2,
quadruped_small::Species::Frog => 0.4, quadruped_small::Species::Frog => 0.6,
quadruped_small::Species::Hare => 0.2, quadruped_small::Species::Hare => 0.8,
quadruped_small::Species::Goat => 0.5, quadruped_small::Species::Goat => 0.5,
_ => 0.0, _ => 1.0,
}, },
Body::QuadrupedMedium(quadruped_medium) => match quadruped_medium.species { Body::QuadrupedMedium(quadruped_medium) => match quadruped_medium.species {
quadruped_medium::Species::Tuskram => 0.7, quadruped_medium::Species::Tuskram => 0.3,
quadruped_medium::Species::Frostfang => 0.9, quadruped_medium::Species::Frostfang => 0.1,
quadruped_medium::Species::Mouflon => 0.7, quadruped_medium::Species::Mouflon => 0.3,
quadruped_medium::Species::Catoblepas => 0.8, quadruped_medium::Species::Catoblepas => 0.2,
quadruped_medium::Species::Deer => 0.6, quadruped_medium::Species::Deer => 0.4,
quadruped_medium::Species::Hirdrasil => 0.7, quadruped_medium::Species::Hirdrasil => 0.3,
quadruped_medium::Species::Donkey => 0.7, quadruped_medium::Species::Donkey => 0.3,
quadruped_medium::Species::Camel => 0.7, quadruped_medium::Species::Camel => 0.3,
quadruped_medium::Species::Zebra => 0.7, quadruped_medium::Species::Zebra => 0.3,
quadruped_medium::Species::Antelope => 0.6, quadruped_medium::Species::Antelope => 0.4,
quadruped_medium::Species::Horse => 0.7, quadruped_medium::Species::Horse => 0.3,
quadruped_medium::Species::Cattle => 0.7, quadruped_medium::Species::Cattle => 0.3,
quadruped_medium::Species::Darkhound => 0.9, quadruped_medium::Species::Darkhound => 0.1,
quadruped_medium::Species::Dreadhorn => 0.8, quadruped_medium::Species::Dreadhorn => 0.2,
quadruped_medium::Species::Snowleopard => 0.7, quadruped_medium::Species::Snowleopard => 0.3,
quadruped_medium::Species::Llama => 0.6, quadruped_medium::Species::Llama => 0.4,
quadruped_medium::Species::Alpaca => 0.6, quadruped_medium::Species::Alpaca => 0.4,
_ => 0.5, _ => 0.5,
}, },
Body::QuadrupedLow(quadruped_low) => match quadruped_low.species { Body::QuadrupedLow(quadruped_low) => match quadruped_low.species {
quadruped_low::Species::Salamander => 0.7, quadruped_low::Species::Salamander => 0.3,
quadruped_low::Species::Monitor => 0.7, quadruped_low::Species::Monitor => 0.3,
quadruped_low::Species::Asp => 0.9, quadruped_low::Species::Asp => 0.1,
quadruped_low::Species::Pangolin => 0.4, quadruped_low::Species::Pangolin => 0.6,
_ => 0.6, _ => 0.4,
}, },
Body::BipedSmall(_) => 0.5, Body::BipedSmall(_) => 0.5,
Body::BirdMedium(_) => 0.5, Body::BirdMedium(_) => 0.5,
Body::BirdLarge(_) => 0.9, Body::BirdLarge(_) => 0.1,
Body::FishMedium(_) => 0.15, Body::FishMedium(_) => 0.85,
Body::FishSmall(_) => 0.0, Body::FishSmall(_) => 1.0,
Body::BipedLarge(_) => 1.0, Body::BipedLarge(_) => 0.0,
Body::Object(_) => 1.0, Body::Object(_) => 0.0,
Body::Golem(_) => 1.0, Body::Golem(_) => 0.0,
Body::Theropod(_) => 1.0, Body::Theropod(_) => 0.0,
Body::Dragon(_) => 1.0, Body::Dragon(_) => 0.0,
Body::Ship(_) => 1.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)] #[derive(Clone, Debug)]
/// Events that affect agent behavior from other entities/players/environment /// Events that affect agent behavior from other entities/players/environment
pub enum AgentEvent { pub enum AgentEvent {
@ -309,8 +332,12 @@ pub enum SoundKind {
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct Target { pub struct Target {
pub target: EcsEntity, pub target: EcsEntity,
/// Whether the target is hostile
pub hostile: bool, pub hostile: bool,
/// The time at which the target was selected
pub selected_at: f64, pub selected_at: f64,
/// Whether the target has come close enough to trigger aggro.
pub aggro_on: bool,
} }
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
@ -345,7 +372,7 @@ impl Agent {
} }
pub fn with_destination(mut self, pos: Vec3<f32>) -> Self { 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.rtsim_controller = RtSimController::with_destination(pos);
self.behavior.allow(BehaviorCapability::SPEAK); self.behavior.allow(BehaviorCapability::SPEAK);
self self
@ -369,7 +396,10 @@ impl Agent {
Agent { Agent {
patrol_origin, patrol_origin,
psyche: if no_flee { psyche: if no_flee {
Psyche { aggro: 1.0 } Psyche {
flee_health: 0.0,
..Psyche::from(body)
}
} else { } else {
Psyche::from(body) Psyche::from(body)
}, },

View File

@ -242,7 +242,10 @@ impl ControllerInputs {
impl Controller { impl Controller {
/// Sets all inputs to default /// 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(); } pub fn clear_events(&mut self) { self.events.clear(); }

View File

@ -6,7 +6,7 @@ use common::{
assets, assets,
comp::{ comp::{
self, self,
agent::{AgentEvent, Sound, SoundKind, MAX_LISTEN_DIST}, agent::{AgentEvent, Sound, SoundKind},
dialogue::Subject, dialogue::Subject,
inventory::slot::EquipSlot, inventory::slot::EquipSlot,
item, 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 propagated_sound = sound.with_new_vol(sound.vol - vol_dropoff);
let can_hear_sound = propagated_sound.vol > 0.00; 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 { if can_hear_sound && should_hear_sound {
agent agent

View File

@ -159,6 +159,7 @@ impl Settings {
max_players: 100, max_players: 100,
start_time: 9.0 * 3600.0, start_time: 9.0 * 3600.0,
max_view_distance: None, max_view_distance: None,
safe_spawn: false,
client_timeout: Duration::from_secs(180), client_timeout: Duration::from_secs(180),
..load // Fill in remaining fields from server_settings.ron. ..load // Fill in remaining fields from server_settings.ron.
} }

View File

@ -3,8 +3,7 @@ use common::{
comp::{ comp::{
self, self,
agent::{ agent::{
AgentEvent, Sound, SoundKind, Target, DEFAULT_INTERACTION_TIME, MAX_LISTEN_DIST, AgentEvent, Sound, SoundKind, Target, DEFAULT_INTERACTION_TIME, TRADE_INTERACTION_TIME,
TRADE_INTERACTION_TIME,
}, },
buff::{BuffKind, Buffs}, buff::{BuffKind, Buffs},
compass::{Direction, Distance}, compass::{Direction, Distance},
@ -169,7 +168,6 @@ const PARTIAL_PATH_DIST: f32 = 50.0;
const SEPARATION_DIST: f32 = 10.0; const SEPARATION_DIST: f32 = 10.0;
const SEPARATION_BIAS: f32 = 0.8; const SEPARATION_BIAS: f32 = 0.8;
const MAX_FLEE_DIST: f32 = 20.0; const MAX_FLEE_DIST: f32 = 20.0;
const SEARCH_DIST: f32 = 48.0;
const SNEAK_COEFFICIENT: f32 = 0.25; const SNEAK_COEFFICIENT: f32 = 0.25;
const AVG_FOLLOW_DIST: f32 = 6.0; const AVG_FOLLOW_DIST: f32 = 6.0;
const RETARGETING_THRESHOLD_SECONDS: f64 = 10.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(_)) { if !matches!(char_state, CharacterState::LeapMelee(_)) {
// Default to looking in orientation direction // 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 // are the only parts of this tree that should provide
// inputs. // inputs.
let idle = |agent: &mut Agent, controller, mut event_emitter| { let idle =
data.idle_tree(agent, controller, &read_data, &mut event_emitter); |agent: &mut Agent,
}; controller: &mut Controller,
event_emitter: &mut Emitter<ServerEvent>| {
let relax = |agent: &mut Agent, controller, event_emitter| { agent.target = None;
agent.target = None; data.idle_tree(agent, controller, &read_data, event_emitter);
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 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 = let react_to_target =
|agent: &mut Agent, |agent: &mut Agent,
target: EcsEntity, target: EcsEntity,
hostile: bool, hostile: bool,
controller, controller: &mut Controller,
mut event_emitter| { event_emitter| {
if let Some(tgt_health) = read_data.healths.get(target) { if let Some(tgt_health) = read_data.healths.get(target) {
// If target is dead, leave it // If target is dead, leave it
if tgt_health.is_dead { if tgt_health.is_dead {
@ -427,69 +426,27 @@ impl<'a> System<'a> for Sys {
{ {
rtsim_forget_enemy(&tgt_stats.name, agent); rtsim_forget_enemy(&tgt_stats.name, agent);
} }
relax(agent, controller, event_emitter); agent.target = None;
// If the target is hostile // If the target is hostile
// (either based on alignment or if // (either based on alignment or if
// the target just attacked) // the target just attacked)
} else if hostile { } else if hostile {
data.hostile_tree( data.hostile_tree(agent, controller, &read_data, event_emitter);
agent,
controller,
&read_data,
&mut event_emitter,
);
// Target is something worth following // Target is something worth following
// methinks // methinks
} else if let Some(Alignment::Owned(uid)) = data.alignment { } else if let Some(Alignment::Owned(uid)) = data.alignment {
if read_data.uids.get(target) == Some(uid) { if read_data.uids.get(target) == Some(uid) {
react_as_pet(agent, target, controller, event_emitter); react_as_pet(agent, target, controller, event_emitter);
} else { } else {
relax(agent, controller, event_emitter); agent.target = None;
idle(agent, controller, event_emitter);
}; };
} else { } else {
idle(agent, controller, event_emitter); idle(agent, controller, event_emitter);
} }
} else { } else {
relax(agent, controller, event_emitter); agent.target = None;
} idle(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);
}
} }
}; };
@ -507,25 +464,75 @@ impl<'a> System<'a> for Sys {
data.fly_upward(controller) data.fly_upward(controller)
} else if is_falling_dangerous && data.glider_equipped { } else if is_falling_dangerous && data.glider_equipped {
data.glider_fall(controller); 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 { } else {
// Target an entity that's attacking us if the attack // Target an entity that's attacking us if the attack
// was recent and we have a health component // was recent and we have a health component
match health { match health {
Some(health) if health.last_change.0 < DAMAGE_MEMORY_DURATION => { Some(health) if health.last_change.0 < DAMAGE_MEMORY_DURATION => {
if let Some(by) = health.last_change.1.cause.damage_by() { if let Some(by) = health.last_change.1.cause.damage_by() {
react_to_damage(agent, by, controller, event_emitter); if let Some(attacker) =
} else { read_data.uid_allocator.retrieve_entity_internal(by.id())
relax(agent, controller, event_emitter); {
// 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 agent.target.is_none() && thread_rng().gen_bool(0.1) {
if let Some(Alignment::Owned(owner)) = self.alignment { if let Some(Alignment::Owned(owner)) = self.alignment {
if let Some(owner) = get_entity_by_id(owner.id(), read_data) { 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 { if let Some(Target {
target, target,
selected_at, 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) { if let Some(tgt_pos) = read_data.positions.get(target) {
let dist_sqrd = self.pos.0.distance_squared(tgt_pos.0); let dist_sq = self.pos.0.distance_squared(tgt_pos.0);
// Should the agent flee? let in_aggro_range = agent
if 1.0 - agent.psyche.aggro > self.damage && self.flees { .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) { if agent.action_state.timer == 0.0 && can_speak(agent) {
let msg = "npc.speech.villager_under_attack".to_string(); self.chat_general("npc.speech.villager_under_attack", event_emitter);
self.chat_general(msg, event_emitter);
self.emit_villager_alarm(read_data.time.0, event_emitter); self.emit_villager_alarm(read_data.time.0, event_emitter);
agent.action_state.timer = 0.01; 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); self.flee(agent, controller, &read_data.terrain, tgt_pos);
agent.action_state.timer += read_data.dt.0; agent.action_state.timer += read_data.dt.0;
@ -668,32 +690,36 @@ impl<'a> AgentData<'a> {
agent.target = None; agent.target = None;
self.idle(agent, controller, read_data); self.idle(agent, controller, read_data);
} }
} else if should_stop_attacking(target, read_data) {
// If not fleeing, attack the hostile entity! 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 { } else {
// If the hostile entity is dead or has an invulnerability buff (eg, those // Potentially choose a new target
// applied in safezones), return to idle if read_data.time.0 - selected_at > RETARGETING_THRESHOLD_SECONDS
if should_stop_attacking(target, read_data) { && !in_aggro_range
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))
{ {
self.choose_target(agent, controller, read_data, event_emitter); 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); let target_data = build_target_data(target, tgt_pos, read_data);
self.attack(agent, controller, &target_data, 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)) => { Some(AgentEvent::Talk(by, subject)) => {
if can_speak(agent) { if can_speak(agent) {
if let Some(target) = get_entity_by_id(by.id(), read_data) { 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::Stand);
controller.actions.push(ControlAction::Talk); controller.actions.push(ControlAction::Talk);
controller.push_event(ControlEvent::Utterance(UtteranceKind::Greeting)); controller.push_event(ControlEvent::Utterance(UtteranceKind::Greeting));
@ -1017,11 +1043,12 @@ impl<'a> AgentData<'a> {
}; };
self.chat_general(msg, event_emitter); self.chat_general(msg, event_emitter);
} else if agent.behavior.can_trade() { } else if agent.behavior.can_trade() {
let msg = "npc.speech.merchant_advertisement".to_string(); self.chat_general(
self.chat_general(msg, event_emitter); "npc.speech.merchant_advertisement",
event_emitter,
);
} else { } else {
let msg = "npc.speech.villager".to_string(); self.chat_general("npc.speech.villager", event_emitter);
self.chat_general(msg, event_emitter);
} }
}, },
Subject::Trade => { Subject::Trade => {
@ -1031,18 +1058,23 @@ impl<'a> AgentData<'a> {
by, by,
InviteKind::Trade, InviteKind::Trade,
)); ));
let msg = self.chat_general(
"npc.speech.merchant_advertisement".to_string(); "npc.speech.merchant_advertisement",
self.chat_general(msg, event_emitter); event_emitter,
);
} else { } else {
let msg = "npc.speech.merchant_busy".to_string(); self.chat_general(
self.chat_general(msg, event_emitter); "npc.speech.merchant_busy",
event_emitter,
);
} }
} else { } else {
// TODO: maybe make some travellers willing to trade with // TODO: maybe make some travellers willing to trade with
// simpler goods like potions // simpler goods like potions
let msg = "npc.speech.villager_decline_trade".to_string(); self.chat_general(
self.chat_general(msg, event_emitter); "npc.speech.villager_decline_trade",
event_emitter,
);
} }
}, },
Subject::Mood => { Subject::Mood => {
@ -1156,7 +1188,7 @@ impl<'a> AgentData<'a> {
controller.actions.push(ControlAction::Stand); controller.actions.push(ControlAction::Stand);
controller.actions.push(ControlAction::Talk); controller.actions.push(ControlAction::Talk);
if let Some(target) = get_entity_by_id(with.id(), read_data) { 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 controller
.events .events
@ -1167,22 +1199,28 @@ impl<'a> AgentData<'a> {
controller controller
.events .events
.push(ControlEvent::InviteResponse(InviteResponse::Decline)); .push(ControlEvent::InviteResponse(InviteResponse::Decline));
let msg = "npc.speech.merchant_busy".to_string(); self.chat_general_if_can_speak(
self.chat_general_if_can_speak(agent, msg, event_emitter); agent,
"npc.speech.merchant_busy",
event_emitter,
);
} }
} else { } else {
// TODO: Provide a hint where to find the closest merchant? // TODO: Provide a hint where to find the closest merchant?
controller controller
.events .events
.push(ControlEvent::InviteResponse(InviteResponse::Decline)); .push(ControlEvent::InviteResponse(InviteResponse::Decline));
let msg = "npc.speech.villager_decline_trade".to_string(); self.chat_general_if_can_speak(
self.chat_general_if_can_speak(agent, msg, event_emitter); agent,
"npc.speech.villager_decline_trade",
event_emitter,
);
} }
}, },
Some(AgentEvent::TradeAccepted(with)) => { Some(AgentEvent::TradeAccepted(with)) => {
if !agent.behavior.is(BehaviorState::TRADING) { if !agent.behavior.is(BehaviorState::TRADING) {
if let Some(target) = get_entity_by_id(with.id(), read_data) { 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);
agent.behavior.set(BehaviorState::TRADING_ISSUER); agent.behavior.set(BehaviorState::TRADING_ISSUER);
@ -1192,12 +1230,13 @@ impl<'a> AgentData<'a> {
if agent.behavior.is(BehaviorState::TRADING) { if agent.behavior.is(BehaviorState::TRADING) {
match result { match result {
TradeResult::Completed => { TradeResult::Completed => {
let msg = "npc.speech.merchant_trade_successful".to_string(); self.chat_general(
self.chat_general(msg, event_emitter); "npc.speech.merchant_trade_successful",
event_emitter,
);
}, },
_ => { _ => {
let msg = "npc.speech.merchant_trade_declined".to_string(); self.chat_general("npc.speech.merchant_trade_declined", event_emitter);
self.chat_general(msg, event_emitter);
}, },
} }
agent.behavior.unset(BehaviorState::TRADING); agent.behavior.unset(BehaviorState::TRADING);
@ -1268,7 +1307,7 @@ impl<'a> AgentData<'a> {
// No new events, continue looking towards the last // No new events, continue looking towards the last
// interacting player for some time // interacting player for some time
if let Some(Target { target, .. }) = &agent.target { if let Some(Target { target, .. }) = &agent.target {
self.look_toward(controller, read_data, target); self.look_toward(controller, read_data, *target);
} else { } else {
agent.action_state.timer = 0.0; agent.action_state.timer = 0.0;
} }
@ -1281,14 +1320,11 @@ impl<'a> AgentData<'a> {
&self, &self,
controller: &mut Controller, controller: &mut Controller,
read_data: &ReadData, read_data: &ReadData,
target: &EcsEntity, target: EcsEntity,
) -> bool { ) -> 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 eye_offset = self.body.map_or(0.0, |b| b.eye_height());
let tgt_eye_offset = read_data let tgt_eye_offset = read_data.bodies.get(target).map_or(0.0, |b| b.eye_height());
.bodies
.get(*target)
.map_or(0.0, |b| b.eye_height());
if let Some(dir) = Dir::from_unnormalized( 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(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), - 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( fn flee(
&self, &self,
agent: &mut Agent, agent: &mut Agent,
@ -1431,34 +1485,41 @@ impl<'a> AgentData<'a> {
}) })
}; };
let in_search_area = |e_pos: &Pos, e_char_state: Option<&CharacterState>| { let max_search_dist = agent.psyche.search_dist();
let mut search_dist = SEARCH_DIST; let max_sight_dist = agent.psyche.sight_dist;
if e_char_state.map_or(false, CharacterState::is_stealthy) { let max_listen_dist = agent.psyche.listen_dist;
// TODO: make sneak more effective based on a stat like e_stats.fitness let in_sight_dist = |e_pos: &Pos, e_char_state: Option<&CharacterState>| {
search_dist *= SNEAK_COEFFICIENT; 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) 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) (e_pos.0 - self.pos.0)
.try_normalized() .try_normalized()
.map_or(true, |v| v.dot(*controller.inputs.look_dir) > 0.15) .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 in_listen_dist = |e_pos: &Pos, e_char_state: Option<&CharacterState>| {
let mut listen_dist = MAX_LISTEN_DIST; let listen_dist = max_listen_dist
if e_char_state.map_or(false, CharacterState::is_stealthy) { * if e_char_state.map_or(false, CharacterState::is_stealthy) {
// TODO: make sneak more effective based on a stat like e_stats.fitness // TODO: make sneak more effective based on a stat like e_stats.fitness
listen_dist *= SNEAK_COEFFICIENT; SNEAK_COEFFICIENT
} } else {
1.0
};
// TODO implement proper sound system for agents // TODO implement proper sound system for agents
e_pos.0.distance_squared(self.pos.0) < listen_dist.powi(2) e_pos.0.distance_squared(self.pos.0) < listen_dist.powi(2)
}; };
let within_reach = |e_pos: &Pos, e_char_state: Option<&CharacterState>| { let within_reach = |e_pos: &Pos, e_char_state: Option<&CharacterState>| {
(in_search_area(e_pos, e_char_state) && within_view(e_pos)) (in_sight_dist(e_pos, e_char_state) && within_fov(e_pos))
|| in_listen_area(e_pos, e_char_state) || in_listen_dist(e_pos, e_char_state)
}; };
let owners_hostile = |e_alignment: Option<&Alignment>| { let owners_hostile = |e_alignment: Option<&Alignment>| {
@ -1594,9 +1655,10 @@ impl<'a> AgentData<'a> {
// TODO choose target by more than just distance // TODO choose target by more than just distance
let common::CachedSpatialGrid(grid) = self.cached_spatial_grid; let common::CachedSpatialGrid(grid) = self.cached_spatial_grid;
let target = 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(worth_choosing)
.filter_map(possible_target) .filter_map(possible_target)
// TODO: This seems expensive. Cache this to avoid recomputing each tick
.filter(|(_, e_pos)| can_see_them(e_pos)) .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) .min_by_key(|(_, e_pos)| (e_pos.0.distance_squared(self.pos.0) * 100.0) as i32)
.map(|(e, _)| e); .map(|(e, _)| e);
@ -1609,6 +1671,7 @@ impl<'a> AgentData<'a> {
target, target,
hostile: true, hostile: true,
selected_at: read_data.time.0, selected_at: read_data.time.0,
aggro_on: false,
}); });
} }
@ -4017,11 +4080,13 @@ impl<'a> AgentData<'a> {
} else if is_village_guard { } else if is_village_guard {
self.follow(agent, controller, &read_data.terrain, &sound_pos); self.follow(agent, controller, &read_data.terrain, &sound_pos);
} else if is_neutral { } 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 close_enough = dist_sqrd < 35.0_f32.powi(2);
let sound_was_loud = sound.vol >= 10.0; 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); self.flee(agent, controller, &read_data.terrain, &sound_pos);
} else { } else {
self.idle(agent, controller, read_data); self.idle(agent, controller, read_data);
@ -4048,11 +4113,11 @@ impl<'a> AgentData<'a> {
controller.push_event(ControlEvent::Utterance(UtteranceKind::Angry)); 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 let Some(tgt_pos) = read_data.positions.get(attacker) {
if should_stop_attacking(attacker, read_data) { 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); self.idle(agent, controller, read_data);
} else { } else {
@ -4154,11 +4219,11 @@ impl<'a> AgentData<'a> {
fn chat_general_if_can_speak( fn chat_general_if_can_speak(
&self, &self,
agent: &Agent, agent: &Agent,
msg: String, msg: impl ToString,
event_emitter: &mut Emitter<'_, ServerEvent>, event_emitter: &mut Emitter<'_, ServerEvent>,
) -> bool { ) -> bool {
if can_speak(agent) { if can_speak(agent) {
event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc(*self.uid, msg))); self.chat_general(msg, event_emitter);
true true
} else { } else {
false false
@ -4177,8 +4242,11 @@ impl<'a> AgentData<'a> {
} }
} }
fn chat_general(&self, msg: String, event_emitter: &mut Emitter<'_, ServerEvent>) { fn chat_general(&self, msg: impl ToString, event_emitter: &mut Emitter<'_, ServerEvent>) {
event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc(*self.uid, msg))); event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc(
*self.uid,
msg.to_string(),
)));
} }
fn emit_villager_alarm(&self, time: f64, event_emitter: &mut Emitter<'_, ServerEvent>) { 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 { Some(Target {
target, target,
hostile: is_hostile, hostile: is_hostile,
selected_at: time, selected_at: time,
aggro_on,
}) })
} }

View File

@ -265,7 +265,7 @@ impl<'a> System<'a> for Sys {
let poise = comp::Poise::new(body); let poise = comp::Poise::new(body);
let can_speak = match 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 { comp::Body::BirdMedium(bird_medium) => match bird_medium.species {
// Parrots like to have a word in this, too... // Parrots like to have a word in this, too...
bird_medium::Species::Parrot => alignment == comp::Alignment::Npc, bird_medium::Species::Parrot => alignment == comp::Alignment::Npc,