From 610319b79fb8b32d1987f05212f47810031a6e83 Mon Sep 17 00:00:00 2001 From: giusdp Date: Fri, 3 Jul 2026 10:20:48 +0200 Subject: [PATCH 01/12] feat: condition and script in datamodel --- src/data/entry.rs | 6 ++++++ src/loader/validate.rs | 2 ++ src/runtime/runner.rs | 2 ++ src/runtime/step.rs | 2 ++ src/runtime/variables.rs | 6 +++++- 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/data/entry.rs b/src/data/entry.rs index 924bd1d..efc55b9 100644 --- a/src/data/entry.rs +++ b/src/data/entry.rs @@ -29,4 +29,10 @@ pub struct DialogueEntry { /// Custom fields. #[serde(default)] pub fields: Vec, + /// Rune expression gating whether this entry can be reached. Empty means always. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub condition: String, + /// Rune code run when this entry is presented. Empty means nothing to run. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub script: String, } diff --git a/src/loader/validate.rs b/src/loader/validate.rs index 7b46aad..1b3de9a 100644 --- a/src/loader/validate.rs +++ b/src/loader/validate.rs @@ -134,6 +134,8 @@ mod tests { is_group: false, links: vec![], fields: vec![], + condition: String::new(), + script: String::new(), }], fields: vec![], }], diff --git a/src/runtime/runner.rs b/src/runtime/runner.rs index 9d8c0f3..d1f8749 100644 --- a/src/runtime/runner.rs +++ b/src/runtime/runner.rs @@ -349,6 +349,8 @@ mod tests { }) .collect(), fields: vec![], + condition: String::new(), + script: String::new(), }; DialogueDatabase { version: "1".to_owned(), diff --git a/src/runtime/step.rs b/src/runtime/step.rs index 4d0ebb2..e94e6d5 100644 --- a/src/runtime/step.rs +++ b/src/runtime/step.rs @@ -213,6 +213,8 @@ mod tests { }) .collect(), fields: vec![], + condition: String::new(), + script: String::new(), } }; DialogueDatabase { diff --git a/src/runtime/variables.rs b/src/runtime/variables.rs index c8bcd8d..549dfad 100644 --- a/src/runtime/variables.rs +++ b/src/runtime/variables.rs @@ -10,7 +10,11 @@ use bevy::prelude::*; use crate::data::{DialogueDatabase, FieldValue}; /// Current variable values, keyed by name. -#[derive(Resource, Debug, Default)] +/// +/// Also available to conditions and scripts as `var`, see [`crate::scripting`]. +/// Clone exists because the store moves in and out of script scopes; it is +/// not meant for keeping copies around. +#[derive(Resource, Debug, Default, Clone)] pub struct Variables(pub HashMap); impl Variables { From 722a887f735bbf1f132e896dd96db75301e60381 Mon Sep 17 00:00:00 2001 From: giusdp Date: Fri, 3 Jul 2026 10:20:48 +0200 Subject: [PATCH 02/12] feat: add rhai for dialogue scripting --- Cargo.toml | 2 + src/lib.rs | 4 + src/runtime/variables.rs | 2 +- src/scripting.rs | 328 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 src/scripting.rs diff --git a/Cargo.toml b/Cargo.toml index a9a458c..fc548c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,8 @@ too_many_arguments = "allow" thiserror = "2" serde = { version = "1", features = ["derive"] } ron = "0.12" +# sync: Engine and ASTs live in Bevy resources, which must be Send + Sync. +rhai = { version = "1", features = ["sync"] } # Bevy Dependencies bevy = { version = "0.19", default-features = false, features = [ diff --git a/src/lib.rs b/src/lib.rs index e1d975d..c9350cd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod loader; pub mod persist; pub mod runtime; pub mod saver; +pub mod scripting; /// The plugin that provides dialogue and conversation handling. pub struct TalksPlugin; @@ -23,10 +24,13 @@ impl Plugin for TalksPlugin { .init_asset_loader::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, ( runtime::variables::seed_variables, + scripting::compile_scripts, runtime::runner::start_runners, ) .chain(), diff --git a/src/runtime/variables.rs b/src/runtime/variables.rs index 549dfad..137ecca 100644 --- a/src/runtime/variables.rs +++ b/src/runtime/variables.rs @@ -11,7 +11,7 @@ use crate::data::{DialogueDatabase, FieldValue}; /// Current variable values, keyed by name. /// -/// Also available to conditions and scripts as `var`, see [`crate::scripting`]. +/// Also available to conditions and scripts as `vars`, see [`crate::scripting`]. /// Clone exists because the store moves in and out of script scopes; it is /// not meant for keeping copies around. #[derive(Resource, Debug, Default, Clone)] diff --git a/src/scripting.rs b/src/scripting.rs new file mode 100644 index 0000000..ff5d55c --- /dev/null +++ b/src/scripting.rs @@ -0,0 +1,328 @@ +//! Rhai scripting: the engine behind entry conditions and scripts. +//! +//! Conditions and scripts authored on [`DialogueEntry`](crate::data::DialogueEntry) +//! are Rhai code. Both see the variable store as `vars`: +//! +//! ```rhai +//! // a condition: +//! vars["Gold"] >= 10 && !vars["AcceptedJob"] +//! +//! // a script: +//! vars["AcceptedJob"] = true; +//! vars["Gold"] -= 10; +//! ``` +//! +//! Reading an unknown variable is an error; `vars.has("name")` tests existence. +//! Writing creates the variable if needed. Numbers are floats on the script +//! side, but Rhai mixes integers and floats freely, so `vars["Gold"] >= 10` +//! works. + +use std::collections::HashMap; + +use bevy::prelude::*; +use rhai::{AST, Dynamic, Engine, EvalAltResult, ParseError}; + +use crate::data::{ConversationId, DialogueDatabase, DialogueEntry, EntryId, FieldValue}; +use crate::runtime::Variables; + +/// The engine that evaluates dialogue conditions and scripts. +#[derive(Resource)] +pub struct ScriptEngine(pub Engine); + +impl Default for ScriptEngine { + fn default() -> Self { + Self(engine()) + } +} + +/// The compiled logic of one entry. +struct CompiledLogic { + /// The entry's condition, if it has one. + condition: Option, + /// The entry's script, if it has one. + script: Option, +} + +/// Compiled conditions and scripts of every loaded database, by entry. +#[derive(Resource, Default)] +pub struct CompiledScripts(HashMap<(ConversationId, EntryId), CompiledLogic>); + +impl CompiledScripts { + /// The compiled condition of `key`'s entry, if it has one. + pub fn condition(&self, key: (ConversationId, EntryId)) -> Option<&AST> { + self.0.get(&key)?.condition.as_ref() + } + + /// The compiled script of `key`'s entry, if it has one. + pub fn script(&self, key: (ConversationId, EntryId)) -> Option<&AST> { + self.0.get(&key)?.script.as_ref() + } +} + +/// Compiles conditions and scripts from every database as it loads. +/// +/// Conditions compile in expression mode: statements like `vars["x"] = 1` +/// are load-time errors there. Anything that fails to compile is reported +/// and skipped, so a broken condition never blocks dialogue. +pub fn compile_scripts( + mut events: MessageReader>, + databases: Res>, + engine: Res, + mut compiled: ResMut, +) { + let relevant = events.read().any(|event| { + matches!( + event, + AssetEvent::Added { .. } | AssetEvent::Modified { .. } + ) + }); + if !relevant { + return; + } + + compiled.0 = databases + .iter() + .flat_map(|(_, db)| &db.conversations) + .flat_map(|conversation| { + conversation + .entries + .iter() + .map(|entry| ((conversation.id, entry.id), entry)) + }) + .filter_map(|(key, entry)| Some((key, compile_entry(&engine.0, key, entry)?))) + .collect(); +} + +/// Compiles one entry's logic; `None` when the entry has none. +fn compile_entry( + engine: &Engine, + key: (ConversationId, EntryId), + entry: &DialogueEntry, +) -> Option { + let condition = compile_snippet(&entry.condition, key, "condition", |text| { + engine.compile_expression(text) + }); + let script = compile_snippet(&entry.script, key, "script", |text| engine.compile(text)); + (condition.is_some() || script.is_some()).then_some(CompiledLogic { condition, script }) +} + +/// Compiles one authored snippet, reporting failures. Empty text is no logic. +fn compile_snippet( + text: &str, + key: (ConversationId, EntryId), + what: &str, + compile: impl FnOnce(&str) -> Result, +) -> Option { + (!text.is_empty()) + .then(|| compile(text))? + .inspect_err(|error| { + warn!( + "{what} on entry {} of conversation {} doesn't compile: {error}", + key.1.0, key.0.0 + ); + }) + .ok() +} + +/// Builds the engine that evaluates dialogue conditions and scripts. +pub fn engine() -> Engine { + let mut engine = Engine::new(); + engine + .register_type_with_name::("Variables") + .register_indexer_get(get_variable) + .register_indexer_set(set_variable) + .register_fn("has", |vars: &mut Variables, name: &str| { + vars.get(name).is_some() + }); + engine +} + +/// `vars[name]`: the variable's current value. Unknown names are an error. +fn get_variable(vars: &mut Variables, name: &str) -> Result> { + match vars.get(name) { + Some(value) => Ok(to_dynamic(value)), + None => Err(format!("unknown variable `{name}`").into()), + } +} + +/// `vars[name] = value`: sets the variable, creating it if needed. +fn set_variable( + vars: &mut Variables, + name: &str, + value: Dynamic, +) -> Result<(), Box> { + match from_dynamic(&value) { + Some(value) => { + vars.set(name, value); + Ok(()) + } + None => Err(format!( + "variable `{name}` can't hold a value of type {}", + value.type_name() + ) + .into()), + } +} + +/// A variable value as a script value. Numbers become floats, actors their id. +fn to_dynamic(value: &FieldValue) -> Dynamic { + match value { + FieldValue::Text(s) | FieldValue::Localization(s) => s.as_str().into(), + FieldValue::Number(n) => Dynamic::from_float(f64::from(*n)), + FieldValue::Boolean(b) => Dynamic::from_bool(*b), + FieldValue::Actor(id) => Dynamic::from_int(i64::from(id.0)), + } +} + +/// A script value as a variable value: bools, numbers (int or float), text. +fn from_dynamic(value: &Dynamic) -> Option { + if let Ok(b) = value.as_bool() { + return Some(FieldValue::Boolean(b)); + } + if let Ok(n) = value.as_float() { + return Some(FieldValue::Number(n as f32)); + } + if let Ok(n) = value.as_int() { + return Some(FieldValue::Number(n as f32)); + } + if value.is_string() { + return Some(FieldValue::Text(value.clone().into_string().ok()?)); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::Conversation; + use rhai::Scope; + use rstest::{fixture, rstest}; + + #[fixture] + fn db() -> DialogueDatabase { + let entry = |id: i32, condition: &str, script: &str| DialogueEntry { + id: EntryId(id), + condition: condition.to_owned(), + script: script.to_owned(), + ..Default::default() + }; + DialogueDatabase { + conversations: vec![Conversation { + id: ConversationId(1), + entries: vec![ + entry(1, "", r#"vars["Greeted"] = true"#), + entry(2, r#"vars["Gold"] >= 10"#, ""), + entry(3, "vars[", ""), + entry(4, r#"vars["x"] = 1"#, ""), + entry(5, "", ""), + ], + ..Default::default() + }], + ..Default::default() + } + } + + #[rstest] + fn loading_a_database_compiles_its_logic(db: DialogueDatabase) { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default(), crate::TalksPlugin)); + let _handle = app + .world_mut() + .resource_mut::>() + .add(db); + // Asset events land after Update; the compiler sees them next frame. + app.update(); + app.update(); + + let compiled = app.world().resource::(); + let key = |id| (ConversationId(1), EntryId(id)); + assert!(compiled.script(key(1)).is_some()); + assert!(compiled.condition(key(1)).is_none()); + assert!(compiled.condition(key(2)).is_some()); + assert!( + compiled.condition(key(3)).is_none(), + "a broken condition is reported and skipped" + ); + assert!( + compiled.condition(key(4)).is_none(), + "statements don't compile as conditions" + ); + assert!(compiled.script(key(5)).is_none()); + } + + #[rstest] + fn compiled_conditions_evaluate_against_the_store(mut vars: Variables) { + let engine = engine(); + let ast = engine.compile_expression(r#"vars["Gold"] >= 10"#).unwrap(); + let mut scope = Scope::new(); + scope.push("vars", std::mem::take(&mut vars)); + assert!( + engine + .eval_ast_with_scope::(&mut scope, &ast) + .unwrap() + ); + } + + #[fixture] + fn vars() -> Variables { + let mut vars = Variables::default(); + vars.set("Gold", 12.0); + vars.set("Name", "Feri"); + vars.set("AcceptedJob", false); + vars + } + + /// Evaluates `code` with the store exposed as `vars`, moving it in and out. + fn eval( + code: &str, + vars: &mut Variables, + ) -> Result> { + let engine = engine(); + let mut scope = Scope::new(); + scope.push("vars", std::mem::take(vars)); + let result = engine.eval_with_scope::(&mut scope, code); + *vars = scope.remove("vars").expect("the store stays in scope"); + result + } + + #[rstest] + fn conditions_compare_numbers_with_int_literals(mut vars: Variables) { + assert!(eval::(r#"vars["Gold"] >= 10"#, &mut vars).unwrap()); + vars.set("Gold", 5.0); + assert!(!eval::(r#"vars["Gold"] >= 10"#, &mut vars).unwrap()); + } + + #[rstest] + fn conditions_read_text_and_bools(mut vars: Variables) { + assert!( + eval::( + r#"vars["Name"] == "Feri" && !vars["AcceptedJob"]"#, + &mut vars + ) + .unwrap() + ); + } + + #[rstest] + fn scripts_write_back_to_the_store(mut vars: Variables) { + eval::<()>( + r#"vars["AcceptedJob"] = true; vars["Gold"] += 30; vars["Greeting"] = "hi";"#, + &mut vars, + ) + .unwrap(); + assert!(vars.truthy("AcceptedJob")); + assert_eq!(vars.number("Gold"), 42.0); + assert_eq!(vars.text("Greeting"), "hi"); + } + + #[rstest] + fn reading_an_unknown_variable_is_an_error(mut vars: Variables) { + let error = eval::(r#"vars["Nope"]"#, &mut vars).unwrap_err(); + assert!(error.to_string().contains("unknown variable `Nope`")); + } + + #[rstest] + fn has_tests_existence(mut vars: Variables) { + assert!(eval::(r#"vars.has("Gold") && !vars.has("Nope")"#, &mut vars).unwrap()); + } +} From 21e3400d570a931e3bd6c7e7dad05460b5fceac1 Mon Sep 17 00:00:00 2001 From: giusdp Date: Fri, 3 Jul 2026 14:26:52 +0200 Subject: [PATCH 03/12] feat: game systems callable from dialogue scripts --- Cargo.toml | 1 + src/lib.rs | 3 + src/prelude.rs | 3 + src/runtime/variables.rs | 4 +- src/scripting.rs | 328 -------------------------- src/scripting/functions.rs | 286 +++++++++++++++++++++++ src/scripting/mod.rs | 455 +++++++++++++++++++++++++++++++++++++ 7 files changed, 749 insertions(+), 331 deletions(-) delete mode 100644 src/scripting.rs create mode 100644 src/scripting/functions.rs create mode 100644 src/scripting/mod.rs diff --git a/Cargo.toml b/Cargo.toml index fc548c7..5370990 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ serde = { version = "1", features = ["derive"] } ron = "0.12" # sync: Engine and ASTs live in Bevy resources, which must be Send + Sync. rhai = { version = "1", features = ["sync"] } +scoped-tls-hkt = "0.1" # Bevy Dependencies bevy = { version = "0.19", default-features = false, features = [ diff --git a/src/lib.rs b/src/lib.rs index c9350cd..4d50f65 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,11 +24,14 @@ impl Plugin for TalksPlugin { .init_asset_loader::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .add_systems( Update, ( + scripting::rebuild_engine + .run_if(resource_changed::), runtime::variables::seed_variables, scripting::compile_scripts, runtime::runner::start_runners, diff --git a/src/prelude.rs b/src/prelude.rs index 2da323f..13de3cc 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -13,3 +13,6 @@ pub use super::runtime::{ VisitCount, Visits, }; pub use super::saver::{DialogueDatabaseSaver, SaveError, to_ron_string}; +pub use super::scripting::{ + AddDialogueSystem, DialogueSystems, ScriptArg, ScriptArgs, ScriptReturn, +}; diff --git a/src/runtime/variables.rs b/src/runtime/variables.rs index 137ecca..c709f9d 100644 --- a/src/runtime/variables.rs +++ b/src/runtime/variables.rs @@ -12,9 +12,7 @@ use crate::data::{DialogueDatabase, FieldValue}; /// Current variable values, keyed by name. /// /// Also available to conditions and scripts as `vars`, see [`crate::scripting`]. -/// Clone exists because the store moves in and out of script scopes; it is -/// not meant for keeping copies around. -#[derive(Resource, Debug, Default, Clone)] +#[derive(Resource, Debug, Default)] pub struct Variables(pub HashMap); impl Variables { diff --git a/src/scripting.rs b/src/scripting.rs deleted file mode 100644 index ff5d55c..0000000 --- a/src/scripting.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! Rhai scripting: the engine behind entry conditions and scripts. -//! -//! Conditions and scripts authored on [`DialogueEntry`](crate::data::DialogueEntry) -//! are Rhai code. Both see the variable store as `vars`: -//! -//! ```rhai -//! // a condition: -//! vars["Gold"] >= 10 && !vars["AcceptedJob"] -//! -//! // a script: -//! vars["AcceptedJob"] = true; -//! vars["Gold"] -= 10; -//! ``` -//! -//! Reading an unknown variable is an error; `vars.has("name")` tests existence. -//! Writing creates the variable if needed. Numbers are floats on the script -//! side, but Rhai mixes integers and floats freely, so `vars["Gold"] >= 10` -//! works. - -use std::collections::HashMap; - -use bevy::prelude::*; -use rhai::{AST, Dynamic, Engine, EvalAltResult, ParseError}; - -use crate::data::{ConversationId, DialogueDatabase, DialogueEntry, EntryId, FieldValue}; -use crate::runtime::Variables; - -/// The engine that evaluates dialogue conditions and scripts. -#[derive(Resource)] -pub struct ScriptEngine(pub Engine); - -impl Default for ScriptEngine { - fn default() -> Self { - Self(engine()) - } -} - -/// The compiled logic of one entry. -struct CompiledLogic { - /// The entry's condition, if it has one. - condition: Option, - /// The entry's script, if it has one. - script: Option, -} - -/// Compiled conditions and scripts of every loaded database, by entry. -#[derive(Resource, Default)] -pub struct CompiledScripts(HashMap<(ConversationId, EntryId), CompiledLogic>); - -impl CompiledScripts { - /// The compiled condition of `key`'s entry, if it has one. - pub fn condition(&self, key: (ConversationId, EntryId)) -> Option<&AST> { - self.0.get(&key)?.condition.as_ref() - } - - /// The compiled script of `key`'s entry, if it has one. - pub fn script(&self, key: (ConversationId, EntryId)) -> Option<&AST> { - self.0.get(&key)?.script.as_ref() - } -} - -/// Compiles conditions and scripts from every database as it loads. -/// -/// Conditions compile in expression mode: statements like `vars["x"] = 1` -/// are load-time errors there. Anything that fails to compile is reported -/// and skipped, so a broken condition never blocks dialogue. -pub fn compile_scripts( - mut events: MessageReader>, - databases: Res>, - engine: Res, - mut compiled: ResMut, -) { - let relevant = events.read().any(|event| { - matches!( - event, - AssetEvent::Added { .. } | AssetEvent::Modified { .. } - ) - }); - if !relevant { - return; - } - - compiled.0 = databases - .iter() - .flat_map(|(_, db)| &db.conversations) - .flat_map(|conversation| { - conversation - .entries - .iter() - .map(|entry| ((conversation.id, entry.id), entry)) - }) - .filter_map(|(key, entry)| Some((key, compile_entry(&engine.0, key, entry)?))) - .collect(); -} - -/// Compiles one entry's logic; `None` when the entry has none. -fn compile_entry( - engine: &Engine, - key: (ConversationId, EntryId), - entry: &DialogueEntry, -) -> Option { - let condition = compile_snippet(&entry.condition, key, "condition", |text| { - engine.compile_expression(text) - }); - let script = compile_snippet(&entry.script, key, "script", |text| engine.compile(text)); - (condition.is_some() || script.is_some()).then_some(CompiledLogic { condition, script }) -} - -/// Compiles one authored snippet, reporting failures. Empty text is no logic. -fn compile_snippet( - text: &str, - key: (ConversationId, EntryId), - what: &str, - compile: impl FnOnce(&str) -> Result, -) -> Option { - (!text.is_empty()) - .then(|| compile(text))? - .inspect_err(|error| { - warn!( - "{what} on entry {} of conversation {} doesn't compile: {error}", - key.1.0, key.0.0 - ); - }) - .ok() -} - -/// Builds the engine that evaluates dialogue conditions and scripts. -pub fn engine() -> Engine { - let mut engine = Engine::new(); - engine - .register_type_with_name::("Variables") - .register_indexer_get(get_variable) - .register_indexer_set(set_variable) - .register_fn("has", |vars: &mut Variables, name: &str| { - vars.get(name).is_some() - }); - engine -} - -/// `vars[name]`: the variable's current value. Unknown names are an error. -fn get_variable(vars: &mut Variables, name: &str) -> Result> { - match vars.get(name) { - Some(value) => Ok(to_dynamic(value)), - None => Err(format!("unknown variable `{name}`").into()), - } -} - -/// `vars[name] = value`: sets the variable, creating it if needed. -fn set_variable( - vars: &mut Variables, - name: &str, - value: Dynamic, -) -> Result<(), Box> { - match from_dynamic(&value) { - Some(value) => { - vars.set(name, value); - Ok(()) - } - None => Err(format!( - "variable `{name}` can't hold a value of type {}", - value.type_name() - ) - .into()), - } -} - -/// A variable value as a script value. Numbers become floats, actors their id. -fn to_dynamic(value: &FieldValue) -> Dynamic { - match value { - FieldValue::Text(s) | FieldValue::Localization(s) => s.as_str().into(), - FieldValue::Number(n) => Dynamic::from_float(f64::from(*n)), - FieldValue::Boolean(b) => Dynamic::from_bool(*b), - FieldValue::Actor(id) => Dynamic::from_int(i64::from(id.0)), - } -} - -/// A script value as a variable value: bools, numbers (int or float), text. -fn from_dynamic(value: &Dynamic) -> Option { - if let Ok(b) = value.as_bool() { - return Some(FieldValue::Boolean(b)); - } - if let Ok(n) = value.as_float() { - return Some(FieldValue::Number(n as f32)); - } - if let Ok(n) = value.as_int() { - return Some(FieldValue::Number(n as f32)); - } - if value.is_string() { - return Some(FieldValue::Text(value.clone().into_string().ok()?)); - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::data::Conversation; - use rhai::Scope; - use rstest::{fixture, rstest}; - - #[fixture] - fn db() -> DialogueDatabase { - let entry = |id: i32, condition: &str, script: &str| DialogueEntry { - id: EntryId(id), - condition: condition.to_owned(), - script: script.to_owned(), - ..Default::default() - }; - DialogueDatabase { - conversations: vec![Conversation { - id: ConversationId(1), - entries: vec![ - entry(1, "", r#"vars["Greeted"] = true"#), - entry(2, r#"vars["Gold"] >= 10"#, ""), - entry(3, "vars[", ""), - entry(4, r#"vars["x"] = 1"#, ""), - entry(5, "", ""), - ], - ..Default::default() - }], - ..Default::default() - } - } - - #[rstest] - fn loading_a_database_compiles_its_logic(db: DialogueDatabase) { - let mut app = App::new(); - app.add_plugins((MinimalPlugins, AssetPlugin::default(), crate::TalksPlugin)); - let _handle = app - .world_mut() - .resource_mut::>() - .add(db); - // Asset events land after Update; the compiler sees them next frame. - app.update(); - app.update(); - - let compiled = app.world().resource::(); - let key = |id| (ConversationId(1), EntryId(id)); - assert!(compiled.script(key(1)).is_some()); - assert!(compiled.condition(key(1)).is_none()); - assert!(compiled.condition(key(2)).is_some()); - assert!( - compiled.condition(key(3)).is_none(), - "a broken condition is reported and skipped" - ); - assert!( - compiled.condition(key(4)).is_none(), - "statements don't compile as conditions" - ); - assert!(compiled.script(key(5)).is_none()); - } - - #[rstest] - fn compiled_conditions_evaluate_against_the_store(mut vars: Variables) { - let engine = engine(); - let ast = engine.compile_expression(r#"vars["Gold"] >= 10"#).unwrap(); - let mut scope = Scope::new(); - scope.push("vars", std::mem::take(&mut vars)); - assert!( - engine - .eval_ast_with_scope::(&mut scope, &ast) - .unwrap() - ); - } - - #[fixture] - fn vars() -> Variables { - let mut vars = Variables::default(); - vars.set("Gold", 12.0); - vars.set("Name", "Feri"); - vars.set("AcceptedJob", false); - vars - } - - /// Evaluates `code` with the store exposed as `vars`, moving it in and out. - fn eval( - code: &str, - vars: &mut Variables, - ) -> Result> { - let engine = engine(); - let mut scope = Scope::new(); - scope.push("vars", std::mem::take(vars)); - let result = engine.eval_with_scope::(&mut scope, code); - *vars = scope.remove("vars").expect("the store stays in scope"); - result - } - - #[rstest] - fn conditions_compare_numbers_with_int_literals(mut vars: Variables) { - assert!(eval::(r#"vars["Gold"] >= 10"#, &mut vars).unwrap()); - vars.set("Gold", 5.0); - assert!(!eval::(r#"vars["Gold"] >= 10"#, &mut vars).unwrap()); - } - - #[rstest] - fn conditions_read_text_and_bools(mut vars: Variables) { - assert!( - eval::( - r#"vars["Name"] == "Feri" && !vars["AcceptedJob"]"#, - &mut vars - ) - .unwrap() - ); - } - - #[rstest] - fn scripts_write_back_to_the_store(mut vars: Variables) { - eval::<()>( - r#"vars["AcceptedJob"] = true; vars["Gold"] += 30; vars["Greeting"] = "hi";"#, - &mut vars, - ) - .unwrap(); - assert!(vars.truthy("AcceptedJob")); - assert_eq!(vars.number("Gold"), 42.0); - assert_eq!(vars.text("Greeting"), "hi"); - } - - #[rstest] - fn reading_an_unknown_variable_is_an_error(mut vars: Variables) { - let error = eval::(r#"vars["Nope"]"#, &mut vars).unwrap_err(); - assert!(error.to_string().contains("unknown variable `Nope`")); - } - - #[rstest] - fn has_tests_existence(mut vars: Variables) { - assert!(eval::(r#"vars.has("Gold") && !vars.has("Nope")"#, &mut vars).unwrap()); - } -} diff --git a/src/scripting/functions.rs b/src/scripting/functions.rs new file mode 100644 index 0000000..1822f90 --- /dev/null +++ b/src/scripting/functions.rs @@ -0,0 +1,286 @@ +//! Game functions callable from conditions and scripts. +//! +//! The game registers Bevy systems under script-facing names: +//! +//! ```rust,ignore +//! app.add_dialogue_system("has_item", |In(name): In, inventory: Res| { +//! inventory.contains(&name) +//! }); +//! ``` +//! +//! A condition can then say `has_item("sword")` and a script `give_item("sword")`. +//! The function runs as a one-shot system in the middle of evaluation, with +//! full world access: queries, resources, commands. + +use std::sync::Arc; + +use bevy::prelude::*; +use rhai::{Dynamic, Engine, EvalAltResult}; +use scoped_tls_hkt::scoped_thread_local; + +scoped_thread_local!( + /// The world evaluating dialogue logic right now. + pub(crate) static mut WORLD: for<'a> &'a mut World +); + +/// Runs `f` against the world evaluating dialogue logic. +/// +/// Errors when no evaluation is in progress, e.g. when script code runs +/// outside the dialogue runtime. +pub(crate) fn with_world(f: impl FnOnce(&mut World) -> R) -> Result> { + if !WORLD.is_set() { + return Err("dialogue logic evaluated outside the dialogue runtime".into()); + } + Ok(WORLD.with(f)) +} + +/// Converts script arguments, runs the game system, converts the result. +type Bridge = Arc) -> Result + Send + Sync>; + +/// One registered dialogue system. +struct DialogueSystem { + /// The name scripts call. + name: String, + /// How many arguments it takes. + arity: usize, + /// The typed path into the game's system. + bridge: Bridge, +} + +/// Every system the game registered for use in dialogue logic. +#[derive(Resource, Default)] +pub struct DialogueSystems(Vec); + +impl DialogueSystems { + /// Registers all systems' shims with `engine`. + pub(crate) fn install_into(&self, engine: &mut Engine) { + for system in &self.0 { + install(engine, system); + } + } +} + +/// Registers one system's shim with the engine. +fn install(engine: &mut Engine, system: &DialogueSystem) { + let bridge = system.bridge.clone(); + let called = system.name.clone(); + let call = move |args: Vec| -> Result> { + with_world(|world| bridge(world, args))? + .map_err(|error| format!("{called}: {error}").into()) + }; + let name = system.name.as_str(); + match system.arity { + 0 => { + engine.register_fn(name, move || call(Vec::new())); + } + 1 => { + engine.register_fn(name, move |a: Dynamic| call(vec![a])); + } + 2 => { + engine.register_fn(name, move |a: Dynamic, b: Dynamic| call(vec![a, b])); + } + 3 => { + engine.register_fn(name, move |a: Dynamic, b: Dynamic, c: Dynamic| { + call(vec![a, b, c]) + }); + } + 4 => { + engine.register_fn( + name, + move |a: Dynamic, b: Dynamic, c: Dynamic, d: Dynamic| call(vec![a, b, c, d]), + ); + } + n => warn!("dialogue system `{name}` takes {n} arguments; the limit is 4"), + } +} + +/// A single value passed from script to a dialogue system. +pub trait ScriptArg: Sized { + /// Converts the script-side value; errors name the expected type. + fn from_dynamic(value: Dynamic) -> Result; +} + +impl ScriptArg for bool { + fn from_dynamic(value: Dynamic) -> Result { + value + .as_bool() + .map_err(|got| format!("expected a bool, got {got}")) + } +} + +impl ScriptArg for i64 { + fn from_dynamic(value: Dynamic) -> Result { + value + .as_int() + .map_err(|got| format!("expected an integer, got {got}")) + } +} + +impl ScriptArg for f64 { + fn from_dynamic(value: Dynamic) -> Result { + value + .as_float() + .or_else(|_| value.as_int().map(|n| n as f64)) + .map_err(|got| format!("expected a number, got {got}")) + } +} + +impl ScriptArg for f32 { + fn from_dynamic(value: Dynamic) -> Result { + f64::from_dynamic(value).map(|n| n as f32) + } +} + +impl ScriptArg for String { + fn from_dynamic(value: Dynamic) -> Result { + value + .into_string() + .map_err(|got| format!("expected a string, got {got}")) + } +} + +impl ScriptArg for Dynamic { + fn from_dynamic(value: Dynamic) -> Result { + Ok(value) + } +} + +/// The full argument list of a dialogue system. +pub trait ScriptArgs: Sized { + /// How many arguments the script must pass. + const ARITY: usize; + + /// Converts the argument list; length is guaranteed to match [`Self::ARITY`]. + fn from_args(args: Vec) -> Result; +} + +impl ScriptArgs for () { + const ARITY: usize = 0; + + fn from_args(_: Vec) -> Result { + Ok(()) + } +} + +/// Implements [`ScriptArgs`] for systems taking a single value. +macro_rules! single_script_arg { + ($($ty:ty),*) => {$( + impl ScriptArgs for $ty { + const ARITY: usize = 1; + + fn from_args(mut args: Vec) -> Result { + ScriptArg::from_dynamic(args.remove(0)) + } + } + )*}; +} + +single_script_arg!(bool, i64, f64, f32, String, Dynamic); + +/// Implements [`ScriptArgs`] for tuples of [`ScriptArg`]s. +macro_rules! tuple_script_args { + ($count:literal: $($ty:ident),*) => { + impl<$($ty: ScriptArg),*> ScriptArgs for ($($ty,)*) { + const ARITY: usize = $count; + + fn from_args(args: Vec) -> Result { + let mut args = args.into_iter(); + Ok(($($ty::from_dynamic(args.next().expect("arity checked"))?,)*)) + } + } + }; +} + +tuple_script_args!(2: A, B); +tuple_script_args!(3: A, B, C); +tuple_script_args!(4: A, B, C, D); + +/// A dialogue system's result, as the script sees it. +pub trait ScriptReturn { + /// Converts to the script-side value. + fn into_dynamic(self) -> Dynamic; +} + +impl ScriptReturn for () { + fn into_dynamic(self) -> Dynamic { + Dynamic::UNIT + } +} + +impl ScriptReturn for bool { + fn into_dynamic(self) -> Dynamic { + Dynamic::from_bool(self) + } +} + +impl ScriptReturn for i64 { + fn into_dynamic(self) -> Dynamic { + Dynamic::from_int(self) + } +} + +impl ScriptReturn for f64 { + fn into_dynamic(self) -> Dynamic { + Dynamic::from_float(self) + } +} + +impl ScriptReturn for f32 { + fn into_dynamic(self) -> Dynamic { + Dynamic::from_float(f64::from(self)) + } +} + +impl ScriptReturn for String { + fn into_dynamic(self) -> Dynamic { + self.into() + } +} + +impl ScriptReturn for Dynamic { + fn into_dynamic(self) -> Dynamic { + self + } +} + +/// App extension for making game systems callable from dialogue logic. +pub trait AddDialogueSystem { + /// Makes `system` callable from conditions and scripts as `name`. + /// + /// The system's `In` input is the argument list (a value or a tuple, up + /// to four [`ScriptArg`]s); its return value, if any, becomes the call's + /// result in the script. + fn add_dialogue_system(&mut self, name: impl Into, system: S) -> &mut Self + where + S: IntoSystem, O, M> + 'static, + I: ScriptArgs + Send + Sync + 'static, + O: ScriptReturn + Send + Sync + 'static; +} + +impl AddDialogueSystem for App { + fn add_dialogue_system(&mut self, name: impl Into, system: S) -> &mut Self + where + S: IntoSystem, O, M> + 'static, + I: ScriptArgs + Send + Sync + 'static, + O: ScriptReturn + Send + Sync + 'static, + { + let id = self.world_mut().register_system(system); + let bridge: Bridge = Arc::new(move |world, args| { + let input = I::from_args(args)?; + world + .run_system_with(id, input) + .map(ScriptReturn::into_dynamic) + .map_err(|error| error.to_string()) + }); + self.init_resource::(); + self.world_mut() + .resource_mut::() + .0 + .push(DialogueSystem { + name: name.into(), + arity: I::ARITY, + bridge, + }); + self + } +} diff --git a/src/scripting/mod.rs b/src/scripting/mod.rs new file mode 100644 index 0000000..4713b66 --- /dev/null +++ b/src/scripting/mod.rs @@ -0,0 +1,455 @@ +//! Rhai scripting: the engine behind entry conditions and scripts. +//! +//! Conditions and scripts authored on [`DialogueEntry`](crate::data::DialogueEntry) +//! are Rhai code. Both see the variable store as `vars` and can call any +//! system the game registered with +//! [`add_dialogue_system`](AddDialogueSystem::add_dialogue_system): +//! +//! ```rhai +//! // a condition: +//! vars["Gold"] >= 10 && has_item("sword") +//! +//! // a script: +//! vars["AcceptedJob"] = true; +//! give_item("sword"); +//! ``` +//! +//! Reading an unknown variable is an error; `vars.has("name")` tests existence. +//! Writing creates the variable if needed. Numbers are floats on the script +//! side, but Rhai mixes integers and floats freely, so `vars["Gold"] >= 10` +//! works. +//! +//! Broken logic never blocks dialogue: a condition that fails to compile or +//! errors at runtime passes (with a warning), a failing script is reported +//! and skipped. + +pub mod functions; + +pub use functions::{AddDialogueSystem, DialogueSystems, ScriptArg, ScriptArgs, ScriptReturn}; + +use std::collections::HashMap; +use std::sync::Arc; + +use bevy::prelude::*; +use rhai::{AST, Dynamic, Engine, EvalAltResult, ParseError, Scope}; + +use crate::data::{ConversationId, DialogueDatabase, DialogueEntry, EntryId, FieldValue}; +use crate::runtime::Variables; +use functions::{WORLD, with_world}; + +/// The engine that evaluates dialogue conditions and scripts. +/// +/// Rebuilt whenever the game's registered dialogue systems change. +#[derive(Resource)] +pub struct ScriptEngine(pub Arc); + +impl Default for ScriptEngine { + fn default() -> Self { + Self(Arc::new(build_engine(&DialogueSystems::default()))) + } +} + +/// Rebuilds the engine from the registered dialogue systems. +/// +/// Runs when [`DialogueSystems`] changes; compiled ASTs stay valid because +/// Rhai resolves function calls at evaluation time. +pub fn rebuild_engine(systems: Res, mut engine: ResMut) { + engine.0 = Arc::new(build_engine(&systems)); +} + +/// Builds an engine with the store bindings and the game's dialogue systems. +fn build_engine(systems: &DialogueSystems) -> Engine { + let mut engine = Engine::new(); + engine + .register_type_with_name::("Variables") + .register_indexer_get(get_variable) + .register_indexer_set(set_variable) + .register_fn("has", has_variable); + systems.install_into(&mut engine); + engine +} + +/// The compiled logic of one entry. +struct CompiledLogic { + /// The entry's condition, if it has one. + condition: Option>, + /// The entry's script, if it has one. + script: Option>, +} + +/// Compiled conditions and scripts of every loaded database, by entry. +#[derive(Resource, Default)] +pub struct CompiledScripts(HashMap<(ConversationId, EntryId), CompiledLogic>); + +impl CompiledScripts { + /// The compiled condition of `key`'s entry, if it has one. + pub fn condition(&self, key: (ConversationId, EntryId)) -> Option> { + self.0.get(&key)?.condition.clone() + } + + /// The compiled script of `key`'s entry, if it has one. + pub fn script(&self, key: (ConversationId, EntryId)) -> Option> { + self.0.get(&key)?.script.clone() + } +} + +/// Compiles conditions and scripts from every database as it loads. +/// +/// Conditions compile in expression mode: statements like `vars["x"] = 1` +/// are load-time errors there. Anything that fails to compile is reported +/// and skipped, so a broken condition never blocks dialogue. +pub fn compile_scripts( + mut events: MessageReader>, + databases: Res>, + engine: Res, + mut compiled: ResMut, +) { + let relevant = events.read().any(|event| { + matches!( + event, + AssetEvent::Added { .. } | AssetEvent::Modified { .. } + ) + }); + if !relevant { + return; + } + + compiled.0 = databases + .iter() + .flat_map(|(_, db)| &db.conversations) + .flat_map(|conversation| { + conversation + .entries + .iter() + .map(|entry| ((conversation.id, entry.id), entry)) + }) + .filter_map(|(key, entry)| Some((key, compile_entry(&engine.0, key, entry)?))) + .collect(); +} + +/// Compiles one entry's logic; `None` when the entry has none. +fn compile_entry( + engine: &Engine, + key: (ConversationId, EntryId), + entry: &DialogueEntry, +) -> Option { + let condition = compile_snippet(&entry.condition, key, "condition", |text| { + engine.compile_expression(text) + }); + let script = compile_snippet(&entry.script, key, "script", |text| engine.compile(text)); + (condition.is_some() || script.is_some()).then_some(CompiledLogic { condition, script }) +} + +/// Compiles one authored snippet, reporting failures. Empty text is no logic. +fn compile_snippet( + text: &str, + key: (ConversationId, EntryId), + what: &str, + compile: impl FnOnce(&str) -> Result, +) -> Option> { + (!text.is_empty()) + .then(|| compile(text))? + .inspect_err(|error| { + warn!( + "{what} on entry {} of conversation {} doesn't compile: {error}", + key.1.0, key.0.0 + ); + }) + .ok() + .map(Arc::new) +} + +/// Evaluates `key`'s condition. Entries without one, or with broken logic, pass. +pub fn check_condition(world: &mut World, key: (ConversationId, EntryId)) -> bool { + let Some(ast) = world.resource::().condition(key) else { + return true; + }; + eval_ast(world, &ast) + .and_then(|value| { + value + .as_bool() + .map_err(|got| format!("expected a bool, got {got}").into()) + }) + .unwrap_or_else(|error| { + warn!( + "condition on entry {} of conversation {} failed: {error}", + key.1.0, key.0.0 + ); + true + }) +} + +/// Runs `key`'s script, if it has one. Failures are reported and skipped. +pub fn run_script(world: &mut World, key: (ConversationId, EntryId)) { + let Some(ast) = world.resource::().script(key) else { + return; + }; + if let Err(error) = eval_ast(world, &ast) { + warn!( + "script on entry {} of conversation {} failed: {error}", + key.1.0, key.0.0 + ); + } +} + +/// Evaluates a compiled AST with `vars` bound and the world reachable. +fn eval_ast(world: &mut World, ast: &AST) -> Result> { + let engine = world.resource::().0.clone(); + let mut scope = Scope::new(); + scope.push("vars", VarStore); + WORLD.set(world, || { + engine.eval_ast_with_scope::(&mut scope, ast) + }) +} + +/// The `vars` binding scripts see: a handle to the [`Variables`] resource of +/// the world being evaluated. +#[derive(Clone, Copy)] +struct VarStore; + +/// `vars[name]`: the variable's current value. Unknown names are an error. +fn get_variable(_: &mut VarStore, name: &str) -> Result> { + with_world(|world| { + world + .resource::() + .get(name) + .map(to_dynamic) + .ok_or_else(|| format!("unknown variable `{name}`")) + })? + .map_err(Into::into) +} + +/// `vars[name] = value`: sets the variable, creating it if needed. +fn set_variable(_: &mut VarStore, name: &str, value: Dynamic) -> Result<(), Box> { + let Some(converted) = from_dynamic(&value) else { + return Err(format!( + "variable `{name}` can't hold a value of type {}", + value.type_name() + ) + .into()); + }; + with_world(|world| world.resource_mut::().set(name, converted)) +} + +/// `vars.has(name)`: whether the variable exists. +fn has_variable(_: &mut VarStore, name: &str) -> Result> { + with_world(|world| world.resource::().get(name).is_some()) +} + +/// A variable value as a script value. Numbers become floats, actors their id. +fn to_dynamic(value: &FieldValue) -> Dynamic { + match value { + FieldValue::Text(s) | FieldValue::Localization(s) => s.as_str().into(), + FieldValue::Number(n) => Dynamic::from_float(f64::from(*n)), + FieldValue::Boolean(b) => Dynamic::from_bool(*b), + FieldValue::Actor(id) => Dynamic::from_int(i64::from(id.0)), + } +} + +/// A script value as a variable value: bools, numbers (int or float), text. +fn from_dynamic(value: &Dynamic) -> Option { + if let Ok(b) = value.as_bool() { + return Some(FieldValue::Boolean(b)); + } + if let Ok(n) = value.as_float() { + return Some(FieldValue::Number(n as f32)); + } + if let Ok(n) = value.as_int() { + return Some(FieldValue::Number(n as f32)); + } + if value.is_string() { + return Some(FieldValue::Text(value.clone().into_string().ok()?)); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::Conversation; + use rstest::{fixture, rstest}; + + #[fixture] + fn db() -> DialogueDatabase { + let entry = |id: i32, condition: &str, script: &str| DialogueEntry { + id: EntryId(id), + condition: condition.to_owned(), + script: script.to_owned(), + ..Default::default() + }; + DialogueDatabase { + conversations: vec![Conversation { + id: ConversationId(1), + entries: vec![ + entry(1, "", r#"vars["Greeted"] = true"#), + entry(2, r#"vars["Gold"] >= 10"#, ""), + entry(3, "vars[", ""), + entry(4, r#"vars["x"] = 1"#, ""), + entry(5, "", ""), + entry(6, r#"vars["Missing"] > 1"#, ""), + ], + ..Default::default() + }], + ..Default::default() + } + } + + /// A world with the resources evaluation needs. + #[fixture] + fn world() -> World { + let mut vars = Variables::default(); + vars.set("Gold", 12.0); + vars.set("Name", "Feri"); + vars.set("AcceptedJob", false); + + let mut world = World::new(); + world.insert_resource(vars); + world.init_resource::(); + world.init_resource::(); + world + } + + /// Compiles and evaluates `code` as dialogue logic against `world`. + fn eval(code: &str, world: &mut World) -> Result> { + let engine = world.resource::().0.clone(); + let ast = engine.compile(code).map_err(|error| error.to_string())?; + eval_ast(world, &ast) + } + + /// Evaluates `code` and expects a boolean result. + fn eval_bool(code: &str, world: &mut World) -> bool { + eval(code, world).unwrap().as_bool().unwrap() + } + + #[rstest] + fn conditions_compare_numbers_with_int_literals(mut world: World) { + assert!(eval_bool(r#"vars["Gold"] >= 10"#, &mut world)); + world.resource_mut::().set("Gold", 5.0); + assert!(!eval_bool(r#"vars["Gold"] >= 10"#, &mut world)); + } + + #[rstest] + fn conditions_read_text_and_bools(mut world: World) { + assert!(eval_bool( + r#"vars["Name"] == "Feri" && !vars["AcceptedJob"]"#, + &mut world + )); + } + + #[rstest] + fn scripts_write_back_to_the_store(mut world: World) { + let _ = eval( + r#"vars["AcceptedJob"] = true; vars["Gold"] += 30; vars["Greeting"] = "hi";"#, + &mut world, + ) + .unwrap(); + let vars = world.resource::(); + assert!(vars.truthy("AcceptedJob")); + assert_eq!(vars.number("Gold"), 42.0); + assert_eq!(vars.text("Greeting"), "hi"); + } + + #[rstest] + fn reading_an_unknown_variable_is_an_error(mut world: World) { + let error = eval(r#"vars["Nope"]"#, &mut world).unwrap_err(); + assert!(error.to_string().contains("unknown variable `Nope`")); + } + + #[rstest] + fn has_tests_existence(mut world: World) { + assert!(eval_bool( + r#"vars.has("Gold") && !vars.has("Nope")"#, + &mut world + )); + } + + #[rstest] + fn loading_a_database_compiles_its_logic(db: DialogueDatabase) { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default(), crate::TalksPlugin)); + let _handle = app + .world_mut() + .resource_mut::>() + .add(db); + // Asset events land after Update; the compiler sees them next frame. + app.update(); + app.update(); + + let compiled = app.world().resource::(); + let key = |id| (ConversationId(1), EntryId(id)); + assert!(compiled.script(key(1)).is_some()); + assert!(compiled.condition(key(1)).is_none()); + assert!(compiled.condition(key(2)).is_some()); + assert!( + compiled.condition(key(3)).is_none(), + "a broken condition is reported and skipped" + ); + assert!( + compiled.condition(key(4)).is_none(), + "statements don't compile as conditions" + ); + assert!(compiled.script(key(5)).is_none()); + } + + #[rstest] + fn conditions_gate_entries_and_broken_logic_passes(db: DialogueDatabase) { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default(), crate::TalksPlugin)); + let _handle = app + .world_mut() + .resource_mut::>() + .add(db); + app.update(); + app.update(); + + let world = app.world_mut(); + world.resource_mut::().set("Gold", 12.0); + let key = |id| (ConversationId(1), EntryId(id)); + assert!(check_condition(world, key(2))); + world.resource_mut::().set("Gold", 5.0); + assert!(!check_condition(world, key(2))); + assert!(check_condition(world, key(5)), "no condition passes"); + assert!(check_condition(world, key(3)), "broken condition passes"); + assert!( + check_condition(world, key(6)), + "runtime errors pass with a warning" + ); + + run_script(world, key(1)); + assert!(world.resource::().truthy("Greeted")); + } + + /// Counts what `give_item` handed out in the dialogue-systems test. + #[derive(Resource, Default)] + struct Given(u32); + + #[rstest] + fn dialogue_systems_run_with_world_access(mut world: World) { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default(), crate::TalksPlugin)); + app.init_resource::(); + app.add_dialogue_system("double", |In(n): In| n * 2.0); + app.add_dialogue_system( + "give_item", + |In(name): In, mut given: ResMut| { + assert_eq!(name, "sword"); + given.0 += 1; + }, + ); + // First update rebuilds the engine with the registered systems. + app.update(); + + app.world_mut() + .resource_mut::() + .set("Gold", 12.0); + assert!(eval_bool( + r#"give_item("sword"); double(vars["Gold"]) >= 24"#, + app.world_mut(), + )); + assert_eq!(app.world().resource::().0, 1); + + // The fixture world has no registered systems; the call must error. + let error = eval(r#"double(2.0) == 4.0"#, &mut world).unwrap_err(); + assert!(error.to_string().contains("double")); + } +} From 9a3be727eb9343fc70333ea7d30e3460d0468eee Mon Sep 17 00:00:00 2001 From: giusdp Date: Fri, 3 Jul 2026 15:03:35 +0200 Subject: [PATCH 04/12] feat: conditions gate links and scripts run on presented lines --- src/lib.rs | 8 +- src/runtime/runner.rs | 349 +++++++++++++++++++++++++----------------- src/runtime/step.rs | 77 ++++++++-- src/scripting/mod.rs | 52 +++++-- 4 files changed, 315 insertions(+), 171 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4d50f65..803919b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,9 +32,11 @@ impl Plugin for TalksPlugin { ( scripting::rebuild_engine .run_if(resource_changed::), - runtime::variables::seed_variables, - scripting::compile_scripts, - runtime::runner::start_runners, + runtime::variables::seed_variables + .run_if(on_message::>), + scripting::compile_scripts.run_if(on_message::>), + runtime::runner::drive_runners + .run_if(any_with_component::), ) .chain(), ) diff --git a/src/runtime/runner.rs b/src/runtime/runner.rs index d1f8749..4390863 100644 --- a/src/runtime/runner.rs +++ b/src/runtime/runner.rs @@ -14,8 +14,10 @@ use super::step::{ ConversationRef, Response, Step, Subtitle, find_conversation, root_entry, step_from, subtitle_at, }; +use super::variables::Variables; use super::visits::Visits; use crate::data::{ActorId, ConversationId, DialogueDatabase, EntryId}; +use crate::scripting::{check_condition, ensure_compiled, run_script}; /// A running conversation. Spawn one to start talking. #[derive(Component, Debug)] @@ -54,7 +56,9 @@ impl DialogueRunner { match &self.phase { Phase::Presenting { at } | Phase::AwaitingChoice { at, .. } - | Phase::Resuming { at } => Some(*at), + | Phase::Resuming { at } + | Phase::Advancing { from: at } + | Phase::Choosing { to: at } => Some(*at), Phase::Starting | Phase::Ended => None, } } @@ -66,7 +70,7 @@ pub enum Phase { /// Waiting for the database asset; steps to the first line once loaded. Starting, /// Waiting for the database asset; re-presents the saved entry once - /// loaded, without counting a new visit. + /// loaded, without counting a new visit or re-running its script. Resuming { /// The entry to resume at. at: (ConversationId, EntryId), @@ -76,6 +80,11 @@ pub enum Phase { /// The entry being presented. at: (ConversationId, EntryId), }, + /// The current line is done; [`drive_runners`] steps past it next Update. + Advancing { + /// The entry being stepped past. + from: (ConversationId, EntryId), + }, /// A menu is on screen; waiting for [`ChooseResponse`]. AwaitingChoice { /// The entry whose links produced the menu. @@ -83,6 +92,11 @@ pub enum Phase { /// The offered responses. responses: Vec, }, + /// A response was picked; [`drive_runners`] presents it next Update. + Choosing { + /// The chosen entry. + to: (ConversationId, EntryId), + }, /// The conversation is over. The runner sticks around; despawning it is /// the game's call. Ended, @@ -138,164 +152,164 @@ pub struct ConversationEnded { pub entity: Entity, } -/// Starts runners in [`Phase::Starting`] or [`Phase::Resuming`] once their -/// database is available. -pub fn start_runners( - mut runners: Query<(Entity, &mut DialogueRunner, Option<&Participants>)>, - databases: Res>, - mut visits: ResMut, - mut commands: Commands, -) { - for (entity, mut runner, participants) in &mut runners { - let Some(db) = databases.get(&runner.database) else { - continue; - }; - match runner.phase { - Phase::Starting => { - let Some(root) = find_conversation(db, &runner.conversation) - .and_then(|c| root_entry(c).map(|e| (c.id, e.id))) - else { - warn!("conversation {:?} not found", runner.conversation); - let nowhere = (ConversationId::default(), EntryId::default()); - apply_step( - Step::End, - nowhere, - entity, - &mut runner, - participants, - None, - &mut commands, - ); - continue; - }; - apply_step( - step_from(db, root), - root, - entity, - &mut runner, - participants, - Some(&mut visits), - &mut commands, - ); - } - Phase::Resuming { at } => { - // Re-presents the saved entry; the player already saw it, so - // no new visit is counted. - let step = match subtitle_at(db, at) { - Some(subtitle) => Step::Line(subtitle), - None => { - warn!("resume point {at:?} not found"); - Step::End - } - }; - apply_step( - step, - at, - entity, - &mut runner, - participants, - None, - &mut commands, - ); - } - _ => {} - } - } -} - -/// Steps a presenting runner to whatever follows the current line. -pub fn on_advance( - advance: On, - mut runners: Query<(&mut DialogueRunner, Option<&Participants>)>, - databases: Res>, - mut visits: ResMut, - mut commands: Commands, -) { - let entity = advance.entity; - let Ok((mut runner, participants)) = runners.get_mut(entity) else { +/// Marks a presenting runner as ready to step past its current line. +pub fn on_advance(advance: On, mut runners: Query<&mut DialogueRunner>) { + let Ok(mut runner) = runners.get_mut(advance.entity) else { return; }; let Phase::Presenting { at } = runner.phase else { warn!("AdvanceConversation while not presenting; ignored"); return; }; - let Some(db) = databases.get(&runner.database) else { - return; - }; - apply_step( - step_from(db, at), - at, - entity, - &mut runner, - participants, - Some(&mut visits), - &mut commands, - ); + runner.phase = Phase::Advancing { from: at }; } -/// Presents the chosen player response as the next line. -pub fn on_choose( - choose: On, - mut runners: Query<(&mut DialogueRunner, Option<&Participants>)>, - databases: Res>, - mut visits: ResMut, - mut commands: Commands, -) { - let entity = choose.entity; - let Ok((mut runner, participants)) = runners.get_mut(entity) else { +/// Marks a choosing runner's picked response, by menu index. +pub fn on_choose(choose: On, mut runners: Query<&mut DialogueRunner>) { + let Ok(mut runner) = runners.get_mut(choose.entity) else { return; }; let Phase::AwaitingChoice { responses, .. } = &runner.phase else { warn!("ChooseResponse while no menu is open; ignored"); return; }; - let Some(response) = responses.get(choose.index).cloned() else { + let Some(response) = responses.get(choose.index) else { warn!("ChooseResponse index {} out of bounds", choose.index); return; }; - let Some(db) = databases.get(&runner.database) else { - return; + runner.phase = Phase::Choosing { + to: (response.conversation, response.entry), }; - let step = match subtitle_at(db, (response.conversation, response.entry)) { - Some(subtitle) => Step::Line(subtitle), - None => Step::End, +} + +/// Drives every runner with pending work: starting, resuming, advancing past +/// a finished line, or presenting a chosen response. +/// +/// Exclusive because conditions and scripts may reach anything in the world. +pub fn drive_runners(world: &mut World) { + let pending: Vec<_> = world + .query::<(Entity, &DialogueRunner)>() + .iter(world) + .filter(|(_, runner)| { + matches!( + runner.phase, + Phase::Starting + | Phase::Resuming { .. } + | Phase::Advancing { .. } + | Phase::Choosing { .. } + ) + }) + .map(|(entity, runner)| { + ( + entity, + runner.database.clone(), + runner.conversation.clone(), + runner.phase.clone(), + ) + }) + .collect(); + for (entity, database, conversation, phase) in pending { + drive_runner(world, entity, &database, conversation, phase); + } +} + +/// Steps one runner; does nothing while its database asset isn't loaded. +fn drive_runner( + world: &mut World, + entity: Entity, + database: &Handle, + conversation: ConversationRef, + phase: Phase, +) { + // The database is cloned out of Assets so conditions, scripts, and + // observers keep unrestricted world access while we traverse it. + let Some(db) = world + .resource::>() + .get(database) + .cloned() + else { + return; }; - apply_step( - step, - (response.conversation, response.entry), - entity, - &mut runner, - participants, - Some(&mut visits), - &mut commands, - ); + // Seeding and compilation normally follow asset events, which land one + // frame after the asset exists. A conversation starting on that first + // frame must not race them, so first contact does both (idempotently). + if matches!(phase, Phase::Starting | Phase::Resuming { .. }) { + world.resource_mut::().seed(&db); + ensure_compiled(world, database.id(), &db); + } + match phase { + Phase::Starting => { + match find_conversation(&db, &conversation) + .and_then(|c| root_entry(c).map(|e| (c.id, e.id))) + { + Some(root) => advance_from(world, entity, &db, root), + None => { + warn!("conversation {conversation:?} not found"); + let nowhere = (ConversationId::default(), EntryId::default()); + apply_step(world, entity, Step::End, nowhere, true); + } + } + } + Phase::Resuming { at } => { + let step = match subtitle_at(&db, at) { + Some(subtitle) => Step::Line(subtitle), + None => { + warn!("resume point {at:?} not found"); + Step::End + } + }; + apply_step(world, entity, step, at, false); + } + Phase::Advancing { from } => advance_from(world, entity, &db, from), + Phase::Choosing { to } => { + let step = match subtitle_at(&db, to) { + Some(subtitle) => Step::Line(subtitle), + None => Step::End, + }; + apply_step(world, entity, step, to, true); + } + _ => {} + } +} + +/// Steps past `from` to whatever follows, gating links by their conditions. +fn advance_from( + world: &mut World, + entity: Entity, + db: &DialogueDatabase, + from: (ConversationId, EntryId), +) { + let step = step_from(db, from, &mut |key| check_condition(world, key)); + apply_step(world, entity, step, from, true); } -/// Applies a [`Step`] to a runner: updates its phase, records visits, and -/// emits the event. `from` is the entry the step was taken from, kept as the -/// menu's position. `visits: None` skips recording (resume re-presents a line -/// the player already saw). +/// Applies a [`Step`] to a runner: updates its phase, records visits, runs +/// the presented entry's script, and emits the event. `from` is the entry the +/// step was taken from, kept as the menu's position. `fresh: false` means the +/// line was already seen (resume): no visit is counted, no script runs. fn apply_step( + world: &mut World, + entity: Entity, step: Step, from: (ConversationId, EntryId), - entity: Entity, - runner: &mut DialogueRunner, - participants: Option<&Participants>, - visits: Option<&mut Visits>, - commands: &mut Commands, + fresh: bool, ) { match step { Step::Line(subtitle) => { - runner.phase = Phase::Presenting { - at: (subtitle.conversation, subtitle.entry), - }; - if let Some(visits) = visits { - visits.record_displayed((subtitle.conversation, subtitle.entry)); + let at = (subtitle.conversation, subtitle.entry); + set_phase(world, entity, Phase::Presenting { at }); + if fresh { + world.resource_mut::().record_displayed(at); + run_script(world, at); } - let bound = |actor: ActorId| participants.and_then(|p| p.0.get(&actor).copied()); - let speaker = bound(subtitle.actor); - let listener = bound(subtitle.conversant); - commands.trigger(SubtitleStarted { + let bound = |world: &World, actor: ActorId| { + world + .get::(entity) + .and_then(|p| p.0.get(&actor).copied()) + }; + let speaker = bound(world, subtitle.actor); + let listener = bound(world, subtitle.conversant); + world.trigger(SubtitleStarted { entity, subtitle, speaker, @@ -303,27 +317,36 @@ fn apply_step( }); } Step::Menu(responses) => { - if let Some(visits) = visits { + if fresh { + let mut visits = world.resource_mut::(); for response in &responses { visits.record_offered((response.conversation, response.entry)); } } - commands.trigger(ResponseMenuOpened { + set_phase( + world, entity, - responses: responses.clone(), - }); - runner.phase = Phase::AwaitingChoice { - at: from, - responses, - }; + Phase::AwaitingChoice { + at: from, + responses: responses.clone(), + }, + ); + world.trigger(ResponseMenuOpened { entity, responses }); } Step::End => { - runner.phase = Phase::Ended; - commands.trigger(ConversationEnded { entity }); + set_phase(world, entity, Phase::Ended); + world.trigger(ConversationEnded { entity }); } } } +/// Sets a runner's phase, if the runner still exists. +fn set_phase(world: &mut World, entity: Entity, phase: Phase) { + if let Some(mut runner) = world.get_mut::(entity) { + runner.phase = phase; + } +} + #[cfg(test)] mod tests { use super::*; @@ -391,6 +414,11 @@ mod tests { #[fixture] fn test_app() -> (App, Entity) { + app_with(db()) + } + + /// An app with event-logging observers and one runner on `db`. + fn app_with(db: DialogueDatabase) -> (App, Entity) { let mut app = App::new(); app.add_plugins((MinimalPlugins, AssetPlugin::default(), TalksPlugin)); app.init_resource::(); @@ -410,7 +438,7 @@ mod tests { let handle = app .world_mut() .resource_mut::>() - .add(db()); + .add(db); let runner = app .world_mut() .spawn(DialogueRunner::new( @@ -515,6 +543,41 @@ mod tests { assert!(matches!(phase, Phase::Presenting { .. })); } + #[rstest] + fn conditions_gate_menus_and_scripts_run_on_presentation() { + use crate::data::{FieldValue, Variable}; + + // "Hello" greets via script; the "Ask" response needs Rich, which + // starts false; a second response "Leave" is always available. + let mut db = db(); + db.variables.push(Variable { + name: "Rich".to_owned(), + initial: FieldValue::Boolean(false), + fields: vec![], + }); + let conversation = &mut db.conversations[0]; + conversation.entries[1].script = r#"vars["Greeted"] = true"#.to_owned(); + conversation.entries[1].links.push(Link { + dest_conversation: ConversationId(1), + dest_entry: EntryId(4), + }); + conversation.entries[2].condition = r#"vars["Rich"]"#.to_owned(); + conversation.entries[3].actor = ActorId(0); + conversation.entries[3].conversant = ActorId(1); + conversation.entries[3].menu_text = "Leave".to_owned(); + + let (mut app, runner) = app_with(db); + app.update(); // presents "Hello" and runs its script + assert!(app.world().resource::().truthy("Greeted")); + + app.world_mut() + .trigger(AdvanceConversation { entity: runner }); + app.update(); // opens the menu; "Ask" is gated out + + let emitted = &app.world().resource::().0; + assert_eq!(emitted, &["line: Hello", "menu: Leave"]); + } + #[rstest] fn out_of_bounds_choice_is_ignored(test_app: (App, Entity)) { let (mut app, runner) = test_app; diff --git a/src/runtime/step.rs b/src/runtime/step.rs index e94e6d5..48f8449 100644 --- a/src/runtime/step.rs +++ b/src/runtime/step.rs @@ -99,10 +99,20 @@ pub fn subtitle_at(db: &DialogueDatabase, at: (ConversationId, EntryId)) -> Opti }) } +/// Decides whether a link destination may be offered or followed. +/// +/// The runner gates on entry conditions; tests pass `&mut |_| true`. +pub type Gate<'a> = &'a mut dyn FnMut((ConversationId, EntryId)) -> bool; + /// All destinations reachable from an entry's links, in link order, with -/// group entries flattened (their links are followed transitively). -pub fn responses(db: &DialogueDatabase, from: (ConversationId, EntryId)) -> Vec { - collect_responses(db, from, 0, &mut HashSet::new()) +/// group entries flattened (their links are followed transitively) and +/// destinations that fail `gate` dropped, subtrees included. +pub fn responses( + db: &DialogueDatabase, + from: (ConversationId, EntryId), + gate: Gate<'_>, +) -> Vec { + collect_responses(db, from, 0, &mut HashSet::new(), gate) } /// The responses behind every link of the entry at `from`. @@ -111,6 +121,7 @@ fn collect_responses( from: (ConversationId, EntryId), depth: usize, visited: &mut HashSet<(ConversationId, EntryId)>, + gate: Gate<'_>, ) -> Vec { if depth > MAX_EVALUATE_DEPTH { return Vec::new(); @@ -119,23 +130,25 @@ fn collect_responses( .into_iter() .flat_map(|entry| &entry.links) .map(|link| (link.dest_conversation, link.dest_entry)) - .flat_map(|dest| destination_responses(db, dest, depth, visited)) + .flat_map(|dest| destination_responses(db, dest, depth, visited, gate)) .collect() } -/// The responses one link destination contributes: none if already visited or -/// missing, its own transitive responses if it is a group, itself otherwise. +/// The responses one link destination contributes: none if already visited, +/// missing, or gated out; its own transitive responses if it is a group; +/// itself otherwise. fn destination_responses( db: &DialogueDatabase, dest: (ConversationId, EntryId), depth: usize, visited: &mut HashSet<(ConversationId, EntryId)>, + gate: Gate<'_>, ) -> Vec { - if !visited.insert(dest) { + if !visited.insert(dest) || !gate(dest) { return Vec::new(); } match entry_at(db, dest) { - Some(entry) if entry.is_group => collect_responses(db, dest, depth + 1, visited), + Some(entry) if entry.is_group => collect_responses(db, dest, depth + 1, visited, gate), Some(entry) => vec![response(db, dest, entry)], None => Vec::new(), } @@ -172,8 +185,8 @@ fn actor_is_player(db: &DialogueDatabase, actor: ActorId) -> bool { /// the first NPC response wins and is auto-followed; /// otherwise player responses become a menu; /// otherwise the conversation ends. -pub fn step_from(db: &DialogueDatabase, at: (ConversationId, EntryId)) -> Step { - let responses = responses(db, at); +pub fn step_from(db: &DialogueDatabase, at: (ConversationId, EntryId), gate: Gate<'_>) -> Step { + let responses = responses(db, at, gate); if let Some(npc) = responses.iter().find(|r| !r.is_player) { match subtitle_at(db, (npc.conversation, npc.entry)) { Some(subtitle) => Step::Line(subtitle), @@ -261,7 +274,7 @@ mod tests { #[rstest] fn start_skips_root_and_presents_first_npc_line(db: DialogueDatabase) { - let step = step_from(&db, (ConversationId(1), EntryId(1))); + let step = step_from(&db, (ConversationId(1), EntryId(1)), &mut |_| true); let Step::Line(subtitle) = step else { panic!("expected a line, got {step:?}"); }; @@ -271,7 +284,7 @@ mod tests { #[rstest] fn player_responses_become_a_menu_with_menu_text_labels(db: DialogueDatabase) { - let step = step_from(&db, (ConversationId(1), EntryId(2))); + let step = step_from(&db, (ConversationId(1), EntryId(2)), &mut |_| true); let Step::Menu(responses) = step else { panic!("expected a menu, got {step:?}"); }; @@ -284,7 +297,7 @@ mod tests { #[rstest] fn groups_are_flattened(db: DialogueDatabase) { // entry 3 links to group 5, which links to npc 6. - let step = step_from(&db, (ConversationId(1), EntryId(3))); + let step = step_from(&db, (ConversationId(1), EntryId(3)), &mut |_| true); let Step::Line(subtitle) = step else { panic!("expected a line, got {step:?}"); }; @@ -293,22 +306,54 @@ mod tests { #[rstest] fn dead_end_ends_the_conversation(db: DialogueDatabase) { - assert_eq!(step_from(&db, (ConversationId(1), EntryId(4))), Step::End); + assert_eq!( + step_from(&db, (ConversationId(1), EntryId(4)), &mut |_| true), + Step::End + ); } #[rstest] fn cycles_terminate(db: DialogueDatabase) { // 6 links back to 2; evaluation must not hang. - let step = step_from(&db, (ConversationId(1), EntryId(6))); + let step = step_from(&db, (ConversationId(1), EntryId(6)), &mut |_| true); assert!(matches!(step, Step::Line(_))); } #[rstest] fn menu_label_falls_back_to_dialogue_text(mut db: DialogueDatabase) { db.conversations[0].entries[2].menu_text.clear(); - let Step::Menu(responses) = step_from(&db, (ConversationId(1), EntryId(2))) else { + let Step::Menu(responses) = step_from(&db, (ConversationId(1), EntryId(2)), &mut |_| true) + else { panic!("expected a menu"); }; assert_eq!(responses[0].text, "What is this?"); } + + #[rstest] + fn gated_destinations_are_dropped(db: DialogueDatabase) { + // Gate out response 4: only "Ask" remains. + let step = step_from(&db, (ConversationId(1), EntryId(2)), &mut |key| { + key.1 != EntryId(4) + }); + let Step::Menu(responses) = step else { + panic!("expected a menu, got {step:?}"); + }; + assert_eq!(responses.len(), 1); + assert_eq!(responses[0].text, "Ask"); + + // Gate out both responses: the conversation ends. + let step = step_from(&db, (ConversationId(1), EntryId(2)), &mut |key| { + key.1 != EntryId(3) && key.1 != EntryId(4) + }); + assert_eq!(step, Step::End); + } + + #[rstest] + fn gating_a_group_drops_its_subtree(db: DialogueDatabase) { + // Entry 3 leads to npc 6 only through group 5. + let step = step_from(&db, (ConversationId(1), EntryId(3)), &mut |key| { + key.1 != EntryId(5) + }); + assert_eq!(step, Step::End); + } } diff --git a/src/scripting/mod.rs b/src/scripting/mod.rs index 4713b66..ff41a79 100644 --- a/src/scripting/mod.rs +++ b/src/scripting/mod.rs @@ -1,6 +1,6 @@ //! Rhai scripting: the engine behind entry conditions and scripts. //! -//! Conditions and scripts authored on [`DialogueEntry`](crate::data::DialogueEntry) +//! Conditions and scripts authored on [`DialogueEntry`] //! are Rhai code. Both see the variable store as `vars` and can call any //! system the game registered with //! [`add_dialogue_system`](AddDialogueSystem::add_dialogue_system): @@ -27,7 +27,7 @@ pub mod functions; pub use functions::{AddDialogueSystem, DialogueSystems, ScriptArg, ScriptArgs, ScriptReturn}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use bevy::prelude::*; @@ -79,20 +79,44 @@ struct CompiledLogic { /// Compiled conditions and scripts of every loaded database, by entry. #[derive(Resource, Default)] -pub struct CompiledScripts(HashMap<(ConversationId, EntryId), CompiledLogic>); +pub struct CompiledScripts { + /// Compiled logic by entry. + logic: HashMap<(ConversationId, EntryId), CompiledLogic>, + /// The databases the logic came from. + sources: HashSet>, +} impl CompiledScripts { /// The compiled condition of `key`'s entry, if it has one. pub fn condition(&self, key: (ConversationId, EntryId)) -> Option> { - self.0.get(&key)?.condition.clone() + self.logic.get(&key)?.condition.clone() } /// The compiled script of `key`'s entry, if it has one. pub fn script(&self, key: (ConversationId, EntryId)) -> Option> { - self.0.get(&key)?.script.clone() + self.logic.get(&key)?.script.clone() } } +/// Compiles `db` now unless it already has been. +/// +/// The runner calls this when a conversation starts: asset events reach +/// [`compile_scripts`] one frame after the asset exists, and a condition on +/// the first line must not race that frame. +pub(crate) fn ensure_compiled( + world: &mut World, + id: AssetId, + db: &DialogueDatabase, +) { + let engine = world.resource::().0.clone(); + let mut compiled = world.resource_mut::(); + if !compiled.sources.insert(id) { + return; + } + let logic: Vec<_> = compile_database(&engine, db).collect(); + compiled.logic.extend(logic); +} + /// Compiles conditions and scripts from every database as it loads. /// /// Conditions compile in expression mode: statements like `vars["x"] = 1` @@ -114,17 +138,27 @@ pub fn compile_scripts( return; } - compiled.0 = databases + compiled.sources = databases.iter().map(|(id, _)| id).collect(); + compiled.logic = databases + .iter() + .flat_map(|(_, db)| compile_database(&engine.0, db)) + .collect(); +} + +/// Compiles every entry of one database that has logic. +fn compile_database<'a>( + engine: &'a Engine, + db: &'a DialogueDatabase, +) -> impl Iterator + 'a { + db.conversations .iter() - .flat_map(|(_, db)| &db.conversations) .flat_map(|conversation| { conversation .entries .iter() .map(|entry| ((conversation.id, entry.id), entry)) }) - .filter_map(|(key, entry)| Some((key, compile_entry(&engine.0, key, entry)?))) - .collect(); + .filter_map(move |(key, entry)| Some((key, compile_entry(engine, key, entry)?))) } /// Compiles one entry's logic; `None` when the entry has none. From d9938fb36f6601f50a62a589b150bfa81c558cac Mon Sep 17 00:00:00 2001 From: giusdp Date: Fri, 3 Jul 2026 15:26:07 +0200 Subject: [PATCH 05/12] feat: add scripting in editor and book --- assets/shop.dialogue.ron | 149 ++++++++++++++++++++++++++++++ docs/src/SUMMARY.md | 1 + docs/src/concepts/format.md | 2 +- docs/src/index.md | 1 + docs/src/runtime/persistence.md | 2 +- docs/src/runtime/scripting.md | 75 +++++++++++++++ docs/src/runtime/variables.md | 2 +- examples/shop.rs | 158 ++++++++++++++++++++++++++++++++ src/data/entry.rs | 4 +- tools/editor/src/panels.rs | 48 ++++++++-- 10 files changed, 427 insertions(+), 15 deletions(-) create mode 100644 assets/shop.dialogue.ron create mode 100644 docs/src/runtime/scripting.md create mode 100644 examples/shop.rs diff --git a/assets/shop.dialogue.ron b/assets/shop.dialogue.ron new file mode 100644 index 0000000..fe16201 --- /dev/null +++ b/assets/shop.dialogue.ron @@ -0,0 +1,149 @@ +( + version: "1", + actors: [ + ( + id: 0, + name: "Player", + is_player: true, + fields: [], + ), + ( + id: 1, + name: "Merchant", + is_player: false, + fields: [], + ), + ], + variables: [ + (name: "Greeted", initial: Boolean(false)), + (name: "BoughtSword", initial: Boolean(false)), + ], + conversations: [ + ( + id: 1, + title: "Shop", + actor: 0, + conversant: 1, + entries: [ + ( + id: 1, + actor: 1, + conversant: 0, + menu_text: "", + dialogue_text: "", + is_root: true, + is_group: false, + links: [ + (dest_conversation: 1, dest_entry: 2), + (dest_conversation: 1, dest_entry: 3), + ], + ), + ( + id: 2, + actor: 1, + conversant: 0, + menu_text: "", + dialogue_text: "Welcome, stranger! Looking for something?", + is_root: false, + is_group: false, + condition: "!vars[\"Greeted\"]", + script: "vars[\"Greeted\"] = true", + links: [ + (dest_conversation: 1, dest_entry: 4), + (dest_conversation: 1, dest_entry: 5), + (dest_conversation: 1, dest_entry: 6), + ], + ), + ( + id: 3, + actor: 1, + conversant: 0, + menu_text: "", + dialogue_text: "Back again?", + is_root: false, + is_group: false, + links: [ + (dest_conversation: 1, dest_entry: 4), + (dest_conversation: 1, dest_entry: 5), + (dest_conversation: 1, dest_entry: 6), + ], + ), + ( + id: 4, + actor: 0, + conversant: 1, + menu_text: "Buy the sword (10 gold)", + dialogue_text: "I'll take the sword.", + is_root: false, + is_group: false, + condition: "!vars[\"BoughtSword\"] && gold() >= 10", + links: [ + (dest_conversation: 1, dest_entry: 7), + ], + ), + ( + id: 5, + actor: 0, + conversant: 1, + menu_text: "Admire the blade", + dialogue_text: "That's a fine blade.", + is_root: false, + is_group: false, + links: [ + (dest_conversation: 1, dest_entry: 8), + ], + ), + ( + id: 6, + actor: 0, + conversant: 1, + menu_text: "Leave", + dialogue_text: "Just looking, thanks.", + is_root: false, + is_group: false, + links: [ + (dest_conversation: 1, dest_entry: 9), + ], + ), + ( + id: 7, + actor: 1, + conversant: 0, + menu_text: "", + dialogue_text: "A fine choice! It served me well.", + is_root: false, + is_group: false, + script: "spend(10); vars[\"BoughtSword\"] = true; give_item(\"sword\")", + links: [ + (dest_conversation: 1, dest_entry: 2), + (dest_conversation: 1, dest_entry: 3), + ], + ), + ( + id: 8, + actor: 1, + conversant: 0, + menu_text: "", + dialogue_text: "Careful, it's sharp.", + is_root: false, + is_group: false, + links: [ + (dest_conversation: 1, dest_entry: 2), + (dest_conversation: 1, dest_entry: 3), + ], + ), + ( + id: 9, + actor: 1, + conversant: 0, + menu_text: "", + dialogue_text: "Safe travels, friend.", + is_root: false, + is_group: false, + links: [], + ), + ], + fields: [], + ), + ], +) diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 470a8a5..e175426 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -10,6 +10,7 @@ - [Playing Conversations](./runtime/playing.md) - [Actors and Participants](./runtime/actors.md) - [Variables](./runtime/variables.md) + - [Conditions and Scripts](./runtime/scripting.md) - [Saving and Loading](./runtime/persistence.md) - [The Editor](./editor.md) - [Roadmap](./roadmap.md) diff --git a/docs/src/concepts/format.md b/docs/src/concepts/format.md index cc23122..1700e55 100644 --- a/docs/src/concepts/format.md +++ b/docs/src/concepts/format.md @@ -49,6 +49,6 @@ The file is a direct serialization of `DialogueDatabase`. Notes: -- `fields` and `variables` may be omitted. +- `fields` and `variables` may be omitted, as may an entry's `condition` and `script` (see [Conditions and Scripts](../runtime/scripting.md)). - Field values are tagged enum variants: `Text("…")`, `Number(1.5)`, `Boolean(true)`, `Localization("…")`, `Actor(2)`. - Loading is **lenient**: files that parse are accepted even if their content has problems. See [Validation](./validation.md). \ No newline at end of file diff --git a/docs/src/index.md b/docs/src/index.md index 6a61d85..60d0863 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -9,6 +9,7 @@ your game: - **Conversations as graphs**: each conversation is a directed graph of entries: spoken lines, player choices, and organizational group nodes, connected by links. - **A runtime that plays them**: spawn a `DialogueRunner`, observe the events it emits, and render them however your game wants. - **A variable store**: a `Variables` resource seeded from the database, the shared game state that dialogue and gameplay read and write. +- **Conditions and scripts**: entries carry [Rhai](https://rhai.rs) logic that gates branches and runs effects, with your own Bevy systems callable from dialogue. - **A visual editor**: a Bevy app for authoring databases: a node canvas for the conversation graph, an inspector for entries and actors, and save/load. ## The shape of a conversation diff --git a/docs/src/runtime/persistence.md b/docs/src/runtime/persistence.md index 9fe356e..93aeda7 100644 --- a/docs/src/runtime/persistence.md +++ b/docs/src/runtime/persistence.md @@ -17,7 +17,7 @@ pub struct VisitCount { ``` Presenting a line bumps its `displayed` count; opening a menu bumps `offered` for every choice in it. -You can read it from your own systems, and future conditions will use it for things like "only say this once": +You can read it from your own systems, including [dialogue systems](./scripting.md#calling-into-your-game) called from conditions, for things like "only say this once": ```rust,ignore fn already_greeted(visits: Res) -> bool { diff --git a/docs/src/runtime/scripting.md b/docs/src/runtime/scripting.md new file mode 100644 index 0000000..c648958 --- /dev/null +++ b/docs/src/runtime/scripting.md @@ -0,0 +1,75 @@ +# Conditions and Scripts + +Entries can carry logic. A **condition** decides whether the entry can be reached, a **script** runs when the entry is presented. Both are written in [Rhai](https://rhai.rs/book/), a small scripting language embedded in the library, and both are optional. + +```ron +( + id: 3, + menu_text: "Bribe the guard", + dialogue_text: "Perhaps this changes your mind.", + condition: "vars[\"Gold\"] >= 10", + script: "vars[\"Gold\"] -= 10; guard_bribed()", + // ... +) +``` + +In the editor both live in the Logic section of the entry inspector. + +## The `vars` binding + +Conditions and scripts see the [variable store](./variables.md) as `vars`: + +```rhai +vars["Gold"] >= 10 // read +vars["AcceptedJob"] = true // write, creates the variable if needed +vars.has("MetBoris") // existence check +``` + +Reading a variable that doesn't exist is an error, so typos surface as warnings instead of silently comparing against a default. Numbers are floats on the script side, but Rhai mixes integers and floats freely: `vars["Gold"] >= 10` works. + +## Conditions + +A condition is a single expression that returns a bool. When the runner follows links, every destination is checked: entries whose condition fails are dropped. A choice disappears from the menu, an NPC branch is skipped, and gating a group node cuts off everything behind it. An empty condition always passes. + +Conditions are expressions only. Statements like `vars["x"] = 1` don't compile there, which catches the classic `=` versus `==` mistake at load time. + +## Scripts + +A script runs when its entry is presented as a line, after the visit is recorded and before `SubtitleStarted` fires, so anything the script writes is visible to your observers. Scripts are full Rhai: statements, `if`, `let`, function calls. + +Scripts run once per presentation. Resuming a saved conversation re-presents the line without re-running its script, matching how resume skips visit counting. + +## Calling into your game + +The game can expose Bevy systems to dialogue logic: + +```rust,ignore +app.add_dialogue_system("has_item", |In(name): In, inventory: Res| { + inventory.contains(&name) +}); + +app.add_dialogue_system("give_item", |In(name): In, mut inventory: ResMut| { + inventory.add(&name); +}); +``` + +A condition can now say `has_item("sword")` and a script `give_item("sword")`. The function runs as a one-shot system in the middle of evaluation, with everything a system can do: queries, resources, commands. + +The system's `In` input is the argument list: a single value or a tuple, up to four arguments, of `bool`, `i64`, `f64`, `f32`, `String`, or `Dynamic`. Integer arguments coerce to float parameters. The return value, if any, becomes the call's result in the script; systems returning nothing are fine for fire-and-forget calls. + +Register systems before the app runs; the script engine picks up changes to the registered set automatically. + +## When logic breaks + +Broken logic never blocks dialogue. A condition that fails to compile, errors at runtime, or returns something that isn't a bool passes with a warning in the log. +A failing script is reported and skipped. Warnings name the conversation and entry, so a typo in a condition shows up as a log line, not as a quest that can't be started. + +Compilation of the script code happens once when the database loads, so syntax errors in any entry are reported up front, before the entry is ever reached. + +## Try it + +The shop example plays a merchant whose greeting, stock, and prices are driven by everything on this page: conditions calling a `gold()` system, scripts spending it, and a `give_item` system filling a game resource. + +```sh +cargo run --example shop +``` diff --git a/docs/src/runtime/variables.md b/docs/src/runtime/variables.md index b4669b3..64d5ea5 100644 --- a/docs/src/runtime/variables.md +++ b/docs/src/runtime/variables.md @@ -1,6 +1,6 @@ # Variables -Variables are the game state that dialogue reads and writes: has the player accepted the job, how much gold do they carry, what name did they pick. Later they will also drive conditions on links and effects on lines. +Variables are the game state that dialogue reads and writes: has the player accepted the job, how much gold do they carry, what name did they pick. They also drive [conditions and scripts](./scripting.md) on entries. There are two halves: definitions in the database, and a live store at runtime. diff --git a/examples/shop.rs b/examples/shop.rs new file mode 100644 index 0000000..e0bffbd --- /dev/null +++ b/examples/shop.rs @@ -0,0 +1,158 @@ +//! Plays `assets/shop.dialogue.ron` in the terminal: a merchant whose +//! dialogue is driven by conditions and scripts. +//! +//! Run with `cargo run --example shop`. Press Enter to advance NPC lines and +//! type a number to pick a menu response. +//! +//! What to watch for: +//! - The greeting changes after the first visit (`Greeted` variable). +//! - "Buy the sword" only appears while you can afford it and don't own it +//! (`gold()` is a game system called from the condition). +//! - Buying runs a script that spends gold and calls `give_item`, which +//! pushes into a game resource. + +use std::io::{BufRead, Write}; +use std::sync::{Mutex, mpsc}; +use std::time::Duration; + +use bevy::app::ScheduleRunnerPlugin; +use bevy::prelude::*; +use bevy_talks::prelude::*; + +/// Lines typed by the player, fed from the stdin thread. +#[derive(Resource)] +struct StdinInput(Mutex>); + +/// The player's gold. Game state, not a dialogue variable. +#[derive(Resource)] +struct Purse(f64); + +/// What the player carries. +#[derive(Resource, Default)] +struct Inventory(Vec); + +fn main() { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in std::io::stdin().lock().lines().map_while(Result::ok) { + if tx.send(line).is_err() { + break; + } + } + }); + + let mut app = App::new(); + app.add_plugins(( + MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_millis(50))), + AssetPlugin::default(), + TalksPlugin, + )) + .insert_resource(StdinInput(Mutex::new(rx))) + .insert_resource(Purse(12.0)) + .init_resource::() + .add_systems(Startup, start) + .add_systems(Update, handle_input) + .add_observer(print_subtitle) + .add_observer(print_menu) + .add_observer(finish); + + // The systems dialogue logic can call. + app.add_dialogue_system("gold", gold); + app.add_dialogue_system("spend", spend); + app.add_dialogue_system("give_item", give_item); + + app.run(); +} + +/// `gold()`: how much the player carries. +fn gold(_: In<()>, purse: Res) -> f64 { + purse.0 +} + +/// `spend(amount)`: takes gold from the purse. +fn spend(In(amount): In, mut purse: ResMut) { + purse.0 -= amount; +} + +/// `give_item(name)`: puts an item in the player's inventory. +fn give_item(In(name): In, mut inventory: ResMut) { + println!(" * {name} added to your inventory"); + inventory.0.push(name); +} + +/// Loads the database and spawns the conversation runner. +fn start(mut commands: Commands, assets: Res) { + println!("--- bevy_talks shop demo ---"); + let database: Handle = assets.load("shop.dialogue.ron"); + commands.spawn(DialogueRunner::new( + database, + ConversationRef::Title("Shop".to_owned()), + )); +} + +/// Prints an NPC line and prompts for Enter. +fn print_subtitle( + line: On, + runners: Query<&DialogueRunner>, + databases: Res>, +) { + let speaker = runners + .get(line.entity) + .ok() + .and_then(|r| databases.get(&r.database)) + .and_then(|db| db.actors.iter().find(|a| a.id == line.subtitle.actor)) + .map(|a| a.name.clone()) + .unwrap_or_else(|| format!("actor {}", line.subtitle.actor.0)); + println!("\n{speaker}: {}", line.subtitle.text); + print!(" [Enter to continue] "); + let _ = std::io::stdout().flush(); +} + +/// Prints the response menu, the purse, and prompts for a number. +fn print_menu(menu: On, purse: Res, inventory: Res) { + println!("\nYour reply ({} gold, carrying: {:?}):", purse.0, inventory.0); + for (i, response) in menu.responses.iter().enumerate() { + println!(" {}) {}", i + 1, response.text); + } + print!("> "); + let _ = std::io::stdout().flush(); +} + +/// Says goodbye and quits. +fn finish(_: On, mut exit: MessageWriter) { + println!("\n--- conversation ended ---"); + exit.write(AppExit::Success); +} + +/// Maps typed lines onto the runner: Enter advances, a number chooses. +fn handle_input( + input: Res, + runners: Query<(Entity, &DialogueRunner)>, + mut commands: Commands, +) { + let Ok(receiver) = input.0.lock() else { + return; + }; + while let Ok(line) = receiver.try_recv() { + for (entity, runner) in &runners { + match &runner.phase { + Phase::Presenting { .. } => { + commands.trigger(AdvanceConversation { entity }); + } + Phase::AwaitingChoice { responses, .. } => match line.trim().parse::() { + Ok(n) if (1..=responses.len()).contains(&n) => { + commands.trigger(ChooseResponse { + entity, + index: n - 1, + }); + } + _ => { + print!("pick 1..{} > ", responses.len()); + let _ = std::io::stdout().flush(); + } + }, + _ => {} + } + } + } +} diff --git a/src/data/entry.rs b/src/data/entry.rs index efc55b9..4643e9d 100644 --- a/src/data/entry.rs +++ b/src/data/entry.rs @@ -29,10 +29,10 @@ pub struct DialogueEntry { /// Custom fields. #[serde(default)] pub fields: Vec, - /// Rune expression gating whether this entry can be reached. Empty means always. + /// Rhai expression gating whether this entry can be reached. Empty means always. #[serde(default, skip_serializing_if = "String::is_empty")] pub condition: String, - /// Rune code run when this entry is presented. Empty means nothing to run. + /// Rhai code run when this entry is presented. Empty means nothing to run. #[serde(default, skip_serializing_if = "String::is_empty")] pub script: String, } diff --git a/tools/editor/src/panels.rs b/tools/editor/src/panels.rs index b8ac7e4..86050e3 100644 --- a/tools/editor/src/panels.rs +++ b/tools/editor/src/panels.rs @@ -56,8 +56,22 @@ pub struct EntryTextTarget { pub conversation: ConversationId, /// The target entry. pub entry: EntryId, - /// Edits `dialogue_text` when true, `menu_text` otherwise. - pub dialogue: bool, + /// Which of the entry's texts is edited. + pub text: EntryText, +} + +/// The editable texts of an entry. +#[derive(Clone, Copy, Default, PartialEq)] +pub enum EntryText { + /// The menu label. + #[default] + Menu, + /// The spoken line. + Dialogue, + /// The Rhai condition gating the entry. + Condition, + /// The Rhai script run when the entry is presented. + Script, } /// Which conversation a title text input renames. @@ -752,16 +766,29 @@ fn inspector_content(state: &EditorState, selection: &EditorSelection) -> Vec impl Scene { bsn! { Node { @@ -962,7 +989,7 @@ fn entry_text_input( EntryTextTarget { conversation: conversation, entry: entry, - dialogue: dialogue, + text: text, } ) ] @@ -1240,10 +1267,11 @@ pub fn commit_entry_text_edits( let Some(entry) = entry_mut(db, target.conversation, target.entry) else { continue; }; - let current = if target.dialogue { - &mut entry.dialogue_text - } else { - &mut entry.menu_text + let current = match target.text { + EntryText::Menu => &mut entry.menu_text, + EntryText::Dialogue => &mut entry.dialogue_text, + EntryText::Condition => &mut entry.condition, + EntryText::Script => &mut entry.script, }; if *current != value { *current = value; From 9f361c281450e739fcc92383669a3ec41516f4cb Mon Sep 17 00:00:00 2001 From: giusdp Date: Fri, 3 Jul 2026 17:03:38 +0200 Subject: [PATCH 06/12] feat: sequence field on dialogue entries --- src/data/entry.rs | 3 +++ src/loader/validate.rs | 1 + src/runtime/runner.rs | 1 + src/runtime/step.rs | 1 + 4 files changed, 6 insertions(+) diff --git a/src/data/entry.rs b/src/data/entry.rs index 4643e9d..242a675 100644 --- a/src/data/entry.rs +++ b/src/data/entry.rs @@ -35,4 +35,7 @@ pub struct DialogueEntry { /// Rhai code run when this entry is presented. Empty means nothing to run. #[serde(default, skip_serializing_if = "String::is_empty")] pub script: String, + /// Rhai code scheduling cues when this entry is presented. Empty means the default sequence. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sequence: String, } diff --git a/src/loader/validate.rs b/src/loader/validate.rs index 1b3de9a..17b365c 100644 --- a/src/loader/validate.rs +++ b/src/loader/validate.rs @@ -136,6 +136,7 @@ mod tests { fields: vec![], condition: String::new(), script: String::new(), + sequence: String::new(), }], fields: vec![], }], diff --git a/src/runtime/runner.rs b/src/runtime/runner.rs index 4390863..f6cc78f 100644 --- a/src/runtime/runner.rs +++ b/src/runtime/runner.rs @@ -374,6 +374,7 @@ mod tests { fields: vec![], condition: String::new(), script: String::new(), + sequence: String::new(), }; DialogueDatabase { version: "1".to_owned(), diff --git a/src/runtime/step.rs b/src/runtime/step.rs index 48f8449..c0eb9d3 100644 --- a/src/runtime/step.rs +++ b/src/runtime/step.rs @@ -228,6 +228,7 @@ mod tests { fields: vec![], condition: String::new(), script: String::new(), + sequence: String::new(), } }; DialogueDatabase { From 4860e9591adfdd5f779ab043e75ed81288612bb1 Mon Sep 17 00:00:00 2001 From: giusdp Date: Fri, 3 Jul 2026 17:34:42 +0200 Subject: [PATCH 07/12] feat: cue scheduling layer for entry sequences --- examples/shop.rs | 5 +- src/lib.rs | 1 + src/scripting/cues.rs | 191 ++++++++++++++++++++++++++++++++++++++++++ src/scripting/mod.rs | 37 +++++++- 4 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 src/scripting/cues.rs diff --git a/examples/shop.rs b/examples/shop.rs index e0bffbd..eca685e 100644 --- a/examples/shop.rs +++ b/examples/shop.rs @@ -110,7 +110,10 @@ fn print_subtitle( /// Prints the response menu, the purse, and prompts for a number. fn print_menu(menu: On, purse: Res, inventory: Res) { - println!("\nYour reply ({} gold, carrying: {:?}):", purse.0, inventory.0); + println!( + "\nYour reply ({} gold, carrying: {:?}):", + purse.0, inventory.0 + ); for (i, response) in menu.responses.iter().enumerate() { println!(" {}) {}", i + 1, response.text); } diff --git a/src/lib.rs b/src/lib.rs index 803919b..ba8e007 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ impl Plugin for TalksPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems( Update, ( diff --git a/src/scripting/cues.rs b/src/scripting/cues.rs new file mode 100644 index 0000000..04f5985 --- /dev/null +++ b/src/scripting/cues.rs @@ -0,0 +1,191 @@ +//! Cues: the staging layer behind entry sequences. +//! +//! A sequence is Rhai code where command calls schedule **cues** instead of +//! executing on the spot. Evaluating the sequence produces the cue list; a +//! driver plays it out over time: +//! +//! ```rhai +//! emit("scene_start"); +//! wait(2.0).emits("looked"); +//! wait(1.0).after("looked").required(); +//! wait(line_end) +//! ``` +//! +//! Every cue supports the timing methods `at(seconds)` (start after a delay), +//! `after(message)` (start when a message fires), `emits(message)` (fire a +//! message when done), and `required()` (still runs when the line is +//! skipped). `wait` and `emit` are built in; everything else comes from +//! commands the game registers. `line_end` is the estimated reading time of +//! the line being presented. + +use bevy::prelude::*; +use rhai::{AST, Dynamic, Engine, EvalAltResult, Scope}; + +use super::functions::with_world; + +/// One scheduled cue: a command call plus its timing. +#[derive(Debug, Clone, Default)] +pub struct CueRecord { + /// The command to run. + pub name: String, + /// The arguments it was scheduled with. + pub args: Vec, + /// Seconds into the sequence before the cue starts. + pub at: f32, + /// Message that must fire before the cue starts. + pub after: Option, + /// Message fired when the cue finishes. + pub emits: Option, + /// Whether the cue still runs when the sequence is skipped. + pub required: bool, +} + +/// The cue list being built while a sequence script evaluates. +#[derive(Resource, Default)] +pub struct PendingCues(pub(crate) Vec); + +/// The script-side handle to a scheduled cue; the timing methods live here. +#[derive(Clone, Copy)] +pub(crate) struct CueRef(usize); + +/// Registers the cue type, its timing methods, and the built-in commands. +pub(crate) fn install(engine: &mut Engine) { + engine + .register_type_with_name::("Cue") + .register_fn("at", |cue: &mut CueRef, secs: f64| { + update(*cue, |record| record.at = secs as f32) + }) + .register_fn("at", |cue: &mut CueRef, secs: i64| { + update(*cue, |record| record.at = secs as f32) + }) + .register_fn("after", |cue: &mut CueRef, message: &str| { + update(*cue, |record| record.after = Some(message.to_owned())) + }) + .register_fn("emits", |cue: &mut CueRef, message: &str| { + update(*cue, |record| record.emits = Some(message.to_owned())) + }) + .register_fn("required", |cue: &mut CueRef| { + update(*cue, |record| record.required = true) + }) + .register_fn("wait", |secs: f64| { + schedule("wait".to_owned(), vec![Dynamic::from_float(secs)]) + }) + .register_fn("wait", |secs: i64| { + schedule("wait".to_owned(), vec![Dynamic::from_float(secs as f64)]) + }) + .register_fn("emit", |message: &str| { + schedule("emit".to_owned(), vec![message.into()]) + }); +} + +/// Schedules a cue, returning the handle the timing methods chain on. +pub(crate) fn schedule(name: String, args: Vec) -> Result> { + with_world(|world| { + let mut pending = world.resource_mut::(); + pending.0.push(CueRecord { + name, + args, + ..Default::default() + }); + CueRef(pending.0.len() - 1) + }) +} + +/// Applies `change` to the cue's record. +fn update(cue: CueRef, change: impl FnOnce(&mut CueRecord)) -> Result> { + with_world(|world| { + world + .resource_mut::() + .0 + .get_mut(cue.0) + .map(change) + })? + .ok_or("cue no longer pending")?; + Ok(cue) +} + +/// Evaluates a compiled sequence into its cue list. +/// +/// `line_end` is the reading time of the line being presented, available to +/// the script under that name. +pub fn eval_cues( + world: &mut World, + ast: &AST, + line_end: f32, +) -> Result, Box> { + world.resource_mut::().0.clear(); + let mut scope = Scope::new(); + scope.push_constant("line_end", f64::from(line_end)); + let _ = super::eval_ast_in(world, ast, scope)?; + Ok(std::mem::take(&mut world.resource_mut::().0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::Variables; + use crate::scripting::{CompiledScripts, ScriptEngine}; + use rstest::{fixture, rstest}; + + /// A world with the resources sequence evaluation needs. + #[fixture] + fn world() -> World { + let mut vars = Variables::default(); + vars.set("Gold", 12.0); + + let mut world = World::new(); + world.insert_resource(vars); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world + } + + /// Compiles and evaluates `code` as a sequence against `world`. + fn cues(code: &str, world: &mut World) -> Result, Box> { + let engine = world.resource::().0.clone(); + let ast = engine.compile(code).map_err(|error| error.to_string())?; + eval_cues(world, &ast, 3.0) + } + + #[rstest] + fn sequences_schedule_cues_in_order(mut world: World) { + let cues = cues(r#"emit("go"); wait(2)"#, &mut world).unwrap(); + assert_eq!(cues.len(), 2); + assert_eq!(cues[0].name, "emit"); + assert_eq!(cues[0].args[0].clone().into_string().unwrap(), "go"); + assert_eq!(cues[1].name, "wait"); + assert_eq!(cues[1].args[0].as_float().unwrap(), 2.0); + } + + #[rstest] + fn timing_methods_chain_and_mutate_the_record(mut world: World) { + let cues = cues( + r#"wait(1.0).at(2.0).emits("done").required(); emit("x").after("done")"#, + &mut world, + ) + .unwrap(); + assert_eq!(cues[0].at, 2.0); + assert_eq!(cues[0].emits.as_deref(), Some("done")); + assert!(cues[0].required); + assert_eq!(cues[1].after.as_deref(), Some("done")); + assert!(!cues[1].required); + } + + #[rstest] + fn sequences_use_vars_and_line_end(mut world: World) { + let cues = cues( + r#"if vars["Gold"] >= 10 { emit("rich") }; wait(line_end)"#, + &mut world, + ) + .unwrap(); + assert_eq!(cues[0].name, "emit"); + assert_eq!(cues[1].args[0].as_float().unwrap(), 3.0); + } + + #[rstest] + fn unknown_commands_error(mut world: World) { + let error = cues(r#"camera("Wide")"#, &mut world).unwrap_err(); + assert!(error.to_string().contains("camera")); + } +} diff --git a/src/scripting/mod.rs b/src/scripting/mod.rs index ff41a79..faa971c 100644 --- a/src/scripting/mod.rs +++ b/src/scripting/mod.rs @@ -23,6 +23,7 @@ //! errors at runtime passes (with a warning), a failing script is reported //! and skipped. +pub mod cues; pub mod functions; pub use functions::{AddDialogueSystem, DialogueSystems, ScriptArg, ScriptArgs, ScriptReturn}; @@ -65,6 +66,7 @@ fn build_engine(systems: &DialogueSystems) -> Engine { .register_indexer_get(get_variable) .register_indexer_set(set_variable) .register_fn("has", has_variable); + cues::install(&mut engine); systems.install_into(&mut engine); engine } @@ -75,6 +77,8 @@ struct CompiledLogic { condition: Option>, /// The entry's script, if it has one. script: Option>, + /// The entry's sequence, if it has one. + sequence: Option>, } /// Compiled conditions and scripts of every loaded database, by entry. @@ -96,6 +100,11 @@ impl CompiledScripts { pub fn script(&self, key: (ConversationId, EntryId)) -> Option> { self.logic.get(&key)?.script.clone() } + + /// The compiled sequence of `key`'s entry, if it has one. + pub fn sequence(&self, key: (ConversationId, EntryId)) -> Option> { + self.logic.get(&key)?.sequence.clone() + } } /// Compiles `db` now unless it already has been. @@ -171,7 +180,14 @@ fn compile_entry( engine.compile_expression(text) }); let script = compile_snippet(&entry.script, key, "script", |text| engine.compile(text)); - (condition.is_some() || script.is_some()).then_some(CompiledLogic { condition, script }) + let sequence = compile_snippet(&entry.sequence, key, "sequence", |text| { + engine.compile(text) + }); + (condition.is_some() || script.is_some() || sequence.is_some()).then_some(CompiledLogic { + condition, + script, + sequence, + }) } /// Compiles one authored snippet, reporting failures. Empty text is no logic. @@ -228,8 +244,16 @@ pub fn run_script(world: &mut World, key: (ConversationId, EntryId)) { /// Evaluates a compiled AST with `vars` bound and the world reachable. fn eval_ast(world: &mut World, ast: &AST) -> Result> { + eval_ast_in(world, ast, Scope::new()) +} + +/// Like [`eval_ast`], with extra bindings already in scope. +pub(crate) fn eval_ast_in( + world: &mut World, + ast: &AST, + mut scope: Scope<'static>, +) -> Result> { let engine = world.resource::().0.clone(); - let mut scope = Scope::new(); scope.push("vars", VarStore); WORLD.set(world, || { engine.eval_ast_with_scope::(&mut scope, ast) @@ -398,7 +422,9 @@ mod tests { } #[rstest] - fn loading_a_database_compiles_its_logic(db: DialogueDatabase) { + fn loading_a_database_compiles_its_logic(mut db: DialogueDatabase) { + db.conversations[0].entries[4].sequence = "wait(1)".to_owned(); + db.conversations[0].entries[5].sequence = "wait(".to_owned(); let mut app = App::new(); app.add_plugins((MinimalPlugins, AssetPlugin::default(), crate::TalksPlugin)); let _handle = app @@ -423,6 +449,11 @@ mod tests { "statements don't compile as conditions" ); assert!(compiled.script(key(5)).is_none()); + assert!(compiled.sequence(key(5)).is_some()); + assert!( + compiled.sequence(key(6)).is_none(), + "a broken sequence is reported and skipped" + ); } #[rstest] From 702db09ee96212f3663a6c327aaf78aaabf89a84 Mon Sep 17 00:00:00 2001 From: giusdp Date: Fri, 3 Jul 2026 17:45:59 +0200 Subject: [PATCH 08/12] feat: add_sequencer_command registry with CueLife bridges --- src/lib.rs | 7 +- src/prelude.rs | 3 +- src/scripting/cues.rs | 168 +++++++++++++++++++++++++++++++++++++++++- src/scripting/mod.rs | 27 +++++-- 4 files changed, 191 insertions(+), 14 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ba8e007..3fe9173 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,14 +25,17 @@ impl Plugin for TalksPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_systems( Update, ( - scripting::rebuild_engine - .run_if(resource_changed::), + scripting::rebuild_engine.run_if( + resource_changed:: + .or_eager(resource_changed::), + ), runtime::variables::seed_variables .run_if(on_message::>), scripting::compile_scripts.run_if(on_message::>), diff --git a/src/prelude.rs b/src/prelude.rs index 13de3cc..72d439b 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -14,5 +14,6 @@ pub use super::runtime::{ }; pub use super::saver::{DialogueDatabaseSaver, SaveError, to_ron_string}; pub use super::scripting::{ - AddDialogueSystem, DialogueSystems, ScriptArg, ScriptArgs, ScriptReturn, + AddDialogueSystem, AddSequencerCommand, CueLife, DialogueSystems, ScriptArg, ScriptArgs, + ScriptReturn, SequencerCommands, }; diff --git a/src/scripting/cues.rs b/src/scripting/cues.rs index 04f5985..fc39fa0 100644 --- a/src/scripting/cues.rs +++ b/src/scripting/cues.rs @@ -15,13 +15,17 @@ //! `after(message)` (start when a message fires), `emits(message)` (fire a //! message when done), and `required()` (still runs when the line is //! skipped). `wait` and `emit` are built in; everything else comes from -//! commands the game registers. `line_end` is the estimated reading time of -//! the line being presented. +//! commands the game registers with +//! [`add_sequencer_command`](AddSequencerCommand::add_sequencer_command). +//! `line_end` is the estimated reading time of the line being presented. + +use std::sync::Arc; +use std::time::Duration; use bevy::prelude::*; use rhai::{AST, Dynamic, Engine, EvalAltResult, Scope}; -use super::functions::with_world; +use super::functions::{ScriptArgs, with_world}; /// One scheduled cue: a command call plus its timing. #[derive(Debug, Clone, Default)] @@ -104,6 +108,127 @@ fn update(cue: CueRef, change: impl FnOnce(&mut CueRecord)) -> Result) -> Result + Send + Sync>; + +/// One registered sequencer command. +struct SequencerCommand { + /// The name sequences call. + name: String, + /// How many arguments it takes. + arity: usize, + /// The typed path into the game's system. + bridge: CueBridge, +} + +/// Every command the game registered for use in sequences. +#[derive(Resource, Default)] +pub struct SequencerCommands(Vec); + +impl SequencerCommands { + /// The bridge into `name`'s handler, if registered. + pub fn bridge(&self, name: &str) -> Option { + self.0 + .iter() + .find(|command| command.name == name) + .map(|command| command.bridge.clone()) + } + + /// Registers all commands' scheduling shims with `engine`. + pub(crate) fn install_into(&self, engine: &mut Engine) { + for command in &self.0 { + install_shim(engine, command); + } + } +} + +/// Registers one command's scheduling shim: calling it in a sequence schedules a cue instead of running anything. +fn install_shim(engine: &mut Engine, command: &SequencerCommand) { + let name = command.name.as_str(); + let called = command.name.clone(); + match command.arity { + 0 => { + engine.register_fn(name, move || schedule(called.clone(), Vec::new())); + } + 1 => { + engine.register_fn(name, move |a: Dynamic| schedule(called.clone(), vec![a])); + } + 2 => { + engine.register_fn(name, move |a: Dynamic, b: Dynamic| { + schedule(called.clone(), vec![a, b]) + }); + } + 3 => { + engine.register_fn(name, move |a: Dynamic, b: Dynamic, c: Dynamic| { + schedule(called.clone(), vec![a, b, c]) + }); + } + 4 => { + engine.register_fn( + name, + move |a: Dynamic, b: Dynamic, c: Dynamic, d: Dynamic| { + schedule(called.clone(), vec![a, b, c, d]) + }, + ); + } + n => warn!("sequencer command `{name}` takes {n} arguments; the limit is 4"), + } +} + +/// App extension for making game systems callable as sequencer commands. +pub trait AddSequencerCommand { + /// Makes `system` schedulable from sequences as `name`. + /// + /// The system's `In` input is the cue entity paired with the argument + /// list (a value or a tuple, up to four [`ScriptArg`]s); it returns the + /// cue's [`CueLife`]: done immediately, done after a duration, or open + /// until the game finishes the cue entity. + /// + /// [`ScriptArg`]: super::ScriptArg + fn add_sequencer_command(&mut self, name: impl Into, system: S) -> &mut Self + where + S: IntoSystem, CueLife, M> + 'static, + I: ScriptArgs + Send + Sync + 'static; +} + +impl AddSequencerCommand for App { + fn add_sequencer_command(&mut self, name: impl Into, system: S) -> &mut Self + where + S: IntoSystem, CueLife, M> + 'static, + I: ScriptArgs + Send + Sync + 'static, + { + let id = self.world_mut().register_system(system); + let bridge: CueBridge = Arc::new(move |world, cue, args| { + let input = (cue, I::from_args(args)?); + world + .run_system_with(id, input) + .map_err(|error| error.to_string()) + }); + self.init_resource::(); + self.world_mut() + .resource_mut::() + .0 + .push(SequencerCommand { + name: name.into(), + arity: I::ARITY, + bridge, + }); + self + } +} + /// Evaluates a compiled sequence into its cue list. /// /// `line_end` is the reading time of the line being presented, available to @@ -188,4 +313,41 @@ mod tests { let error = cues(r#"camera("Wide")"#, &mut world).unwrap_err(); assert!(error.to_string().contains("camera")); } + + /// What the `animation` command played in the registry test. + #[derive(Resource, Default)] + struct Played(Vec<(Entity, String)>); + + #[rstest] + fn registered_commands_schedule_cues_and_their_bridges_run() { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default(), crate::TalksPlugin)); + app.init_resource::(); + app.add_sequencer_command( + "animation", + |In((cue, clip)): In<(Entity, String)>, mut played: ResMut| { + played.0.push((cue, clip)); + CueLife::For(Duration::from_secs(1)) + }, + ); + // First update rebuilds the engine with the registered command. + app.update(); + + let world = app.world_mut(); + let scheduled = cues(r#"animation("Fire").at(2.0)"#, world).unwrap(); + assert_eq!(scheduled[0].name, "animation"); + assert_eq!(scheduled[0].at, 2.0); + + let cue = world.spawn_empty().id(); + let bridge = world + .resource::() + .bridge("animation") + .unwrap(); + let life = bridge(world, cue, scheduled[0].args.clone()).unwrap(); + assert_eq!(life, CueLife::For(Duration::from_secs(1))); + assert_eq!(world.resource::().0, vec![(cue, "Fire".to_owned())]); + + let error = bridge(world, cue, vec![Dynamic::from_bool(true)]).unwrap_err(); + assert!(error.contains("expected a string")); + } } diff --git a/src/scripting/mod.rs b/src/scripting/mod.rs index faa971c..6b703f9 100644 --- a/src/scripting/mod.rs +++ b/src/scripting/mod.rs @@ -26,6 +26,7 @@ pub mod cues; pub mod functions; +pub use cues::{AddSequencerCommand, CueLife, SequencerCommands}; pub use functions::{AddDialogueSystem, DialogueSystems, ScriptArg, ScriptArgs, ScriptReturn}; use std::collections::{HashMap, HashSet}; @@ -46,20 +47,29 @@ pub struct ScriptEngine(pub Arc); impl Default for ScriptEngine { fn default() -> Self { - Self(Arc::new(build_engine(&DialogueSystems::default()))) + Self(Arc::new(build_engine( + &DialogueSystems::default(), + &SequencerCommands::default(), + ))) } } -/// Rebuilds the engine from the registered dialogue systems. +/// Rebuilds the engine from the registered dialogue systems and sequencer +/// commands. /// -/// Runs when [`DialogueSystems`] changes; compiled ASTs stay valid because -/// Rhai resolves function calls at evaluation time. -pub fn rebuild_engine(systems: Res, mut engine: ResMut) { - engine.0 = Arc::new(build_engine(&systems)); +/// Runs when either registry changes; compiled ASTs stay valid because Rhai +/// resolves function calls at evaluation time. +pub fn rebuild_engine( + systems: Res, + commands: Res, + mut engine: ResMut, +) { + engine.0 = Arc::new(build_engine(&systems, &commands)); } -/// Builds an engine with the store bindings and the game's dialogue systems. -fn build_engine(systems: &DialogueSystems) -> Engine { +/// Builds an engine with the store bindings, the game's dialogue systems, +/// and its sequencer commands. +fn build_engine(systems: &DialogueSystems, commands: &SequencerCommands) -> Engine { let mut engine = Engine::new(); engine .register_type_with_name::("Variables") @@ -68,6 +78,7 @@ fn build_engine(systems: &DialogueSystems) -> Engine { .register_fn("has", has_variable); cues::install(&mut engine); systems.install_into(&mut engine); + commands.install_into(&mut engine); engine } From a8867d9e64a30931cc37d8bb213cdbf06aafa793 Mon Sep 17 00:00:00 2001 From: giusdp Date: Fri, 3 Jul 2026 18:05:59 +0200 Subject: [PATCH 09/12] feat: sequence driver playing cues over time with LineFinished --- src/lib.rs | 6 +- src/prelude.rs | 6 +- src/runtime/mod.rs | 2 + src/runtime/sequencer.rs | 451 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 src/runtime/sequencer.rs diff --git a/src/lib.rs b/src/lib.rs index 3fe9173..0657863 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,7 @@ impl Plugin for TalksPlugin { .init_asset_loader::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -41,10 +42,13 @@ impl Plugin for TalksPlugin { scripting::compile_scripts.run_if(on_message::>), runtime::runner::drive_runners .run_if(any_with_component::), + runtime::sequencer::drive_sequences + .run_if(any_with_component::), ) .chain(), ) .add_observer(runtime::runner::on_advance) - .add_observer(runtime::runner::on_choose); + .add_observer(runtime::runner::on_choose) + .add_observer(runtime::sequencer::on_finish_cue); } } diff --git a/src/prelude.rs b/src/prelude.rs index 72d439b..5fd7dff 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -8,9 +8,9 @@ pub use super::loader::from_ron_str; pub use super::loader::validate::{Issue, validate}; pub use super::persist::{DialogueSave, save_from_ron, save_to_ron}; pub use super::runtime::{ - AdvanceConversation, ChooseResponse, ConversationEnded, ConversationRef, DialogueRunner, - Participants, Phase, Response, ResponseMenuOpened, Step, Subtitle, SubtitleStarted, Variables, - VisitCount, Visits, + AdvanceConversation, ChooseResponse, ConversationEnded, ConversationRef, Cue, DialogueRunner, + FinishCue, LineFinished, Participants, Phase, PlayingSequence, Response, ResponseMenuOpened, + SequencerSettings, Step, Subtitle, SubtitleStarted, Variables, VisitCount, Visits, }; pub use super::saver::{DialogueDatabaseSaver, SaveError, to_ron_string}; pub use super::scripting::{ diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 44add5c..8635f8a 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -12,6 +12,7 @@ //! flattened, and the START entry's own text is skipped. pub mod runner; +pub mod sequencer; pub mod step; pub mod variables; pub mod visits; @@ -20,6 +21,7 @@ pub use runner::{ AdvanceConversation, ChooseResponse, ConversationEnded, DialogueRunner, Participants, Phase, ResponseMenuOpened, SubtitleStarted, }; +pub use sequencer::{Cue, FinishCue, LineFinished, PlayingSequence, SequencerSettings}; pub use step::{ConversationRef, Response, Step, Subtitle}; pub use variables::Variables; pub use visits::{VisitCount, Visits}; diff --git a/src/runtime/sequencer.rs b/src/runtime/sequencer.rs new file mode 100644 index 0000000..11b7891 --- /dev/null +++ b/src/runtime/sequencer.rs @@ -0,0 +1,451 @@ +//! The sequence driver: plays an entry's cue list out over time. +//! +//! Presenting a line starts a [`PlayingSequence`] child of the runner. Each +//! frame the driver starts the cues whose time has come or whose awaited +//! message has fired, and finishes the ones whose life ended. Started cues +//! are child entities of the sequence; a [`CueLife::Until`] cue ends when the +//! game triggers [`FinishCue`] on it. When no cues remain, [`LineFinished`] +//! fires on the runner. +//! +//! Entries without a sequence play the default one from +//! [`SequencerSettings`], `wait(line_end)` unless configured: every line is a +//! sequence, and a game that wants sequences to pace the dialogue observes +//! [`LineFinished`] and triggers +//! [`AdvanceConversation`](crate::runtime::AdvanceConversation). + +use std::collections::HashSet; +use std::time::Duration; + +use bevy::prelude::*; + +use crate::data::{ConversationId, EntryId}; +use crate::scripting::cues::{CueLife, CueRecord, eval_cues}; +use crate::scripting::{CompiledScripts, ScriptEngine, SequencerCommands}; + +/// Tuning for sequences and the `line_end` reading-time estimate. +#[derive(Resource)] +pub struct SequencerSettings { + /// Reading speed `line_end` is estimated with. + pub chars_per_second: f32, + /// The floor for `line_end`. + pub min_seconds: f32, + /// Sequence played by entries that don't author one. + pub default_sequence: String, +} + +impl Default for SequencerSettings { + fn default() -> Self { + Self { + chars_per_second: 30.0, + min_seconds: 1.0, + default_sequence: "wait(line_end)".to_owned(), + } + } +} + +/// A sequence being played for a presented line. Child of the runner. +#[derive(Component)] +pub struct PlayingSequence { + /// The runner presenting the line. + runner: Entity, + /// Cues not started yet. + pending: Vec, + /// Started cues still alive. + active: Vec, + /// Seconds since the sequence started. + elapsed: f32, + /// Messages fired so far. + fired: HashSet, +} + +/// A started cue. Child of its [`PlayingSequence`]. +#[derive(Component)] +pub struct Cue { + /// The command that started this cue. + pub name: String, + /// Message fired when this cue finishes. + emits: Option, +} + +/// Ends a [`CueLife::For`] cue when it runs out. +#[derive(Component)] +struct CueTimer(Timer); + +/// Marks a cue to be finished on the next drive. +#[derive(Component)] +struct CueDone; + +/// Trigger this on a [`CueLife::Until`] cue entity to finish it. +#[derive(EntityEvent, Debug, Clone, Copy)] +pub struct FinishCue { + /// The cue entity to finish. + pub entity: Entity, +} + +/// The presented line's sequence has played out. +#[derive(EntityEvent, Debug, Clone, Copy)] +pub struct LineFinished { + /// The runner whose line finished. + pub entity: Entity, +} + +/// Records the [`FinishCue`] intent for the driver. +pub(crate) fn on_finish_cue(finish: On, mut commands: Commands) { + commands.entity(finish.entity).insert(CueDone); +} + +/// Builds the cue list for presenting an entry's line: its authored +/// sequence, or the default one when the entry has none or its evaluation +/// fails. +pub fn build_line_cues( + world: &mut World, + key: (ConversationId, EntryId), + text: &str, +) -> Vec { + let line_end = line_end(world.resource::(), text); + world + .resource::() + .sequence(key) + .and_then(|ast| { + eval_cues(world, &ast, line_end) + .inspect_err(|error| { + warn!( + "sequence on entry {} of conversation {} failed: {error}; playing the default", + key.1.0, key.0.0 + ); + }) + .ok() + }) + .unwrap_or_else(|| default_cues(world, line_end)) +} + +/// The estimated reading time of `text`. +fn line_end(settings: &SequencerSettings, text: &str) -> f32 { + (text.chars().count() as f32 / settings.chars_per_second.max(1.0)).max(settings.min_seconds) +} + +/// The cue list of the default sequence. Empty when it's unset or broken. +fn default_cues(world: &mut World, line_end: f32) -> Vec { + let source = world + .resource::() + .default_sequence + .clone(); + if source.is_empty() { + return Vec::new(); + } + let engine = world.resource::().0.clone(); + engine + .compile(&source) + .inspect_err(|error| warn!("the default sequence doesn't compile: {error}")) + .ok() + .and_then(|ast| { + eval_cues(world, &ast, line_end) + .inspect_err(|error| warn!("the default sequence failed: {error}")) + .ok() + }) + .unwrap_or_default() +} + +/// Starts playing `cues` for `runner`'s presented line. +pub fn begin_sequence(world: &mut World, runner: Entity, cues: Vec) { + world.spawn(( + PlayingSequence { + runner, + pending: cues, + active: Vec::new(), + elapsed: 0.0, + fired: HashSet::new(), + }, + ChildOf(runner), + )); +} + +/// Advances every playing sequence by this frame's time. +pub fn drive_sequences(world: &mut World) { + let delta = world.resource::