Implement a basic dialogue system

This commit is contained in:
Vincent Foulon 2021-03-29 14:47:42 +00:00 committed by Marcel
parent 206efcfc3a
commit a35fa19409
10 changed files with 520 additions and 88 deletions

View File

@ -10,6 +10,8 @@ use specs_idvs::IdvStorage;
use std::collections::VecDeque;
use vek::*;
use super::dialogue::Subject;
pub const DEFAULT_INTERACTION_TIME: f32 = 3.0;
pub const TRADE_INTERACTION_TIME: f32 = 300.0;
@ -179,8 +181,9 @@ impl<'a> From<&'a Body> for Psyche {
/// Events that affect agent behavior from other entities/players/environment
pub enum AgentEvent {
/// Engage in conversation with entity with Uid
Talk(Uid),
Talk(Uid, Subject),
TradeInvite(Uid),
TradeAccepted(Uid),
FinishedTrade(TradeResult),
UpdatePendingTrade(
// this data structure is large so box it to keep AgentEvent small
@ -212,6 +215,7 @@ pub struct Agent {
pub can_speak: bool,
pub trade_for_site: Option<SiteId>,
pub trading: bool,
pub trading_issuer: bool,
pub psyche: Psyche,
pub inbox: VecDeque<AgentEvent>,
pub action_timer: f32,

View File

@ -0,0 +1,90 @@
use vek::Vec2;
/// Cardinal directions
pub enum Direction {
North,
Northeast,
East,
Southeast,
South,
Southwest,
West,
Northwest,
}
impl Direction {
/// Convert a direction vector to a cardinal direction
/// Direction vector can be trivially calculated by doing (target - source)
pub fn from_dir(dir: Vec2<f32>) -> Self {
if let Some(dir) = dir.try_normalized() {
let mut angle = dir.angle_between(Vec2::unit_y()).to_degrees();
if dir.x < 0.0 {
angle = -angle;
}
match angle as i32 {
-360..=-157 => Direction::South,
-156..=-112 => Direction::Southwest,
-111..=-67 => Direction::West,
-66..=-22 => Direction::Northwest,
-21..=22 => Direction::North,
23..=67 => Direction::Northeast,
68..=112 => Direction::East,
113..=157 => Direction::Southeast,
158..=360 => Direction::South,
_ => Direction::North, // should never happen
}
} else {
Direction::North // default value, should never happen
}
}
// TODO: localization
pub fn name(&self) -> &'static str {
match self {
Direction::North => "North",
Direction::Northeast => "Northeast",
Direction::East => "East",
Direction::Southeast => "Southeast",
Direction::South => "South",
Direction::Southwest => "Southwest",
Direction::West => "West",
Direction::Northwest => "Northwest",
}
}
}
/// Arbitrarily named Distances
pub enum Distance {
VeryFar,
Far,
Ahead,
Near,
NextTo,
}
impl Distance {
/// Convert a length to a Distance
pub fn from_length(length: i32) -> Self {
match length {
0..=100 => Distance::NextTo,
101..=500 => Distance::Near,
501..=3000 => Distance::Ahead,
3001..=10000 => Distance::Far,
_ => Distance::VeryFar,
}
}
/// Convert a vector to a Distance
pub fn from_dir(dir: Vec2<f32>) -> Self { Self::from_length(dir.magnitude() as i32) }
// TODO: localization
pub fn name(&self) -> &'static str {
match self {
Distance::VeryFar => "very far",
Distance::Far => "far",
Distance::Ahead => "ahead",
Distance::Near => "near",
Distance::NextTo => "just around",
}
}
}

124
common/src/comp/dialogue.rs Normal file
View File

@ -0,0 +1,124 @@
use vek::{Vec2, Vec3};
use super::Item;
#[derive(Clone, Debug)]
pub struct AskedLocation {
pub name: String,
pub origin: Vec2<i32>,
}
#[derive(Clone, Debug)]
pub enum PersonType {
Merchant,
Villager { name: String },
}
#[derive(Clone, Debug)]
pub struct AskedPerson {
pub person_type: PersonType,
pub origin: Option<Vec3<f32>>,
}
impl AskedPerson {
pub fn name(&self) -> String {
match &self.person_type {
PersonType::Merchant => "The Merchant".to_string(),
PersonType::Villager { name } => name.clone(),
}
}
}
/// Conversation subject
#[derive(Clone, Debug)]
pub enum Subject {
/// Using simple interaction with NPC
/// This is meant to be the default behavior of talking
/// NPC will throw a random dialogue to you
Regular,
/// Asking for trading
/// Ask the person to trade with you
/// NPC will either invite you to trade, or decline
Trade,
/// Inquiring the mood of the NPC
/// NPC will explain what his mood is, and why.
/// Can lead to potential quests if the NPC has a bad mood
/// Else it'll just be flavor text explaining why he got this mood
Mood,
/// Asking for a location
/// NPC will either know where this location is, or not
/// It'll tell you which direction and approx what distance it is from you
Location(AskedLocation),
/// Asking for a person's location
/// NPC will either know where this person is, or not
/// It'll tell you which direction and approx what distance it is from you
Person(AskedPerson),
/// Asking for work
/// NPC will give you a quest if his mood is bad enough
/// So either it'll tell you something to do, or just say that he got
/// nothing
Work,
}
/// Context of why a NPC has a specific mood (good, neutral, bad, ...)
#[derive(Clone, Debug)]
pub enum MoodContext {
/// The weather is good, sunny, appeasing, etc...
GoodWeather,
/// Someone completed a quest and enlightened this NPC's day
QuestSucceeded { hero: String, quest_desc: String },
/// Normal day, same as yesterday, nothing relevant to say about it, that's
/// everyday life
EverydayLife,
/// Need one or more items in order to complete a personal task, or for
/// working
NeedItem { item: Item, quantity: u16 },
/// A personal good has been robbed! Gotta find a replacement
MissingItem { item: Item },
}
// Note: You can add in-between states if needed
/// NPC mood status indicator
#[derive(Clone, Debug)]
pub enum MoodState {
/// The NPC is happy!
Good(MoodContext),
/// The NPC is having a normal day
Neutral(MoodContext),
/// The NPC got a pretty bad day. He may even need player's help!
Bad(MoodContext),
}
// TODO: dialogue localization
impl MoodState {
pub fn describe(&self) -> String {
match self {
MoodState::Good(context) => format!("I'm so happy, {}", context.describe()),
MoodState::Neutral(context) => context.describe(),
MoodState::Bad(context) => {
format!("I'm mad, {}", context.describe())
},
}
}
}
// TODO: dialogue localization
impl MoodContext {
pub fn describe(&self) -> String {
match &self {
MoodContext::GoodWeather => "The weather is great today!".to_string(),
MoodContext::QuestSucceeded { hero, quest_desc } => {
format!("{} helped me on {}", hero, quest_desc)
},
&MoodContext::EverydayLife => "Life's going as always.".to_string(),
MoodContext::NeedItem { item, quantity } => {
format!("I need {} {}!", quantity, item.name())
},
&MoodContext::MissingItem { item } => {
format!("Someone robbed my {}!", item.name())
},
}
}
}

View File

@ -9,8 +9,10 @@ pub mod buff;
mod character_state;
#[cfg(not(target_arch = "wasm32"))] pub mod chat;
#[cfg(not(target_arch = "wasm32"))] pub mod combo;
pub mod compass;
#[cfg(not(target_arch = "wasm32"))]
mod controller;
pub mod dialogue;
#[cfg(not(target_arch = "wasm32"))] mod energy;
#[cfg(not(target_arch = "wasm32"))] pub mod group;
mod health;

View File

@ -7,6 +7,8 @@ use specs::Component;
use specs_idvs::IdvStorage;
use vek::*;
use crate::comp::dialogue::MoodState;
pub type RtSimId = usize;
#[derive(Copy, Clone, Debug)]
@ -19,6 +21,7 @@ impl Component for RtSimEntity {
#[derive(Clone, Debug)]
pub enum RtSimEvent {
AddMemory(Memory),
SetMood(Memory),
PrintMemories,
}
@ -34,6 +37,7 @@ pub enum MemoryItem {
// such as clothing worn, weapon used, etc.
CharacterInteraction { name: String },
CharacterFight { name: String },
Mood { state: MoodState },
}
/// This type is the map route through which the rtsim (real-time simulation)

View File

@ -4,8 +4,8 @@ use vek::*;
use common::{
comp::{
self, agent::AgentEvent, inventory::slot::EquipSlot, item, slot::Slot, tool::ToolKind,
Inventory, Pos,
self, agent::AgentEvent, dialogue::Subject, inventory::slot::EquipSlot, item, slot::Slot,
tool::ToolKind, Inventory, Pos,
},
consts::MAX_MOUNT_RANGE,
outcome::Outcome,
@ -70,7 +70,9 @@ pub fn handle_npc_interaction(server: &mut Server, interactor: EcsEntity, npc_en
.get_mut(npc_entity)
{
if let Some(interactor_uid) = state.ecs().uid_from_entity(interactor) {
agent.inbox.push_front(AgentEvent::Talk(interactor_uid));
agent
.inbox
.push_front(AgentEvent::Talk(interactor_uid, Subject::Regular));
}
}
}

View File

@ -166,7 +166,7 @@ pub fn handle_invite_accept(server: &mut Server, entity: specs::Entity) {
let state = server.state_mut();
let clients = state.ecs().read_storage::<Client>();
let uids = state.ecs().read_storage::<Uid>();
let agents = state.ecs().read_storage::<Agent>();
let mut agents = state.ecs().write_storage::<Agent>();
let mut invites = state.ecs().write_storage::<Invite>();
if let Some((inviter, kind)) = invites.remove(entity).and_then(|invite| {
let Invite { inviter, kind } = invite;
@ -218,6 +218,11 @@ pub fn handle_invite_accept(server: &mut Server, entity: specs::Entity) {
let mut trades = state.ecs().write_resource::<Trades>();
let id = trades.begin_trade(inviter_uid, invitee_uid);
let trade = trades.trades[&id].clone();
if let Some(agent) = agents.get_mut(inviter) {
agent
.inbox
.push_front(AgentEvent::TradeAccepted(invitee_uid));
}
let pricing = agents
.get(inviter)
.and_then(|a| index.get_site_prices(a))

View File

@ -206,6 +206,33 @@ pub struct Brain {
impl Brain {
pub fn add_memory(&mut self, memory: Memory) { self.memories.push(memory); }
pub fn remembers_mood(&self) -> bool {
self.memories
.iter()
.any(|memory| matches!(&memory.item, MemoryItem::Mood { .. }))
}
pub fn set_mood(&mut self, memory: Memory) {
if let MemoryItem::Mood { .. } = memory.item {
if self.remembers_mood() {
while let Some(position) = self
.memories
.iter()
.position(|mem| matches!(&mem.item, MemoryItem::Mood { .. }))
{
self.memories.remove(position);
}
}
self.add_memory(memory);
};
}
pub fn get_mood(&self) -> Option<&Memory> {
self.memories
.iter()
.find(|memory| matches!(&memory.item, MemoryItem::Mood { .. }))
}
pub fn remembers_character(&self, name_to_remember: &str) -> bool {
self.memories.iter().any(|memory| matches!(&memory.item, MemoryItem::CharacterInteraction { name, .. } if name == name_to_remember))
}

View File

@ -81,6 +81,12 @@ impl RtSim {
.get_mut(entity)
.map(|entity| entity.brain.add_memory(memory));
}
pub fn set_entity_mood(&mut self, entity: RtSimId, memory: Memory) {
self.entities
.get_mut(entity)
.map(|entity| entity.brain.set_mood(memory));
}
}
pub fn add_server_systems(dispatch_builder: &mut DispatcherBuilder) {

View File

@ -4,9 +4,11 @@ use common::{
self,
agent::{AgentEvent, Tactic, Target, DEFAULT_INTERACTION_TIME, TRADE_INTERACTION_TIME},
buff::{BuffKind, Buffs},
compass::{Direction, Distance},
dialogue::{MoodContext, MoodState, Subject},
group,
inventory::{item::ItemTag, slot::EquipSlot, trade_pricing::TradePricing},
invite::InviteResponse,
invite::{InviteKind, InviteResponse},
item::{
tool::{ToolKind, UniqueKind},
ItemDesc, ItemKind,
@ -505,8 +507,14 @@ impl<'a> System<'a> for Sys {
// Entity must be loaded in as it has an agent component :)
// React to all events in the controller
for event in core::mem::take(&mut agent.rtsim_controller.events) {
if let RtSimEvent::AddMemory(memory) = event {
rtsim.insert_entity_memory(rtsim_entity.0, memory.clone());
match event {
RtSimEvent::AddMemory(memory) => {
rtsim.insert_entity_memory(rtsim_entity.0, memory.clone())
},
RtSimEvent::SetMood(memory) => {
rtsim.set_entity_mood(rtsim_entity.0, memory.clone())
},
_ => {},
}
}
}
@ -838,7 +846,7 @@ impl<'a> AgentData<'a> {
agent.action_timer += read_data.dt.0;
let msg = agent.inbox.pop_back();
match msg {
Some(AgentEvent::Talk(by)) => {
Some(AgentEvent::Talk(by, subject)) => {
if agent.can_speak {
if let Some(target) = read_data.uid_allocator.retrieve_entity_internal(by.id())
{
@ -847,64 +855,197 @@ impl<'a> AgentData<'a> {
hostile: false,
selected_at: read_data.time.0,
});
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());
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,
),
) {
controller.inputs.look_dir = dir;
}
if self.look_toward(controller, read_data, &target) {
controller.actions.push(ControlAction::Talk);
if let (Some((_travel_to, destination_name)), Some(rtsim_entity)) =
(&agent.rtsim_controller.travel_to, &self.rtsim_entity)
{
let msg = if let Some(tgt_stats) = read_data.stats.get(target) {
agent.rtsim_controller.events.push(RtSimEvent::AddMemory(
Memory {
item: MemoryItem::CharacterInteraction {
name: tgt_stats.name.clone(),
},
time_to_forget: read_data.time.0 + 600.0,
},
));
if rtsim_entity.brain.remembers_character(&tgt_stats.name) {
format!(
"Greetings fair {}! It has been far too long since \
last I saw you.",
&tgt_stats.name
)
match subject {
Subject::Regular => {
if let (
Some((_travel_to, destination_name)),
Some(rtsim_entity),
) = (&agent.rtsim_controller.travel_to, &self.rtsim_entity)
{
let msg =
if let Some(tgt_stats) = read_data.stats.get(target) {
agent.rtsim_controller.events.push(
RtSimEvent::AddMemory(Memory {
item: MemoryItem::CharacterInteraction {
name: tgt_stats.name.clone(),
},
time_to_forget: read_data.time.0 + 600.0,
}),
);
if rtsim_entity
.brain
.remembers_character(&tgt_stats.name)
{
format!(
"Greetings fair {}! It has been far too \
long since last I saw you.",
&tgt_stats.name
)
} else {
format!(
"I'm heading to {}! Want to come along?",
destination_name
)
}
} else {
format!(
"I'm heading to {}! Want to come along?",
destination_name
)
};
event_emitter.emit(ServerEvent::Chat(
UnresolvedChatMsg::npc(*self.uid, msg),
));
} else if agent.trade_for_site.is_some() {
let msg = "Can I interest you in a trade?".to_string();
event_emitter.emit(ServerEvent::Chat(
UnresolvedChatMsg::npc(*self.uid, msg),
));
} else {
format!(
"I'm heading to {}! Want to come along?",
destination_name
)
let msg = "npc.speech.villager".to_string();
event_emitter.emit(ServerEvent::Chat(
UnresolvedChatMsg::npc(*self.uid, msg),
));
}
} else {
format!(
"I'm heading to {}! Want to come along?",
destination_name
)
};
event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc(
*self.uid, msg,
)));
} else if agent.trade_for_site.is_some() {
let msg = "Can I interest you in a trade?".to_string();
event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc(
*self.uid, msg,
)));
} else {
let msg = "npc.speech.villager".to_string();
event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc(
*self.uid, msg,
)));
},
Subject::Trade => {
if agent.trade_for_site.is_some() && !agent.trading {
controller.events.push(ControlEvent::InitiateInvite(
by,
InviteKind::Trade,
));
let msg = "Can I interest you in a trade?".to_string();
event_emitter.emit(ServerEvent::Chat(
UnresolvedChatMsg::npc(*self.uid, msg),
));
} else {
// TODO: maybe make some travellers willing to trade with
// simpler goods like potions
event_emitter.emit(ServerEvent::Chat(
UnresolvedChatMsg::npc(
*self.uid,
"Sorry, I don't have anything to trade."
.to_string(),
),
));
}
},
Subject::Mood => {
if let Some(rtsim_entity) = self.rtsim_entity {
if !rtsim_entity.brain.remembers_mood() {
// TODO: the following code will need a rework to
// implement more mood contexts
// This require that town NPCs becomes rtsim_entities to
// work fully.
match rand::random::<u32>() % 3 {
0 => agent.rtsim_controller.events.push(
RtSimEvent::SetMood(Memory {
item: MemoryItem::Mood {
state: MoodState::Good(
MoodContext::GoodWeather,
),
},
time_to_forget: read_data.time.0 + 21200.0,
}),
),
1 => agent.rtsim_controller.events.push(
RtSimEvent::SetMood(Memory {
item: MemoryItem::Mood {
state: MoodState::Neutral(
MoodContext::EverydayLife,
),
},
time_to_forget: read_data.time.0 + 21200.0,
}),
),
2 => agent.rtsim_controller.events.push(
RtSimEvent::SetMood(Memory {
item: MemoryItem::Mood {
state: MoodState::Bad(
MoodContext::GoodWeather,
),
},
time_to_forget: read_data.time.0 + 86400.0,
}),
),
_ => {}, // will never happen
}
}
if let Some(memory) = rtsim_entity.brain.get_mood() {
let msg = match &memory.item {
MemoryItem::Mood { state } => state.describe(),
_ => "".to_string(),
};
event_emitter.emit(ServerEvent::Chat(
UnresolvedChatMsg::npc(*self.uid, msg),
));
}
}
},
Subject::Location(location) => {
if let Some(tgt_pos) = read_data.positions.get(target) {
event_emitter.emit(ServerEvent::Chat(
UnresolvedChatMsg::npc(
*self.uid,
format!(
"{} ? I think it's {} {} from here!",
location.name,
Distance::from_dir(
location.origin.as_::<f32>()
- tgt_pos.0.xy()
)
.name(),
Direction::from_dir(
location.origin.as_::<f32>()
- tgt_pos.0.xy()
)
.name()
),
),
));
}
},
Subject::Person(person) => {
if let Some(src_pos) = read_data.positions.get(target) {
let msg = if let Some(person_pos) = person.origin {
let distance = Distance::from_dir(
person_pos.xy() - src_pos.0.xy(),
);
match distance {
Distance::NextTo | Distance::Near => {
format!(
"{} ? I think he's {} {} from here!",
person.name(),
distance.name(),
Direction::from_dir(
person_pos.xy() - src_pos.0.xy(),
)
.name()
)
},
_ => {
format!(
"{} ? I think he's gone visiting another \
town. Come back later!",
person.name()
)
},
}
} else {
format!(
"{} ? Sorry, I don't know where you can find him.",
person.name()
)
};
event_emitter.emit(ServerEvent::Chat(
UnresolvedChatMsg::npc(*self.uid, msg),
));
}
},
Subject::Work => {},
}
}
}
@ -926,12 +1067,34 @@ impl<'a> AgentData<'a> {
controller
.events
.push(ControlEvent::InviteResponse(InviteResponse::Accept));
agent.trading_issuer = false;
agent.trading = true;
} else {
// TODO: Provide a hint where to find the closest merchant?
controller
.events
.push(ControlEvent::InviteResponse(InviteResponse::Decline));
if agent.can_speak {
event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc(
*self.uid,
"Sorry, I don't have anything to trade.".to_string(),
)));
}
}
},
Some(AgentEvent::TradeAccepted(with)) => {
if !agent.trading {
if let Some(target) =
read_data.uid_allocator.retrieve_entity_internal(with.id())
{
agent.target = Some(Target {
target,
hostile: false,
selected_at: read_data.time.0,
});
}
agent.trading = true;
agent.trading_issuer = true;
}
},
Some(AgentEvent::FinishedTrade(result)) => {
@ -954,10 +1117,7 @@ impl<'a> AgentData<'a> {
Some(AgentEvent::UpdatePendingTrade(boxval)) => {
let (tradeid, pending, prices, inventories) = *boxval;
if agent.trading {
// For now, assume player is 0 and agent is 1.
// This needs revisiting when agents can initiate trades (e.g. to offer
// mercenary contracts as quests)
const WHO: usize = 1;
let who: usize = if agent.trading_issuer { 0 } else { 1 };
let balance = |who: usize, reduce: bool| {
pending.offers[who]
.iter()
@ -983,8 +1143,8 @@ impl<'a> AgentData<'a> {
})
.sum()
};
let balance0: f32 = balance(1 - WHO, true);
let balance1: f32 = balance(WHO, false);
let balance0: f32 = balance(1 - who, true);
let balance1: f32 = balance(who, false);
tracing::debug!("UpdatePendingTrade({}, {})", balance0, balance1);
if balance0 >= balance1 {
// If the trade is favourable to us, only send an accept message if we're
@ -992,7 +1152,7 @@ impl<'a> AgentData<'a> {
// results in lagging and moving to the review phase of an unfavorable trade
// (although since the phase is included in the message, this shouldn't
// result in fully accepting an unfavourable trade))
if !pending.accept_flags[WHO] {
if !pending.accept_flags[who] {
event_emitter.emit(ServerEvent::ProcessTradeAction(
*self.entity,
tradeid,
@ -1026,23 +1186,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 {
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());
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,
),
) {
controller.inputs.look_dir = dir;
}
}
self.look_toward(controller, read_data, target);
} else {
agent.action_timer = 0.0;
}
@ -1051,6 +1195,30 @@ impl<'a> AgentData<'a> {
}
}
fn look_toward(
&self,
controller: &mut Controller,
read_data: &ReadData,
target: &EcsEntity,
) -> bool {
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());
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),
) {
controller.inputs.look_dir = dir;
}
true
} else {
false
}
}
fn flee(
&self,
agent: &mut Agent,