From 6b12785a7dcdb9b2bb73369352f2e41a9d694147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TATSUNO=20=E2=80=9CTaz=E2=80=9D=20Yasuhiro?= Date: Sat, 18 Oct 2025 22:43:41 +0900 Subject: [PATCH] Allow selecting options by line ID --- crates/bevy_plugin/assets/options.yarn | 4 +- crates/bevy_plugin/src/dialogue_runner.rs | 19 +++++++++ .../test_dialogue_runner_delivers_options.rs | 41 +++++++++++++++++++ crates/runtime/src/dialogue.rs | 25 +++++++++++ crates/runtime/src/virtual_machine.rs | 30 ++++++++++++++ 5 files changed, 117 insertions(+), 2 deletions(-) diff --git a/crates/bevy_plugin/assets/options.yarn b/crates/bevy_plugin/assets/options.yarn index 16dfa45f7..9db00c294 100644 --- a/crates/bevy_plugin/assets/options.yarn +++ b/crates/bevy_plugin/assets/options.yarn @@ -11,11 +11,11 @@ Ancient Reptilian Brain: Never ever. title: Hub0 position: -43,-216 --- --> You: Never ever ever? <> +-> You: Never ever ever? <> #line:x1 Ancient Reptilian Brain: Never ever ever ever, baby! <> <> --> You: (Simply keep on non-existing.) +-> You: (Simply keep on non-existing.) #line:x2 Ancient Reptilian Brain: An inordinate amount of time passes. It is utterly void of struggle. No ex-wives are contained within it. <> diff --git a/crates/bevy_plugin/src/dialogue_runner.rs b/crates/bevy_plugin/src/dialogue_runner.rs index 4416e5f47..52b97e297 100644 --- a/crates/bevy_plugin/src/dialogue_runner.rs +++ b/crates/bevy_plugin/src/dialogue_runner.rs @@ -102,6 +102,25 @@ impl DialogueRunner { Ok(self) } + /// If the dialogue is currently waiting for the user to select an option, this method will select the option with the given id. + /// Implies [`DialogueRunner::continue_in_next_update`]. + pub fn select_option_by_line_id(&mut self, line: LineId) -> Result<&mut Self> { + if !self.is_running { + bail!( + "Can't select option of line id {line}: the dialogue is currently not running. Please call `DialogueRunner::continue_in_next_update()` only after receiving a `PresentOptionsEvent`." + ) + } + + let option = self + .inner_mut() + .0 + .set_selected_option_by_line_id(line) + .map_err(Error::from)?; + self.last_selected_option.replace(option); + self.continue_in_next_update(); + Ok(self) + } + /// Returns whether the dialogue runner is currently running. Returns `false` if: /// - The dialogue has not yet been started via [`DialogueRunner::start_node`] /// - The dialogue has been stopped via [`DialogueRunner::stop`] diff --git a/crates/bevy_plugin/tests/test_dialogue_runner_delivers_options.rs b/crates/bevy_plugin/tests/test_dialogue_runner_delivers_options.rs index 983c57b8a..0982adefd 100644 --- a/crates/bevy_plugin/tests/test_dialogue_runner_delivers_options.rs +++ b/crates/bevy_plugin/tests/test_dialogue_runner_delivers_options.rs @@ -57,6 +57,18 @@ fn errs_on_unexpected_selection_value() -> Result<()> { Ok(()) } +#[test] +fn errs_on_unexpected_line_id() -> Result<()> { + let mut app = App::new(); + app.setup_dialogue_runner().start_node("Start"); + app.continue_dialogue_and_update_n_times(4); + app.dialogue_runner_mut() + .select_option_by_line_id(LineId("line:1".to_string())) + .unwrap_err(); + + Ok(()) +} + #[test] fn option_selection_implies_continue() -> Result<()> { let mut app = App::new(); @@ -158,6 +170,35 @@ fn can_select_unavailable_choice() -> Result<()> { Ok(()) } +#[test] +fn can_select_by_line_id() -> Result<()> { + let mut app = App::new(); + let mut asserter = EventAsserter::new(); + app.setup_dialogue_runner().start_node("Start"); + app.continue_dialogue_and_update_n_times(4); + app.dialogue_runner_mut() + .select_option_by_line_id(LineId("line:x1".to_string()))?; + app.continue_dialogue_and_update(); + asserter.clear_events(&mut app); + + app.continue_dialogue_and_update(); + assert_events!(asserter, app contains [ + PresentLineEvent (n = 0), + PresentOptionsEvent with |event| + event.options.len() == 2 + && event.options.iter().filter(|o| o.is_available).count() == 1 + && event.options.iter().filter(|o| !o.is_available).all(|o| o.id == OptionId(0)), + ]); + app.dialogue_runner_mut().select_option(OptionId(0))?; + app.update(); + assert_events!(asserter, app contains [ + PresentLineEvent with |event| event.line.text == lines()[6], + PresentOptionsEvent (n = 0), + ]); + + Ok(()) +} + #[test] fn generates_files_in_dev_mode() -> Result<()> { let dir = tempdir()?; diff --git a/crates/runtime/src/dialogue.rs b/crates/runtime/src/dialogue.rs index 3437fbae3..057030cd7 100644 --- a/crates/runtime/src/dialogue.rs +++ b/crates/runtime/src/dialogue.rs @@ -34,6 +34,10 @@ pub enum DialogueError { selected_option_id: OptionId, max_id: usize, }, + InvalidLineIdError { + selected_line_id: LineId, + line_ids: Vec, + }, UnexpectedOptionSelectionError, ContinueOnOptionSelectionError, NoNodeSelectedOnContinue, @@ -66,6 +70,10 @@ impl Display for DialogueError { MarkupParseError(e) => Display::fmt(e, f), LineProviderError { id, language_code } => write!(f, "Line ID \"{id}\" not found in line provider with language code {language_code:?}"), InvalidOptionIdError { selected_option_id, max_id } => write!(f, "{selected_option_id:?} is not a valid option ID (expected a number between 0 and {max_id}."), + InvalidLineIdError { selected_line_id, line_ids } => { + let line_ids = line_ids.iter().map(|id| id.0.clone()).collect::>().join(", "); + write!(f, "{selected_line_id:?} is not a valid line ID of options (expected line ids: {line_ids}.") + }, UnexpectedOptionSelectionError => f.write_str("An option was selected, but the dialogue wasn't waiting for a selection. This method should only be called after the Dialogue is waiting for the user to select an option."), ContinueOnOptionSelectionError => f.write_str("Dialogue was asked to continue running, but it is waiting for the user to select an option first."), NoNodeSelectedOnContinue => f.write_str("Cannot continue running dialogue. No node has been selected."), @@ -475,6 +483,23 @@ impl Dialogue { Ok(self) } + /// Signals to the [`Dialogue`] that the user has selected a specified [`DialogueOption`]. + /// + /// This makes dialogue replay more robust than [`self.set_selected_option`] when adding new options. + /// + /// The ID number that should be passed as the parameter to this method should be the id + /// of the [`line`] field in the [`DialogueOption`] that represents the user's selection. + /// + /// ## Panics + /// - If the Dialogue is not expecting an option to be selected. + /// - If the line ID is not found in the vector of [`DialogueOption`] provided by [`DialogueEvent::Options`]. + /// + /// ## See Also + /// - [`Dialogue::continue_`] + pub fn set_selected_option_by_line_id(&mut self, selected_line_id: LineId) -> Result { + self.vm.set_selected_option_by_line_id(selected_line_id) + } + /// Gets a value indicating whether the Dialogue is currently executing Yarn instructions. #[must_use] pub fn is_active(&self) -> bool { diff --git a/crates/runtime/src/virtual_machine.rs b/crates/runtime/src/virtual_machine.rs index b77789f47..6a5a26d94 100644 --- a/crates/runtime/src/virtual_machine.rs +++ b/crates/runtime/src/virtual_machine.rs @@ -254,6 +254,36 @@ impl VirtualMachine { Ok(()) } + pub(crate) fn set_selected_option_by_line_id( + &mut self, + selected_line_id: LineId, + ) -> Result { + if self.execution_state != ExecutionState::WaitingOnOptionSelection { + return Err(DialogueError::UnexpectedOptionSelectionError); + } + if let Some(selected_option) = self + .state + .current_options + .iter() + .find(|o| o.line.id == selected_line_id) + { + let selected_option_id = selected_option.id; + self.set_selected_option(selected_option_id) + .map(|_| selected_option_id) + } else { + let line_ids = self + .state + .current_options + .iter() + .map(|o| o.line.id.clone()) + .collect(); + Err(DialogueError::InvalidLineIdError { + selected_line_id, + line_ids, + }) + } + } + pub(crate) fn is_active(&self) -> bool { self.execution_state != ExecutionState::Stopped }