diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/actor/components.rs | 52 | ||||
-rw-r--r-- | src/actor/events.rs | 72 | ||||
-rw-r--r-- | src/actor/mod.rs | 95 | ||||
-rw-r--r-- | src/actor/resources.rs | 8 | ||||
-rw-r--r-- | src/actor/systems.rs | 121 | ||||
-rw-r--r-- | src/lib.rs | 21 | ||||
-rw-r--r-- | src/systems.rs | 32 | ||||
-rw-r--r-- | src/util.rs | 88 | ||||
-rw-r--r-- | src/widgets.rs | 181 |
9 files changed, 670 insertions, 0 deletions
diff --git a/src/actor/components.rs b/src/actor/components.rs new file mode 100644 index 0000000..43987c0 --- /dev/null +++ b/src/actor/components.rs @@ -0,0 +1,52 @@ +//! Components related to actors + +use bevy::{prelude::*, utils::HashMap}; +use yarnspinner::{compiler::{Compiler, File}, core::{Library, LineId}, runtime::{Dialogue, MemoryVariableStorage, StringTableTextProvider}}; + +/// Main actor component, holds state about dialogue along with the dialogue runner itself +#[derive(Component)] +pub struct Actor { + /// Whether this actor is currently conversing + pub active: bool, + /// Yarnspinner dialogue runner + pub dialogue: Dialogue, + /// Yarnspinner dialogue metadata + pub metadata: HashMap<LineId, Vec<String>>, +} + +impl Actor { + /// Create a new actor from the given source code, starting on the given start node, and with + /// the given function library + pub fn new(file_name: &str, source: &[u8], start_node: &str, function_library: &Library) -> Self { + let compilation = Compiler::new() + .add_file(File { + source: String::from_utf8_lossy(source).into(), + file_name: file_name.into(), + }) + .compile() + .unwrap(); + + let mut base_language_string_table = std::collections::HashMap::new(); + let mut metadata = HashMap::new(); + + for (k, v) in compilation.string_table { + base_language_string_table.insert(k.clone(), v.text); + metadata.insert(k, v.metadata); + } + + let mut text_provider = StringTableTextProvider::new(); + text_provider.extend_base_language(base_language_string_table); + + let mut dialogue = Dialogue::new(Box::new(MemoryVariableStorage::new()), Box::new(text_provider)); + dialogue.library_mut().extend(function_library.clone()); + dialogue.add_program(compilation.program.unwrap()); + dialogue.set_node(start_node).unwrap(); + + Self { + active: false, + dialogue, + metadata, + } + } +} + diff --git a/src/actor/events.rs b/src/actor/events.rs new file mode 100644 index 0000000..e24e7f3 --- /dev/null +++ b/src/actor/events.rs @@ -0,0 +1,72 @@ +//! Actor-related events + +use bevy::prelude::*; +use yarnspinner::{core::LineId, runtime::{Command, DialogueOption, Line, OptionId}}; + +/// Event called by user to progress dialogue +#[derive(Debug, Event)] +pub enum ContinueDialogueEvent { + /// Continue to next line of dialogue for given actor entity + Continue(Entity), + /// Submit option selection to given actor entity + SelectedOption { + /// Target actor entity + actor: Entity, + /// Selected option ID + option: OptionId + }, +} + +/// Event called by plugin in response to a corresponding yarnspinner dialogue events +/// +/// The user should catch these events to update UI, and never call it directly. +#[derive(Event)] +pub enum DialogueEvent { + /// Recieved new line of dialogue + Line { + /// Actor entity + actor: Entity, + /// Line of dialogue received + line: Line, + }, + /// Dialogue complete + DialogueComplete { + /// Actor entity + actor: Entity, + }, + /// Encountered an option selection + Options { + /// Actor entity + actor: Entity, + /// Options to select from + options: Vec<DialogueOption>, + }, + /// Triggered a yarnspinner command + Command { + /// Actor entity + actor: Entity, + /// Triggered command + command: Command, + }, + /// Node started + NodeStart { + /// Actor entity + actor: Entity, + /// Name of started node + name: String, + }, + /// Node complete + NodeComplete { + /// Actor entity + actor: Entity, + /// Name of completed node + name: String, + }, + /// Received line hints + LineHints { + /// Actor entity + actor: Entity, + /// Lines affected + lines: Vec<LineId>, + }, +} diff --git a/src/actor/mod.rs b/src/actor/mod.rs new file mode 100644 index 0000000..3ecc32e --- /dev/null +++ b/src/actor/mod.rs @@ -0,0 +1,95 @@ +//! NPCs containing their own individual yarnspinner contexts +// TODO: Split off into own crate? + +use std::sync::{Arc, Mutex}; + +use bevy::{prelude::*, utils::HashMap}; +use lazy_static::lazy_static; +use resources::FunctionLibrary; +use yarnspinner::core::YarnValue; + +pub mod components; +pub mod events; +pub mod resources; +mod systems; + +lazy_static! { + /// Custom yarnspinner variable storage + /// Stores variables as <instance>.<varname> + /// Global variables are stored in the "global" instance + pub static ref DIRWORLD_VARIABLE_STORAGE: Arc<Mutex<DirworldVariableStorage>> = + Arc::new(Mutex::new(DirworldVariableStorage::default())); +} + +/// Plugin which controls the behavior of actors +pub struct ActorPlugin; + +impl Plugin for ActorPlugin { + fn build(&self, app: &mut App) { + let mut function_library = FunctionLibrary::default(); + function_library.add_function("get_string", get_string); + function_library.add_function("get_number", get_number); + function_library.add_function("get_bool", get_bool); + + app.add_systems( + Update, + (systems::handle_dialog_initiation, systems::progress_dialog, systems::handle_variable_set_commands), + ) + .insert_resource(function_library) + .add_event::<events::ContinueDialogueEvent>() + .add_event::<events::DialogueEvent>(); + } +} + +fn get_string(instance_name: &str, var_name: &str) -> String { + if let Some(YarnValue::String(value)) = DIRWORLD_VARIABLE_STORAGE + .lock() + .unwrap() + .get(instance_name, var_name) + { + value + } else { + "".into() + } +} + +fn get_number(instance_name: &str, var_name: &str) -> f32 { + if let Some(YarnValue::Number(value)) = DIRWORLD_VARIABLE_STORAGE + .lock() + .unwrap() + .get(instance_name, var_name) + { + value + } else { + 0.0 + } +} + +fn get_bool(instance_name: &str, var_name: &str) -> bool { + if let Some(YarnValue::Boolean(value)) = DIRWORLD_VARIABLE_STORAGE + .lock() + .unwrap() + .get(instance_name, var_name) + { + value + } else { + false + } +} + +/// Variable Storage +#[derive(Default, Debug)] +pub struct DirworldVariableStorage(pub HashMap<String, YarnValue>); + +impl DirworldVariableStorage { + /// Set value of instance variable (use "global" for global) + pub fn set(&mut self, instance_name: &str, var_name: &str, value: YarnValue) { + self.0.insert(format!("{instance_name}.{var_name}"), value); + } + + /// Get value of instance variable (use "global" for global) + pub fn get(&self, instance_name: &str, var_name: &str) -> Option<YarnValue> { + self.0.get(&format!("{instance_name}.{var_name}")).cloned() + } +} + diff --git a/src/actor/resources.rs b/src/actor/resources.rs new file mode 100644 index 0000000..76ead59 --- /dev/null +++ b/src/actor/resources.rs @@ -0,0 +1,8 @@ +//! Actor-related resources + +use bevy::prelude::*; +use yarnspinner::core::Library; + +/// Library of yarnspinner function callbacks +#[derive(Resource, Deref, DerefMut, Default, Debug)] +pub struct FunctionLibrary(pub Library); diff --git a/src/actor/systems.rs b/src/actor/systems.rs new file mode 100644 index 0000000..a719858 --- /dev/null +++ b/src/actor/systems.rs @@ -0,0 +1,121 @@ +use bevy::prelude::*; +use bevy_basic_interaction::events::InteractionEvent; +use yarnspinner::core::YarnValue; + +use super::{ + components::Actor, + events::{ContinueDialogueEvent, DialogueEvent}, DIRWORLD_VARIABLE_STORAGE, +}; + +pub fn handle_dialog_initiation( + mut event_reader: EventReader<InteractionEvent>, + mut actor_query: Query<(Entity, &mut Actor)>, + mut event_writer: EventWriter<ContinueDialogueEvent>, +) { + for InteractionEvent { interactable, .. } in event_reader.read() { + if let Ok((actor_entity, mut actor)) = actor_query.get_mut(*interactable) { + actor.active = true; + event_writer.send(ContinueDialogueEvent::Continue(actor_entity)); + } + } +} + +pub fn progress_dialog( + mut event_reader: EventReader<ContinueDialogueEvent>, + mut actor_query: Query<&mut Actor>, + mut event_writer: EventWriter<DialogueEvent>, +) { + for event in event_reader.read() { + let actor_entity = match event { + ContinueDialogueEvent::Continue(actor) => actor, + ContinueDialogueEvent::SelectedOption { actor, .. } => actor, + }; + + if let Ok(mut actor) = actor_query.get_mut(*actor_entity) { + if let ContinueDialogueEvent::SelectedOption { option, .. } = event { + actor.dialogue.set_selected_option(*option).unwrap(); + } + if actor.dialogue.current_node().is_none() { + actor.dialogue.set_node("Start").unwrap(); + } + match actor.dialogue.continue_() { + Ok(events) => { + info!("BATCH"); + for event in events { + info!("Event: {:?}", event); + match event { + yarnspinner::prelude::DialogueEvent::Line(line) => { + event_writer.send(DialogueEvent::Line { + actor: *actor_entity, + line, + }); + } + yarnspinner::prelude::DialogueEvent::DialogueComplete => { + event_writer.send(DialogueEvent::DialogueComplete { + actor: *actor_entity, + }); + } + yarnspinner::prelude::DialogueEvent::Options(options) => { + event_writer.send(DialogueEvent::Options { + actor: *actor_entity, + options, + }); + } + yarnspinner::runtime::DialogueEvent::Command(command) => { + event_writer.send(DialogueEvent::Command { + actor: *actor_entity, + command, + }); + } + yarnspinner::runtime::DialogueEvent::NodeStart(name) => { + event_writer.send(DialogueEvent::NodeStart { + actor: *actor_entity, + name, + }); + } + yarnspinner::runtime::DialogueEvent::NodeComplete(name) => { + event_writer.send(DialogueEvent::NodeComplete { + actor: *actor_entity, + name, + }); + } + yarnspinner::runtime::DialogueEvent::LineHints(lines) => { + event_writer.send(DialogueEvent::LineHints { + actor: *actor_entity, + lines, + }); + } + } + } + } + Err(err) => error!("{:?}", err), + } + } + } +} + +pub fn handle_variable_set_commands( + mut event_reader: EventReader<DialogueEvent>, + mut event_writer: EventWriter<ContinueDialogueEvent>, +) { + for event in event_reader.read() { + if let DialogueEvent::Command { command, actor } = event { + if command.name != "set_var" { + continue; + } + + event_writer.send(ContinueDialogueEvent::Continue(*actor)); + + if command.parameters.len() != 3 { + warn!("Incorrect number of parameters passed to set command: {}", command.parameters.len()); + continue; + } + + if let YarnValue::String(instance_name) = &command.parameters[0] { + if let YarnValue::String(var_name) = &command.parameters[1] { + DIRWORLD_VARIABLE_STORAGE.lock().unwrap().set(instance_name, var_name, command.parameters[2].clone()); + } + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c11e51f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,21 @@ +#![warn(missing_docs)] + +//! Bevy plugin providing a yarnspinner-based dialog system for the bevy_terminal_display plugin +//! and dirworld plugin. + +use bevy::prelude::*; + +mod systems; +pub mod widgets; +pub mod util; +pub mod actor; + +/// Plugin which provides dialog functionality +pub struct TerminalDialogPlugin; + +impl Plugin for TerminalDialogPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(actor::ActorPlugin) + .add_systems(Startup, systems::setup); + } +} diff --git a/src/systems.rs b/src/systems.rs new file mode 100644 index 0000000..444e99f --- /dev/null +++ b/src/systems.rs @@ -0,0 +1,32 @@ +use bevy::prelude::*; +use bevy_terminal_display::widgets::components::Widget; + +use super::widgets::{DialogBox, DialogBoxWidget, InteractTooltip, InteractTooltipWidget, OptionsBox, OptionsBoxWidget}; + +pub fn setup(mut commands: Commands) { + commands.spawn(( + InteractTooltip, + Widget { + enabled: false, + depth: 0, + widget: Box::new(InteractTooltipWidget), + }, + )); + + commands.spawn(( + DialogBox, + Widget { + enabled: false, + depth: 0, + widget: Box::<DialogBoxWidget>::default(), + }, + )); + commands.spawn(( + OptionsBox, + Widget { + enabled: false, + depth: 0, + widget: Box::<OptionsBoxWidget>::default(), + }, + )); +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..bac2a4e --- /dev/null +++ b/src/util.rs @@ -0,0 +1,88 @@ +//! Utilities related to the dialog widget display + +use bevy_terminal_display::ratatui::style::{Color, Style, Stylize}; +use yarnspinner::runtime::MarkupValue; +use zalgo::{Generator, GeneratorArgs, ZalgoSize}; + +/// Splits a marked-up yarnspinner line into sections of text and their corresponding ratatui styles +pub fn style_line(line: &yarnspinner::runtime::Line) -> Vec<(String, Style)> { + if line.attributes.is_empty() { + return vec![(line.text_without_character_name(), Style::new())]; + } + + let mut line_segments = Vec::<(String, Style)>::new(); + let mut attributes = line.attributes.clone(); + attributes.sort_by_key(|attribute| attribute.position); + line_segments.push(( + line.text[..attributes[0].position].to_string(), + Style::new(), + )); + for (i, attribute) in attributes.iter().enumerate() { + let mut attrib_text = line.text_for_attribute(&attribute).to_string(); + let mut style = Style::new(); + match attribute.name.as_str() { + "style" => { + for (property_name, property_value) in attribute.properties.iter() { + match property_name.as_str() { + "bold" => { + if let MarkupValue::Bool(value) = property_value { + if *value { + style = style.bold(); + } + } + } + "italic" => { + if let MarkupValue::Bool(value) = property_value { + if *value { + style = style.italic(); + } + } + } + "color" => { + if let MarkupValue::Integer(value) = property_value { + style = style.fg(Color::Indexed(*value as u8)) + } + } + "zalgo" => { + if let MarkupValue::Bool(value) = property_value { + if *value { + let mut generator = Generator::new(); + let mut out = String::new(); + let args = + GeneratorArgs::new(true, true, true, ZalgoSize::Mini); + generator.gen(&attrib_text, &mut out, &args); + attrib_text = out; + } + } + } + "bg" => { + if let MarkupValue::Integer(value) = property_value { + style = style.bg(Color::Indexed(*value as u8)) + } + } + _ => (), + } + } + } + _ => (), + } + if attribute.name != "character" { + line_segments.push((attrib_text, style)) + } + + if let Some(next_attribute) = attributes.get(i + 1) { + line_segments.push(( + line.text[attribute.position + attribute.length..next_attribute.position] + .to_string(), + Style::new(), + )); + } + } + let last_attribute = attributes.last().unwrap(); + line_segments.push(( + line.text[last_attribute.position + last_attribute.length..].to_string(), + Style::new(), + )); + + line_segments +} diff --git a/src/widgets.rs b/src/widgets.rs new file mode 100644 index 0000000..52eff3c --- /dev/null +++ b/src/widgets.rs @@ -0,0 +1,181 @@ +//! bevy_terminal_display widgets for dialog boxes + +use bevy::prelude::*; +use bevy_terminal_display::{crossterm, ratatui::{layout::{Alignment, Constraint, Flex, Layout}, style::Style, text::{Line, Span}, widgets::{Block, Borders, Clear, HighlightSpacing, List, ListItem, ListState, Padding, Paragraph, Wrap}}, widgets::TerminalWidget}; +use yarnspinner::runtime::DialogueOption; +use unicode_segmentation::UnicodeSegmentation as _; +use arbitrary_chunks::ArbitraryChunks as _; + +/// Interaction tooltip widget marker +// TODO: Move tooltip out of this crate? +#[derive(Component)] +pub struct InteractTooltip; + +/// Interaction tooltip widget +pub struct InteractTooltipWidget; + +impl TerminalWidget for InteractTooltipWidget { + fn render( + &mut self, + frame: &mut bevy_terminal_display::ratatui::Frame, + rect: bevy_terminal_display::ratatui::prelude::Rect, + ) { + let text = Paragraph::new("E") + .block( + Block::new() + .borders(Borders::ALL) + .padding(Padding::horizontal(1)), + ) + .alignment(Alignment::Center); + let [area] = Layout::horizontal([Constraint::Length(5)]) + .flex(Flex::Center) + .areas(rect); + let [area] = Layout::vertical([Constraint::Length(3)]) + .flex(Flex::Center) + .areas(area); + frame.render_widget(Clear, area); + frame.render_widget(text, area); + } +} + +/// Dialog box widget marker +#[derive(Component)] +pub struct DialogBox; + +/// Dialog box widget +#[derive(Default)] +pub struct DialogBoxWidget { + /// Name of speaking character + pub character: Option<String>, + /// Chunks of text and corresponding styles representing the currently spoken line + pub text: Vec<(String, Style)>, +} + +impl TerminalWidget for DialogBoxWidget { + fn render( + &mut self, + frame: &mut bevy_terminal_display::ratatui::Frame, + rect: bevy_terminal_display::ratatui::prelude::Rect, + ) { + let text = Paragraph::new(bevy_terminal_display::ratatui::text::Line::from( + self.text + .iter() + .map(|(text, style)| Span::styled(text, *style)) + .collect::<Vec<_>>(), + )) + .wrap(Wrap { trim: true }) + .block({ + let mut block = Block::new() + .borders(Borders::ALL) + .padding(Padding::horizontal(1)); + if let Some(character) = &self.character { + block = block.title(character.clone()); + } + block + }); + let [area] = Layout::horizontal([Constraint::Max(100)]) + .flex(Flex::Center) + .areas(rect); + let [area] = Layout::vertical([Constraint::Max(10)]) + .flex(Flex::End) + .areas(area); + frame.render_widget(Clear, area); + frame.render_widget(text, area); + } +} + +/// Option selection box widget marker +#[derive(Component)] +pub struct OptionsBox; + +/// Option selection box widget +#[derive(Default)] +pub struct OptionsBoxWidget { + /// State of ratatui list widget + pub state: ListState, + /// Available dialog options + pub options: Vec<(DialogueOption, Vec<(String, Style)>)>, +} + +impl TerminalWidget for OptionsBoxWidget { + fn render( + &mut self, + frame: &mut bevy_terminal_display::ratatui::Frame, + rect: bevy_terminal_display::ratatui::prelude::Rect, + ) { + let terminal_size = crossterm::terminal::size().unwrap(); + let box_size: u16 = if terminal_size.0 > 40 { 20 } else { 10 }; + let items = self + .options + .iter() + .map(|option| { + let option_spans = option + .1 + .iter() + .map(|(text, style)| Span::styled(text, *style)) + .collect::<Vec<_>>(); + let string_chunks = textwrap::wrap( + &Line::from(option_spans.clone()).to_string(), + textwrap::Options::new(box_size as usize), + ) + .into_iter() + .map(|chunk| chunk.graphemes(true).count()) + .collect::<Vec<_>>(); + + let line = Line::from(option_spans); + let graphemes = line.styled_graphemes(Style::default()).collect::<Vec<_>>(); + + let mut final_string_chunks = vec![]; + let mut i = 0; + for string_chunk in string_chunks { + final_string_chunks.push(string_chunk); + i += string_chunk; + if let Some(grapheme) = graphemes.get(i) { + if grapheme.symbol == " " { + final_string_chunks.push(1); + i += 1; + } + } + } + + let mut chunked_graphemes = graphemes + .arbitrary_chunks(&final_string_chunks) + .collect::<Vec<_>>(); + chunked_graphemes.retain(|chunk| !(chunk.len() == 1 && chunk[0].symbol == " ")); + + let line = chunked_graphemes + .into_iter() + .map(|chunk| { + Line::from( + chunk + .iter() + .map(|grapheme| { + Span::styled(grapheme.symbol.to_string(), grapheme.style) + }) + .collect::<Vec<_>>(), + ) + }) + .collect::<Vec<_>>(); + ListItem::new(line) + }) + .collect::<Vec<_>>(); + + let height = items.iter().fold(0, |acc, item| acc + item.height()); + + let outer_block = Block::bordered().padding(Padding::horizontal(1)); + let list = List::new(items) + .block(outer_block) + .highlight_symbol("-> ") + .highlight_spacing(HighlightSpacing::Always); + + let [area] = Layout::horizontal([Constraint::Length(box_size + 7)]) + .flex(Flex::Center) + .areas(rect); + let [area] = Layout::vertical([Constraint::Length(height as u16 + 2)]) + .flex(Flex::Center) + .areas(area); + + frame.render_widget(Clear, area); + frame.render_stateful_widget(list, area, &mut self.state); + } +} |