diff --git a/Cargo.lock b/Cargo.lock index 4026c84..5498801 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2776,6 +2776,8 @@ dependencies = [ "bevy_xpbd_3d", "fastrand", "regex", + "serde", + "serde_yaml", ] [[package]] @@ -3266,6 +3268,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3673,6 +3688,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "uuid" version = "1.7.0" diff --git a/Cargo.toml b/Cargo.toml index 138645b..069faed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ bevy = { version = "0.13.1", default-features = false, features = ["jpeg", "bevy bevy_xpbd_3d = { version = "0.4.2", default-features = false, features = ["3d", "f64", "parry-f64", "parallel", "async-collider"] } bevy_embedded_assets = "0.10.2" fastrand = "2.0.2" +serde = "1.0" +serde_yaml = "0.9" [features] dev = ["bevy/dynamic_linking", "bevy/file_watcher"] diff --git a/src/chat.rs b/src/chat.rs index eef5710..67526fe 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1,304 +1,688 @@ -use bevy::prelude::*; -use bevy_xpbd_3d::prelude::*; -use bevy::math::DVec3; use crate::{actor, audio, hud, settings, world, effects}; +use bevy::prelude::*; +use bevy::math::DVec3; +use bevy_xpbd_3d::prelude::*; +use serde_yaml::Value; +use serde::Deserialize; +use std::collections::HashMap; + +pub const CHATS: &[&str] = &[ + include_str!("chats/serenity.yaml"), + include_str!("chats/startrans.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_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 DEFAULT_SOUND: &str = "chat"; +pub const MAX_BRANCH_DEPTH: usize = 64; + +pub const CHOICE_TIMER: f64 = 40.0 * settings::DEFAULT_CHAT_SPEED as f64; +pub const LETTERS_PER_SECOND: f32 = 17.0; +pub const TALKER_SPEED_FACTOR: f32 = settings::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_GOTO, + TOKEN_LABEL, + TOKEN_SCRIPT, + TOKEN_SOUND, + TOKEN_NOWAIT, +]; +pub const SKIPPABLE_TOKENS: &[&str] = &[ + TOKEN_LABEL, + TOKEN_GOTO, + TOKEN_NOWAIT, +]; pub struct ChatPlugin; impl Plugin for ChatPlugin { fn build(&self, app: &mut App) { - app.register_type::(); + app.add_systems(Startup, load_chats); app.add_systems(Update, ( - handle_new_conversations, - handle_reply_keys, - handle_send_messages, - handle_conversations, + 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, )); - app.add_systems(PostUpdate, despawn_old_choices); app.add_event::(); - app.add_event::(); + app.add_event::(); app.add_event::(); + app.insert_resource(ChatDB(Vec::new())); } } +type ChatPos = Vec; + +#[derive(Component)] +pub struct Chat { + pub 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)] +#[derive(Clone)] +pub struct Talker { + pub conv_id: String, + pub name: Option, + pub pronoun: Option, + pub talking_speed: f32, +} + #[derive(Event)] pub struct StartConversationEvent { pub talker: Talker, } #[derive(Event)] -pub struct SendMessageEvent { - pub conv_id: String, - pub conv_label: String, - pub text: String, -} +pub struct ChatScriptEvent(String); #[derive(Event)] -pub struct ChatScriptEvent { - name: String, - param: String, - param2: String, +pub enum ChatEvent { + DespawnAllChoices, + DespawnAllChats, + SpawnMessage(String, hud::LogLevel, String), + SpawnChoice(String, usize, ChatPos, bool), + RunScript(String), + Sleep(f64), + //Script(String, String, String), } -#[derive(Debug)] -#[derive(Component, Reflect, Default)] -#[reflect(Component)] -pub struct ChatBranch { - pub id: String, - pub name: String, - pub label: String, - pub delay: f64, - pub sound: String, - pub level: String, - pub reply: String, - pub goto: String, - pub choice: String, - pub script: String, - pub script_parameter: String, - pub script_parameter2: String, +// 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 { + vector.remove(index); + vector.splice(index..index, include_db[&label].iter().cloned()); + } + } + return; + } + + 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"}` + // Returns (choice text, sub-conversation branch, nowait flag) + fn search_choice(&self, yaml: Option<&Value>) -> Option<(String, Value, bool)> { + let non_choice_tokens = NON_CHOICE_TOKENS.to_vec(); + let mut result: Option<(String, Value, bool)> = None; + let mut nowait = false; + if let Some(Value::Mapping(map)) = yaml { + for (key, value) in map { + if let Value::String(key) = key { + if key == TOKEN_NOWAIT && value.as_bool() == Some(true) { + nowait = true; + } + if non_choice_tokens.contains(&key.as_str()) { + continue; + } + result = Some((key.into(), map[key].clone(), nowait)); + } + } + } + return result; + } + + 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.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.search_choice(Some(&Value::Mapping(map))).is_some() { + // 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)) => { + if let Some((_choicetext, subconversation, _)) = self.search_choice(value) { + result = Some(Value::Mapping(mapping.clone())); + next_pointer = Some(subconversation); + } + else { + result = Some(Value::Mapping(mapping.clone())); + } + } + 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.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.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 let Some(_) = self.search_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), _) => {} // TODO + (Some(TOKEN_SOUND), Value::String(sound_name)) => { + sound = sound_name.clone(); + } + _ => {} + } + } + + // 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), _) => {} // TODO + _ => {} + } + } + + // 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::Sleep(time_f64)); + } + } + (Some(TOKEN_GOTO), Value::String(label)) => { + match self.search_label(chat.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((choice, _, nowait)) = + self.search_choice(self.at(chat.id, &chat.position).as_ref()) { + if reached_end_of_branch { + break; + } + let mut goto: Vec = chat.position.clone(); + goto.push(0); + event.send(ChatEvent::SpawnChoice(choice, key, goto, nowait)); + key += 1; + reached_end_of_branch = self.advance_pointer(chat); + } + } + } } -#[derive(Component)] -pub struct Chat { - pub id: String, - pub label: String, - pub timer: f64, +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(); } -#[derive(Component)] -pub struct ChoiceAvailable { - pub conv_id: String, - pub conv_label: String, - pub recipient: String, - pub text: String, -} - - -#[derive(Component)] -#[derive(Clone)] -pub struct Talker { - pub pronoun: String, - pub conv_id: String, -} -impl Default for Talker { fn default() -> Self { Self { - pronoun: "they/them".to_string(), - conv_id: "error".to_string(), -}}} - pub fn handle_new_conversations( mut commands: Commands, mut er_conv: EventReader, mut ew_sfx: EventWriter, - q_conv: Query<&Chat>, + mut ew_chatevent: EventWriter, + chatdb: Res, + q_chats: Query<&Chat>, time: Res