From 0d0b814b5605a84084f8fc621941d6193d2d8934 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Mon, 7 Jul 2025 15:53:58 -0700 Subject: [PATCH] hey the dialog system kinda works! * .mono dialog files * clicking trees loads the dialog in to the dialog box * dialog box only visible when dialog is active/idle (not none i.e., hidden) Next up: Debug info about tree's dialogs and the active dialog --- Cargo.lock | 2 + Cargo.toml | 7 ++ assets/trees/blue.mono | 27 ++++++ assets/trees/green.mono | 29 ++++++ assets/trees/red.mono | 19 ++++ src/bin/trees/main.rs | 200 +++++++++++++++++++++++++++++++++++----- src/bin/trees/mono.rs | 82 ++++++++++++++++ 7 files changed, 343 insertions(+), 23 deletions(-) create mode 100644 assets/trees/blue.mono create mode 100644 assets/trees/green.mono create mode 100644 assets/trees/red.mono create mode 100644 src/bin/trees/mono.rs diff --git a/Cargo.lock b/Cargo.lock index 43073ca..c0b2b52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2205,6 +2205,8 @@ version = "0.1.0" dependencies = [ "bevy", "bevy_rapier3d", + "serde", + "thiserror 2.0.12", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4935160..00b7a32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,13 @@ name = "games" version = "0.1.0" edition = "2024" +[dependencies] +thiserror = "2.0.12" + +[dependencies.serde] +version = "1.0.219" +features = ["derive"] + [dependencies.bevy_rapier3d] version = "0.30.0" diff --git a/assets/trees/blue.mono b/assets/trees/blue.mono new file mode 100644 index 0000000..5be9435 --- /dev/null +++ b/assets/trees/blue.mono @@ -0,0 +1,27 @@ +a + +b + +c + +--- + +d + +e + +f + +--- +g + +h +--- + +i + +j + +--- + +k diff --git a/assets/trees/green.mono b/assets/trees/green.mono new file mode 100644 index 0000000..1b56989 --- /dev/null +++ b/assets/trees/green.mono @@ -0,0 +1,29 @@ +# This empty dialog should be ignored +--- + +# This is a comment +this is one line of dialog + +this is another options + +# This is another comment +--- + +# this is +# a lot +# of comments +# back to back +together they can make poetry + +together they can tell a story +# and a few more for good measure + +--- + +# This should be ignored + +--- + +# This too + +--- diff --git a/assets/trees/red.mono b/assets/trees/red.mono new file mode 100644 index 0000000..75cfd72 --- /dev/null +++ b/assets/trees/red.mono @@ -0,0 +1,19 @@ +### +# Industry +# Written by Elijah Voigt +# No copyright, it's bad on purpose +### + +industry + +--- + +cars drone in the distance with no sign of stopping... + +the roar of a jet echos in the sky somewhere between takeoff and landing... + +a train in the distance warns of it's imminent arrival... + +--- + +industry diff --git a/src/bin/trees/main.rs b/src/bin/trees/main.rs index 73a2a64..5e0df3f 100644 --- a/src/bin/trees/main.rs +++ b/src/bin/trees/main.rs @@ -1,11 +1,18 @@ #![allow(clippy::type_complexity)] +#![allow(clippy::too_many_arguments)] -use bevy::color::palettes::css::{DARK_GREY, DARK_ORANGE, GREY, ORANGE}; +mod mono; + +use bevy::color::palettes::css::{DARK_ORANGE, ORANGE}; use games::*; +use mono::*; fn main() { App::new() .add_plugins(BaseGamePlugin) + .add_plugins(MonologueAssetsPlugin) + .add_event::() + .init_state::() .insert_resource(ClearColor(WHITE.into())) .add_systems( Startup, @@ -14,9 +21,19 @@ fn main() { .add_systems( Update, ( - dialog_engine.run_if(input_just_pressed(KeyCode::KeyN)), + // Start a dialog if none are running + start_dialog + .run_if(in_state(DialogState::None)) + .run_if(on_event::>), + // Close the dialog box if it is idle (not choosing) + end_dialog + .run_if(in_state(DialogState::Idle)) + .run_if(on_event::>), + dialog_engine.run_if(on_event::), mouse_wheel_scroll.run_if(on_event::), auto_scroll.run_if(any_component_added::), + dialog_box_visibility + .run_if(state_changed::) ), ) .add_observer(add_dialog_option) @@ -28,6 +45,9 @@ fn main() { #[derive(Component)] struct Tree; +#[derive(Component, PartialEq)] +struct TreeMonologue(Handle); + /// Initialize the trees, currently placeholders /// Trees are 2d cards in a 3d world for flexibility /// Might move fully 2d if the art style allows it @@ -49,10 +69,12 @@ fn init_trees( alpha_mode: AlphaMode::Blend, ..default() }); + let monologue: Handle = server.load("trees/red.mono"); let tree_transform_red = Transform::from_xyz(-15.0, 0.0, 15.0).with_scale(Vec3::splat(10.0)); commands.spawn(( Tree, + TreeMonologue(monologue), Mesh3d(tree_card_mesh.clone()), MeshMaterial3d(tree_material_red), tree_transform_red, @@ -68,10 +90,12 @@ fn init_trees( alpha_mode: AlphaMode::Blend, ..default() }); + let monologue: Handle = server.load("trees/green.mono"); let tree_transform_green = Transform::from_xyz(15.0, 0.0, 15.0).with_scale(Vec3::splat(10.0)); commands.spawn(( Tree, + TreeMonologue(monologue), Mesh3d(tree_card_mesh.clone()), MeshMaterial3d(tree_material_green), tree_transform_green, @@ -87,10 +111,12 @@ fn init_trees( alpha_mode: AlphaMode::Blend, ..default() }); + let monologue: Handle = server.load("trees/blue.mono"); let tree_transform_blue = Transform::from_xyz(0.0, 0.0, -15.0).with_scale(Vec3::splat(10.0)); commands.spawn(( Tree, + TreeMonologue(monologue), Mesh3d(tree_card_mesh.clone()), MeshMaterial3d(tree_material_blue), tree_transform_blue, @@ -109,6 +135,7 @@ fn init_ui(mut commands: Commands) { .spawn(( DialogBox, BackgroundColor(BLACK.with_alpha(0.9).into()), + DialogState::Ongoing, Node { align_self: AlignSelf::End, justify_self: JustifySelf::Center, @@ -195,36 +222,145 @@ struct DialogOption; #[derive(Component)] struct DialogLine; +/// Events that drive the dialog engine +#[derive(Event, PartialEq)] +enum DialogEvent { + Start(Handle), + NextBatch, + End, +} + +/// State tracking if we are actively "doing a monologue" +#[derive(States, Debug, Hash, Eq, PartialEq, Clone, Component, Default)] +enum DialogState { + // Dialog is running and being interacted with + Ongoing, + // There is no more dialog so it's just sitting there + Idle, + // Dialog box is not visible + #[default] + None, +} + +/// Start dialog, will expand later with handle to monologue asset +fn start_dialog( + mut click_events: EventReader>, + mut dialog_events: EventWriter, + query: Query<&TreeMonologue>, +) { + click_events.read().for_each(|event| { + info!("Click event detected"); + if let Ok(TreeMonologue(handle)) = query.get(event.target) { + info!("Tree Monologue received, sending start dialog event"); + dialog_events.write(DialogEvent::Start(handle.clone())); + dialog_events.write(DialogEvent::NextBatch); + } + }) +} + +/// When dialog is complete and you click away from the dialog box, close it out +fn end_dialog( + mut click_events: EventReader>, + mut dialog_events: EventWriter, + query: Query, With, With)>>, +) { + click_events.read().for_each(|event| { + info!("Click even triggered end of dialog: {:?}", event.target); + if !query.contains(event.target) { + dialog_events.write(DialogEvent::End); + } + }); +} + /// System which puts DialogOptions into the DialogBox fn dialog_engine( - mut commands: Commands, + // React to dialog events + mut events: EventReader, + // Reference to DialogBox dialog_box: Single>, + // EntityCommands for Dialog Box + mut commands: Commands, + // Handle to "active" monologue + mut handle: Local>, + // Index into "active" monologue mut idx: Local, + // Inform the rest of the game what state of dialog we are in + mut next_state: ResMut>, + // Monologue assets for obvious reasons + monologues: Res>, + // Monologue trees so we can remove that component at end of monologue + monologue_trees: Query<(Entity, &TreeMonologue)>, + // Dialog lines to despawn them at the end/start of a dialog + lines: Query>, ) { - let dialog: Vec> = vec![ - vec!["A", "B", "C"], - vec!["E", "F", "G"], - vec!["H", "I"], - vec!["J", "K"], - vec!["L"], - ]; - - debug!("Show options: {:?}", dialog.get(*idx)); - - commands.entity(*dialog_box).with_children(|parent| { - if let Some(options) = dialog.get(*idx) { - options.iter().for_each(|option| { - parent.spawn((Text::new(*option), DialogOption)); - }); + debug_assert!( + !events.is_empty(), + "Dialog engine is triggered by Dialog Events" + ); + + events.read().for_each(|event| { + match event { + DialogEvent::Start(h) => { + debug!("Dialog start: {:?}", h); + + // Set state to "Active" + next_state.set(DialogState::Ongoing); + + // Copy monologue asset into local + *handle = h.clone(); + } + DialogEvent::NextBatch => { + debug!("Dialog batch"); + + commands.entity(*dialog_box).with_children(|parent| { + // Fetch this monologue from the assets + if let Some(monologue) = monologues.get(handle.clone().id()) { + // Fetch this batch of options + if let Some(options) = monologue.get(*idx) { + // Spawn the dialog options in the dialog box + options.iter().for_each(|option| { + parent.spawn((Text::new(option.clone()), DialogOption)); + }); + *idx += 1; + } else { + // Set the dialog state to "Idle" + next_state.set(DialogState::Idle); + } + } + }); + } + DialogEvent::End => { + debug!("Dialog ended"); + + // Remove the TreeMonologue component from the active tree + monologue_trees.iter().filter_map(|(entity, tree_monologue)| { + (*tree_monologue == TreeMonologue(handle.clone())).then_some(entity) + }).for_each(|e| { + commands.entity(e).remove::(); + }); + + // Remove lines from dialog box + lines.iter().for_each(|e| { + commands.entity(e).despawn(); + }); + + // Reset index into the dialog options array + *idx = 0; + + // Wipe the current handle from context + *handle = Handle::default(); + + // Set state to "Active" + next_state.set(DialogState::None); + } } }); - - *idx += 1; } /// When a dialog option is chosen (clicked on) we do the following: fn choose_dialog_option( trigger: Trigger>, + mut dialog_events: EventWriter, mut commands: Commands, texts: Query<&Text>, options: Query>, @@ -243,6 +379,9 @@ fn choose_dialog_option( parent.spawn((t.clone(), DialogLine)); }); } + + // trigger the next dialog line + dialog_events.write(DialogEvent::NextBatch); } fn hover_dialog_option_over( @@ -251,13 +390,17 @@ fn hover_dialog_option_over( ) { if let Ok((mut tc, mut bg)) = query.get_mut(trigger.target()) { *tc = TextColor(DARK_ORANGE.into()); - bg.set_alpha(1.0); + bg.0.set_alpha(1.0); } } -fn hover_dialog_option_out(trigger: Trigger>, mut query: Query<&mut TextColor>) { - if let Ok(mut tc) = query.get_mut(trigger.target()) { +fn hover_dialog_option_out( + trigger: Trigger>, + mut query: Query<(&mut TextColor, &mut BackgroundColor)>, +) { + if let Ok((mut tc, mut bg)) = query.get_mut(trigger.target()) { *tc = TextColor(ORANGE.into()); + bg.0.set_alpha(0.0); } } @@ -283,3 +426,14 @@ fn add_dialog_option(trigger: Trigger, mut commands: Comman fn add_dialog_line(trigger: Trigger, mut commands: Commands) { debug!("Adding dialog line"); } + +fn dialog_box_visibility( + state: Res>, + mut dialog_box: Single<&mut Visibility, With>, +) { + **dialog_box = if *state.get() == DialogState::None { + Visibility::Hidden + } else { + Visibility::Inherited + }; +} diff --git a/src/bin/trees/mono.rs b/src/bin/trees/mono.rs new file mode 100644 index 0000000..e81b732 --- /dev/null +++ b/src/bin/trees/mono.rs @@ -0,0 +1,82 @@ +use bevy::{ + asset::{AssetLoader, LoadContext, io::Reader}, + prelude::*, + reflect::TypePath, +}; +use serde::Deserialize; +use thiserror::Error; + +#[derive(Asset, TypePath, Debug, Deserialize, Default, Clone)] +pub(crate) struct Monologue { + value: Vec>, +} + +impl Monologue { + pub fn get(&self, idx: usize) -> Option<&Vec> { + self.value.get(idx) + } +} + +#[derive(Default)] +struct MonologueLoader; + +#[derive(Debug, Error)] +enum MonologueLoaderError { + #[error("Could not load asset: {0}")] + Io(#[from] std::io::Error), + #[error("Could not parse utf8")] + Utf8(#[from] std::string::FromUtf8Error), +} + +impl AssetLoader for MonologueLoader { + type Asset = Monologue; + type Settings = (); + type Error = MonologueLoaderError; + async fn load( + &self, + reader: &mut dyn Reader, + _settings: &(), + _load_context: &mut LoadContext<'_>, + ) -> Result { + let mut bytes: Vec = Vec::new(); + reader.read_to_end(&mut bytes).await?; + + let raw_string = String::from_utf8(bytes)?; + + let value: Vec> = raw_string + // First split on the '---' separators between batches + .split_terminator("---") + .map(|batch| { + batch + // Then split batches into newline-separated groups of text + .split_terminator("\n\n") + .filter_map(|line| { + // Filter out comments, empty lines, and extraneous newlines + (!line.starts_with("#") && !line.is_empty() && line != "\n") + // Trim the resulting dialog option + .then_some(line.trim().into()) + }) + .collect() + }) + .filter(|sub: &Vec| !sub.is_empty()) + .collect(); + + info!("Monologue: {:#?}", value); + + let thing = Monologue { value }; + Ok(thing) + } + + fn extensions(&self) -> &[&str] { + &["mono"] + } +} + +pub struct MonologueAssetsPlugin; + +impl Plugin for MonologueAssetsPlugin { + fn build(&self, app: &mut App) { + app.init_asset::() + .init_asset_loader::(); + } +}