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

@ -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,17 +379,18 @@ 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;
idle(agent, controller, event_emitter); data.idle_tree(agent, controller, &read_data, event_emitter);
}; };
let react_as_pet = let react_as_pet = |agent: &mut Agent,
|agent: &mut Agent, target: EcsEntity, controller, event_emitter| { target: EcsEntity,
controller: &mut Controller,
event_emitter| {
if let Some(tgt_pos) = read_data.positions.get(target) { if let Some(tgt_pos) = read_data.positions.get(target) {
let dist_sqrd = pos.0.distance_squared(tgt_pos.0); let dist_sqrd = pos.0.distance_squared(tgt_pos.0);
// If really far away drop everything and follow // If really far away drop everything and follow
@ -417,8 +416,8 @@ impl<'a> System<'a> for Sys {
|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) =
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 { } else {
relax(agent, controller, event_emitter); 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);
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? // Should the agent flee?
if 1.0 - agent.psyche.aggro > self.damage && self.flees {
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,12 +690,7 @@ 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!
} 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) { if can_speak(agent) {
let msg = "npc.speech.villager_enemy_killed".to_string(); let msg = "npc.speech.villager_enemy_killed".to_string();
event_emitter event_emitter
@ -681,19 +698,28 @@ impl<'a> AgentData<'a> {
} }
agent.target = None; agent.target = None;
self.idle(agent, controller, read_data); self.idle(agent, controller, read_data);
// Choose a new target every 10 seconds, but only for } else {
// enemies // Potentially choose a new target
// TODO: This should be more principled. Consider factoring if read_data.time.0 - selected_at > RETARGETING_THRESHOLD_SECONDS
// health, combat rating, wielded weapon, etc, into the && !in_aggro_range
// 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;
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 // TODO: make sneak more effective based on a stat like e_stats.fitness
search_dist *= SNEAK_COEFFICIENT; 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,