aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/actor/components.rs52
-rw-r--r--src/actor/events.rs72
-rw-r--r--src/actor/mod.rs95
-rw-r--r--src/actor/resources.rs8
-rw-r--r--src/actor/systems.rs121
-rw-r--r--src/lib.rs21
-rw-r--r--src/systems.rs32
-rw-r--r--src/util.rs88
-rw-r--r--src/widgets.rs181
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);
+ }
+}