// ▄████████▄ + ███ + ▄█████████ ███ + // ███▀ ▀███ + + ███ ███▀ + ███ + + // ███ + ███ ███ ███ █████████ ███ ███ ███ ███ // ███ +███ ███ ███ ███ ███▐██████ ███ ███ ███ // ███ + ███ ███+ ███ +███ ███ + ███ ███ + ███ // ███▄ ▄███ ███▄ ███ ███ + ███ + ███ ███▄ ███ // ▀████████▀ + ▀███████ ███▄ ███▄ ▀████ ▀███████ // + + + ███ // + ▀████████████████████████████████████████████████████▀ // // This module loads the chat definitions from the YAML files // and manages the flow of conversations. use crate::prelude::*; use bevy::prelude::*; use bevy_xpbd_3d::prelude::*; use serde::Deserialize; use serde_yaml::Value; use std::collections::HashMap; pub const CHATS: &[&str] = &[ include_str!("chats/serenity.yaml"), include_str!("chats/startrans.yaml"), include_str!("chats/thebe.yaml"), ]; pub const TOKEN_CHAT: &str = "chat"; pub const TOKEN_MSG: &str = "msg"; pub const TOKEN_SYSTEM: &str = "system"; pub const TOKEN_WARN: &str = "warn"; pub const TOKEN_SLEEP: &str = "sleep"; pub const TOKEN_SET: &str = "set"; pub const TOKEN_IF: &str = "if"; pub const TOKEN_THEN: &str = "then"; pub const TOKEN_GOTO: &str = "goto"; pub const TOKEN_LABEL: &str = "label"; pub const TOKEN_SCRIPT: &str = "script"; pub const TOKEN_SOUND: &str = "sound"; pub const TOKEN_NOWAIT: &str = "nowait"; pub const TOKEN_INCLUDE: &str = "include"; pub const TOKEN_GOTO_EXIT: &str = "EXIT"; pub const TOKEN_IF_INLINE: &str = "if "; // for lines like `- if foo:` pub const DEFAULT_SOUND: &str = "chat"; pub const MAX_BRANCH_DEPTH: usize = 64; pub const CHOICE_TIMER: f64 = 40.0 * var::DEFAULT_CHAT_SPEED as f64; pub const LETTERS_PER_SECOND: f32 = 17.0; pub const TALKER_SPEED_FACTOR: f32 = var::DEFAULT_CHAT_SPEED / LETTERS_PER_SECOND; pub const CHAT_SPEED_MIN_LEN: f32 = 40.0; pub const NON_CHOICE_TOKENS: &[&str] = &[ TOKEN_CHAT, TOKEN_MSG, TOKEN_SYSTEM, TOKEN_WARN, TOKEN_SLEEP, TOKEN_SET, TOKEN_IF, TOKEN_THEN, TOKEN_GOTO, TOKEN_LABEL, TOKEN_SCRIPT, TOKEN_SOUND, TOKEN_NOWAIT, ]; pub const SKIPPABLE_TOKENS: &[&str] = &[TOKEN_CHAT, TOKEN_LABEL, TOKEN_GOTO, TOKEN_NOWAIT]; pub struct ChatPlugin; impl Plugin for ChatPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, load_chats); app.add_systems( Update, ( handle_reply_keys.before(handle_chat_timer), handle_chat_timer.before(handle_chat_events), handle_new_conversations.before(handle_chat_events), handle_chat_events.before(handle_chat_scripts), handle_chat_scripts, update_chat_variables, ), ); app.add_event::(); app.add_event::(); app.add_event::(); app.insert_resource(ChatDB(Vec::new())); } } type ChatPos = Vec; #[derive(Component)] pub struct Chat { pub internal_id: usize, pub position: ChatPos, pub timer: f64, pub talker: Talker, } #[derive(Component)] pub struct Choice { pub text: String, pub key: usize, pub goto: ChatPos, } #[derive(Component, Clone)] pub struct Talker { pub chat_name: String, pub actor_id: String, pub name: Option, pub pronoun: Option, pub talking_speed: f32, } #[derive(Event)] pub struct StartConversationEvent { pub talker: Talker, } #[derive(Event)] pub struct ChatScriptEvent(String); #[derive(Event)] pub enum ChatEvent { DespawnAllChoices, DespawnAllChats, SpawnMessage(String, hud::LogLevel, String), SpawnChoice(String, usize, ChatPos, bool, Option), RunScript(String), SleepSeconds(f64), SetVariable(String), GotoIf(String, ChatPos), } pub struct Extracted { choice_text: Option, nowait: bool, condition: Option, } impl Default for Extracted { fn default() -> Self { Self { choice_text: None, nowait: false, condition: None, } } } // This is the only place where any YAML interaction should be happening. #[derive(Resource)] pub struct ChatDB(Vec); impl ChatDB { pub fn load_from_str(&mut self, yaml_string: &str) -> Result<(), ()> { let mut count = 0; for document in serde_yaml::Deserializer::from_str(yaml_string) { match Value::deserialize(document) { Ok(yaml_data) => { if let Value::Sequence(yaml_sequence) = yaml_data { self.0.push(Value::Sequence(yaml_sequence)); count += 1; } else { error!("Could not load YAML: {:?}", yaml_data); } } Err(error) => { dbg!(error); return Err(()); } } } info!("Loaded {count} conversations"); return Ok(()); } pub fn preprocess_includes(&mut self) { let mut include_db: HashMap> = HashMap::new(); for sequence in &self.0 { if let Some(vector) = sequence.as_sequence() { if let Some(first_item) = vector.get(0) { if let Some(map) = first_item.as_mapping() { for (key, value) in map { if let (Some(key), Some(value)) = (key.as_str(), value.as_str()) { if key == TOKEN_CHAT { include_db.insert(value.to_string(), vector.clone()); } } } } } } } for mut sequence in self.0.iter_mut() { ChatDB::preprocess_includes_recursively(&mut sequence, &include_db); } } fn preprocess_includes_recursively( sequence: &mut Value, include_db: &HashMap>, ) { let mut changes: Vec<(usize, String)> = Vec::new(); if let Some(vector) = sequence.as_sequence_mut() { for (index, item) in vector.iter_mut().enumerate() { match item { Value::Mapping(map) => { for (key, value) in map.iter_mut() { if let (Some(key), Some(value)) = (key.as_str(), value.as_str()) { if key == TOKEN_INCLUDE { changes.push((index, value.to_string())); } } else if value.is_sequence() { ChatDB::preprocess_includes_recursively(value, include_db); } } } _ => {} } } for (index, label) in changes { if index < vector.len() { vector.remove(index); if let Some(chat) = include_db.get(&label) { vector.splice(index..index, chat.iter().cloned()); } } } } } pub fn get_chat_by_id(&self, id: &String) -> Result { let mut found: Option = None; for (index, object_yaml) in self.0.iter().enumerate() { if let Some(chat_id) = object_yaml[0][TOKEN_CHAT].as_str() { if chat_id == id { if found.is_some() { return Err("Found multiple chats with the same id!".to_string()); } found = Some(index); } } } if let Some(result) = found { return Ok(result); } return Err(format!( "No chat with the conversation ID `{id}` was found." )); } // For a given Value, check whether it's a Value::Mapping and whether it // contains a choice. Mappings that will be detected as choices: // - `{"What's up?": [...]}` // - `{"What's up?": [...], "if": "value > 3"}` // Not acceptable: // - `"What's up?"` // - `{"goto": "foo"}` fn is_choice(&self, yaml: Option<&Value>) -> bool { if let Some(data) = self.extract_choice(yaml) { return data.choice_text.is_some(); } return false; } fn extract_choice(&self, yaml: Option<&Value>) -> Option { let non_choice_tokens = NON_CHOICE_TOKENS.to_vec(); if let Some(Value::Mapping(map)) = yaml { let mut result: Extracted = Extracted::default(); for (key, value) in map { if let Value::String(key) = key { if key == TOKEN_NOWAIT && value.as_bool() == Some(true) { result.nowait = true; } else if key == TOKEN_IF { if let Some(condition) = value.as_str() { result.condition = Some(condition.to_string()); } } else if non_choice_tokens.contains(&key.as_str()) { // skip over the other non-choice tokens } else if key.as_str().starts_with(TOKEN_IF_INLINE) { // skip over inlined if-statements } else { result.choice_text = Some(key.to_string()); } } } return Some(result); } return None; } fn search_label_recursively( &self, sequence: &Value, label: &String, mut pos: ChatPos, ) -> Option { if pos.len() > MAX_BRANCH_DEPTH { return None; } if let Some(vector) = sequence.as_sequence() { for (index, item) in vector.iter().enumerate() { match item { Value::String(_) => {} Value::Mapping(map) => { for (key, value) in map { if let Some(key) = key.as_str() { if key == TOKEN_LABEL { if value == label { pos.push(index); return Some(pos); } } } if value.is_sequence() { pos.push(index); if let Some(result) = self.search_label_recursively(value, label, pos.clone()) { return Some(result); } pos.pop(); } } } _ => {} } } } return None; } fn search_label(&self, chat_id: usize, label: &String) -> Option { if label == TOKEN_GOTO_EXIT { return Some(vec![]); } return self.search_label_recursively(&self.0[chat_id], label, vec![]); } // returns true if we reached the end of a branch and possibly popped the position stack fn advance_pointer(&self, chat: &mut Chat) -> bool { let len = chat.position.len(); if len == 0 { return true; // out of bounds } chat.position[len - 1] += 1; let mut popped = false; let mut seek_past_dialog_choices = false; while chat.position.len() > 0 { match self.at(chat.internal_id, &chat.position) { None => { chat.position.pop(); popped = true; seek_past_dialog_choices = true; if chat.position.len() > 0 { let index = chat.position.len() - 1; chat.position[index] += 1; } } Some(Value::Mapping(map)) => { if seek_past_dialog_choices && self.is_choice(Some(&Value::Mapping(map))) { // we just dropped out of a branch and ended up in a dialog // choice. let's seek past all the choices until we find // the next non-dialog-choice item. if chat.position.len() > 0 { let index = chat.position.len() - 1; chat.position[index] += 1; } } else { break; } } Some(_) => { break; } } } if chat.position.len() == 0 { return true; // out of bounds } return popped; } // Returns the Value at the given ID/position, as-is. // If it's a choice, it returns the entire {"choice text": [...]} mapping. fn at(&self, id: usize, position: &Vec) -> Option { if position.len() == 0 { return None; } let mut result: Option = None; let mut pointer: Option = Some(self.0[id].clone()); let mut next_pointer: Option = None; for index in position { if let Some(Value::Sequence(seq)) = &pointer { let value = seq.get(*index); match value { Some(Value::String(value_string)) => { result = Some(Value::String(value_string.into())); } Some(Value::Mapping(mapping)) => { result = Some(Value::Mapping(mapping.clone())); for value in mapping.values() { if let Some(list) = value.as_sequence() { next_pointer = Some(Value::Sequence(list.clone())); break; } } } None => { return None; // Out of bounds. } _ => { error!("Could not handle YAML value {value:?}"); return None; } } pointer = next_pointer; next_pointer = None; } else { return None; } } return result; } // Determines whether the item at the current position is "skippable". // This means that we should process it right away to get to the correct // position for finding the choices of a message. // This includes flow control tokens like "goto", no-op tokens like "label", // but not something with a side effect like "script", and especially not "if". fn is_skippable(&self, chat: &mut Chat) -> bool { let current_item = self.at(chat.internal_id, &chat.position); if current_item.is_none() { return false; } if let Some(map) = current_item.unwrap().as_mapping() { for key in map.keys() { if let Some(key) = key.as_str() { if !SKIPPABLE_TOKENS.contains(&key) { return false; } } else { return false; } } // It's a mapping that contains ONLY keys in SKIPPABLE_TOKENS. return true; } return false; } fn process_yaml_entry(&self, chat: &mut Chat, event: &mut EventWriter) -> bool { let current_item = self.at(chat.internal_id, &chat.position); let mut processed_a_choice = false; match current_item { Some(Value::String(message)) => { event.send(ChatEvent::SpawnMessage( message.to_string(), hud::LogLevel::Chat, DEFAULT_SOUND.to_string(), )); } Some(Value::Mapping(map)) => { let mut sound = DEFAULT_SOUND.to_string(); // Is this a dialog choice? if self.is_choice(Some(&Value::Mapping(map.clone()))) { processed_a_choice = true; } // We're going through the list of keys/values multiple times // to ensure that dependencies for certain commands are available // First pass for (key, value) in &map { let key = key.as_str(); match (key, value) { (Some(TOKEN_IF), Value::String(condition)) => { let mut pos = chat.position.clone(); pos.push(0); event.send(ChatEvent::GotoIf(condition.into(), pos)); } (Some(TOKEN_SOUND), Value::String(sound_name)) => { sound = sound_name.clone(); } _ => {} } if let Some(key) = key { if key.starts_with(TOKEN_IF_INLINE) { let condition: &str = &key[TOKEN_IF_INLINE.len()..]; let mut pos = chat.position.clone(); pos.push(0); event.send(ChatEvent::GotoIf(condition.into(), pos)); } } } // Second pass for (key, value) in &map { let key = key.as_str(); match (key, value) { (Some(TOKEN_MSG), Value::String(message)) => { event.send(ChatEvent::SpawnMessage( message.to_string(), hud::LogLevel::Chat, sound.clone(), )); } (Some(TOKEN_SYSTEM), Value::String(message)) => { event.send(ChatEvent::SpawnMessage( message.to_string(), hud::LogLevel::Info, sound.clone(), )); } (Some(TOKEN_WARN), Value::String(message)) => { event.send(ChatEvent::SpawnMessage( message.to_string(), hud::LogLevel::Warning, sound.clone(), )); } (Some(TOKEN_SET), Value::String(instructions)) => { event.send(ChatEvent::SetVariable(instructions.to_string())); } _ => {} } } // Third pass for (key, value) in &map { let key = key.as_str(); match (key, value) { (Some(TOKEN_SLEEP), Value::Number(time)) => { if let Some(time_f64) = time.as_f64() { event.send(ChatEvent::SleepSeconds(time_f64)); } } (Some(TOKEN_GOTO), Value::String(label)) => { match self.search_label(chat.internal_id, &label) { Some(pos) => { chat.position = pos; } None => { error!("Could not find goto label {label}!"); } } } (Some(TOKEN_SCRIPT), Value::String(script)) => { event.send(ChatEvent::RunScript(script.clone())); } _ => {} } } } None => { if chat.position.len() == 0 { event.send(ChatEvent::SpawnMessage( "Disconnected.".to_string(), hud::LogLevel::Info, DEFAULT_SOUND.to_string(), )); event.send(ChatEvent::DespawnAllChats); } } _ => { error!("Can't handle YAML value {current_item:?}"); } } return processed_a_choice; } pub fn advance_chat(&self, chat: &mut Chat, event: &mut EventWriter) { event.send(ChatEvent::DespawnAllChoices); // Handle this entry in the chat list let processed_a_choice: bool = self.process_yaml_entry(chat, event); // Move on to next entry self.advance_pointer(chat); // Add the following choices, unless we ended up in the middle of a dialog // choice list, and should just skip through to the next non-dialog-choice item if !processed_a_choice { // Skip/process some entries right away, to be able to fetch the correct choices while self.is_skippable(chat) { self.process_yaml_entry(chat, event); self.advance_pointer(chat); } // Spawn choices until we reach a non-choice item or the end of the branch let mut key: usize = 0; let mut reached_end_of_branch = false; while let Some(data) = self.extract_choice(self.at(chat.internal_id, &chat.position).as_ref()) { if let Some(choice_text) = data.choice_text { if reached_end_of_branch { break; } let mut goto: Vec = chat.position.clone(); goto.push(0); event.send(ChatEvent::SpawnChoice( choice_text, key, goto, data.nowait, data.condition, )); key += 1; reached_end_of_branch = self.advance_pointer(chat); } else { break; } } } } } pub fn load_chats(mut chatdb: ResMut) { for chat_yaml in CHATS { if chatdb.load_from_str(chat_yaml).is_err() { error!("Could not load chat definitions. Validate files in `src/chats/` path."); } } chatdb.preprocess_includes(); } pub fn handle_new_conversations( mut commands: Commands, mut er_conv: EventReader, mut ew_sfx: EventWriter, mut ew_chatevent: EventWriter, mut ew_achievement: EventWriter, chatdb: Res, q_chats: Query<&Chat>, time: Res