diff --git a/Cargo.toml b/Cargo.toml index a9a458c..5370990 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,9 @@ 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"] } +scoped-tls-hkt = "0.1" # Bevy Dependencies bevy = { version = "0.19", default-features = false, features = [ diff --git a/assets/cutscene.dialogue.ron b/assets/cutscene.dialogue.ron new file mode 100644 index 0000000..21a8427 --- /dev/null +++ b/assets/cutscene.dialogue.ron @@ -0,0 +1,104 @@ +( + version: "1", + actors: [ + ( + id: 0, + name: "Bard", + is_player: true, + fields: [], + ), + ( + id: 1, + name: "Innkeeper", + is_player: false, + fields: [], + ), + ], + variables: [], + conversations: [ + ( + id: 1, + title: "Storm", + 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), + ], + ), + ( + id: 2, + actor: 1, + conversant: 0, + menu_text: "", + dialogue_text: "Foul night. The pass is snowed in.", + is_root: false, + is_group: false, + sequence: "sfx(\"thunder rolls in the distance\").at(0.4); sfx(\"rain lashes the windows\").at(1.6); wait(line_end)", + links: [ + (dest_conversation: 1, dest_entry: 3), + ], + ), + ( + id: 3, + actor: 1, + conversant: 0, + menu_text: "", + dialogue_text: "Sit. Play something, if you know how.", + is_root: false, + is_group: false, + sequence: "sfx(\"a stool scrapes toward the fire\").at(0.6); wait(line_end)", + links: [ + (dest_conversation: 1, dest_entry: 4), + (dest_conversation: 1, dest_entry: 5), + ], + ), + ( + id: 4, + actor: 0, + conversant: 1, + menu_text: "Play a song", + dialogue_text: "Very well. One song.", + is_root: false, + is_group: false, + sequence: "strum().emits(\"song\"); sfx(\"coins clatter by your boots\").after(\"song\").required(); wait(line_end)", + links: [ + (dest_conversation: 1, dest_entry: 6), + ], + ), + ( + id: 5, + actor: 0, + conversant: 1, + menu_text: "Sit quietly", + dialogue_text: "Not tonight.", + is_root: false, + is_group: false, + links: [ + (dest_conversation: 1, dest_entry: 6), + ], + ), + ( + id: 6, + actor: 1, + conversant: 0, + menu_text: "", + dialogue_text: "Rest well, bard.", + is_root: false, + is_group: false, + sequence: "sfx(\"the fire settles to embers\").at(0.8); wait(line_end)", + links: [], + ), + ], + fields: [], + ), + ], +) 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..2b107f5 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -10,6 +10,9 @@ - [Playing Conversations](./runtime/playing.md) - [Actors and Participants](./runtime/actors.md) - [Variables](./runtime/variables.md) + - [Conditions and Scripts](./runtime/scripting.md) + - [Sequences and Cutscenes](./runtime/cues.md) + - [Manual and Auto Advance](./runtime/pacing.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..9aa78a7 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -9,6 +9,8 @@ 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. +- **Sequences and cutscenes**: entries stage what happens while a line plays, with timed cues that call into your game: camera moves, animations, pauses. - **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/cues.md b/docs/src/runtime/cues.md new file mode 100644 index 0000000..2d89651 --- /dev/null +++ b/docs/src/runtime/cues.md @@ -0,0 +1,93 @@ +# Sequences and Cutscenes + +A **sequence** is [Rhai](https://rhai.rs/book/) code that runs when the entry is presented, but instead of doing things on the spot, it schedules **cues**: timed instructions played out while the line is on screen. Camera moves, animations, sounds, pauses. + +```ron +( + id: 5, + dialogue_text: "You dare come back here?", + sequence: "shake_camera(0.5); play_anim(\"point\").at(0.8); wait(line_end)", + // ... +) +``` + +In the editor the sequence lives in the Logic section of the entry inspector, next to the condition and the script. + +## The timing methods + +Every scheduled cue returns a handle, and the timing methods chain on it: + +```rhai +wait(2.0) // a cue that lasts two seconds +emit("looked") // fires a message, instantly +play_anim("draw").at(1.5) // starts 1.5 seconds in +play_sound("gasp").after("looked") // starts when that message fires +zoom("closeup").emits("zoomed") // fires a message when done +reset_camera().required() // still runs if the line is skipped +``` + +`at` delays a cue's start. `after` holds it until a message fires. `emits` fires a message when the cue finishes, which is how cues chain off each other without counting seconds. `required` marks cleanup that must happen even when the player skips the line. + +`wait` and `emit` are built in. Everything else is a command your game registers. + +A sequence is full Rhai, so it can branch on game state: + +```rhai +if vars["Scared"] { play_anim("cower") } else { play_anim("smirk") } +wait(line_end) +``` + +## line_end and the default sequence + +`line_end` is the estimated reading time of the line, computed from its length using `SequencerSettings` (characters per second, with a minimum). An entry with no sequence plays the default one from the same resource, `wait(line_end)` unless you change it. So every presented line plays a sequence and every line has a clock, even when nobody authored one. + +## Registering commands + +Commands are Bevy systems, registered like [dialogue systems](./scripting.md#calling-into-your-game): + +```rust,ignore +app.add_sequencer_command("play_anim", play_anim); + +fn play_anim(In((cue, clip)): In<(Entity, String)>, /* any system params */) -> CueLife { + // start the clip... + CueLife::For(Duration::from_secs_f32(1.2)) +} +``` + +The system's `In` input is the cue entity paired with the arguments (a value or a tuple, up to four of `bool`, `i64`, `f64`, `f32`, `String`, or `Dynamic`). It returns how long the cue lives: + +- `CueLife::Instant`: done the moment it ran. +- `CueLife::For(duration)`: done after that long. +- `CueLife::Until`: open-ended. The game finishes it by triggering `FinishCue` on the cue entity, when the audio ends, the tween completes, the character arrives. + +## Pacing the conversation + +When a line's last cue finishes, `LineFinished` fires on the runner. Advancing stays your call, so nothing moves until you trigger `AdvanceConversation`. A game that wants sequences to pace the dialogue wires the two together with one observer: + +```rust,ignore +app.add_observer(|line: On, mut commands: Commands| { + commands.trigger(AdvanceConversation { entity: line.entity }); +}); +``` + +With that in place a conversation plays itself: each line stays up for its reading time, or for as long as its cues take, then flows on. Menus still wait for a choice. Manual advance, auto-play, and switching between the two are covered in [Manual and Auto Advance](./pacing.md). + +## Skipping + +Trigger `SkipLine` on the runner to fast-forward the line's sequence without advancing the conversation. The sequence ends immediately but lands on the same state it would have reached by playing out: `required` cues that hadn't started yet still run (marked with a `Skipped` component, so their handlers can jump straight to the end result), every running cue gets a `CueSkipped` event to snap its effects to their final state, and `LineFinished` fires. + +Advancing or choosing while a sequence is still playing cuts it short the same way, except `LineFinished` does not fire, since the line was replaced rather than finished. + +## Script or sequence? + +If it changes the game, it is a script. If it shows the game, it is a sequence. + +The two fields differ in when they re-run. A script runs once per visit and never on resume. A sequence replays every time the line is presented, including when a saved conversation resumes, because the re-presented line still needs its cues and its clock. The whole sequence body re-runs to rebuild the cue list, so writing game state there means paying for it again on every load. Keep `vars` writes and calls like `spend(10)` in the script; a sequence should be safe to run twice. + +## Try it + +The cutscene example plays a stormy inn scene that runs on its own: sound effects landing mid-line, a song chained with messages, a required cue that survives skipping, and the one-observer auto-advance. Enter skips a line. + +```sh +cargo run --example cutscene +``` diff --git a/docs/src/runtime/pacing.md b/docs/src/runtime/pacing.md new file mode 100644 index 0000000..bd91db5 --- /dev/null +++ b/docs/src/runtime/pacing.md @@ -0,0 +1,111 @@ +# Manual and Auto Advance + +Who decides when the next line comes? Some games wait for a click on every line, others also offer an auto mode. + +The key idea: a presented line is not instant, it *plays* for a while. A line with a [sequence](./cues.md) plays for as long as its effects take. + +Three events are involved. Two are commands your game sends to the dialogue runner: + +- `AdvanceConversation` ends the current line and moves on. +- `SkipLine` fast-forwards the current line without leaving it. Fires `LineFinished` at the end. + +The third goes the other way, from the runner to your game: + +- `LineFinished` reports that the line has finished playing. It exists so your game knows the line has nothing left and can advance the conversation. + +## Manual + +You can just ignore `LineFinished` entirely and advance on input: + +```rust,ignore +// on click / key press, while the runner is presenting: +commands.trigger(AdvanceConversation { entity: runner }); +``` + +This is the shop example. + +## Manual with fast-forward + +What most dialogue-heavy games do: the first press finishes the line, the second moves on. Track whether the line has played out with a marker: + +```rust,ignore +#[derive(Component)] +struct LineDone; + +app.add_observer(|line: On, mut commands: Commands| { + commands.entity(line.entity).insert(LineDone); +}); +app.add_observer(|line: On, mut commands: Commands| { + commands.entity(line.entity).remove::(); +}); +``` + +Then input branches on the marker: + +```rust,ignore +fn on_press(runner: Entity, done: bool, commands: &mut Commands) { + if done { + commands.trigger(AdvanceConversation { entity: runner }); + } else { + commands.trigger(SkipLine { entity: runner }); + } +} +``` + +Press once to jump to the end, press again to continue. + +## Auto-play + +One observer, and the lines pace themselves: + +```rust,ignore +app.add_observer(|line: On, mut commands: Commands| { + commands.trigger(AdvanceConversation { entity: line.entity }); +}); +``` + +Every line stays up for its reading time, or for as long as its authored effects take, then flows on. +This is the cutscene example. Input still works: `SkipLine` fires `LineFinished`, which this observer turns into an advance, so pressing skip in auto mode jumps to the next line. + +To give readers more time without touching every entry, stretch the default reading-time clock in `SequencerSettings`: + +```rust,ignore +settings.default_sequence = "wait(line_end + 0.75)".to_owned(); +``` + +## The toggle + +Both modes can be the same two observers with a switch in front: + +```rust,ignore +#[derive(Resource, Default)] +struct AutoPlay(bool); + +app.add_observer( + |line: On, auto: Res, mut commands: Commands| { + if auto.0 { + commands.trigger(AdvanceConversation { entity: line.entity }); + } else { + commands.entity(line.entity).insert(LineDone); + } + }, +); +``` + +Input keeps the fast-forward branching from above. +One catch when the player turns auto on: the current line may have finished long ago, sitting there waiting for a press that will never come. Catch it up when the flag flips: + +```rust,ignore +fn enable_auto( + mut auto: ResMut, + waiting: Query>, + mut commands: Commands, +) { + auto.0 = true; + for runner in &waiting { + commands.trigger(AdvanceConversation { entity: runner }); + } +} +``` + +Turning auto off needs nothing: the next `LineFinished` simply inserts the marker and waits. 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..74ae322 --- /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. A third kind of entry logic, the sequence, schedules what happens on screen while the line plays; it has [its own page](./cues.md). + +```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/cutscene.rs b/examples/cutscene.rs new file mode 100644 index 0000000..b1b7593 --- /dev/null +++ b/examples/cutscene.rs @@ -0,0 +1,150 @@ +//! Plays `assets/cutscene.dialogue.ron` in the terminal: a stormy inn scene +//! that stages itself with sequences and cues. +//! +//! Run with `cargo run --example cutscene`. The scene plays on its own; +//! press Enter to skip a line's staging, type a number to pick a response. +//! +//! What to watch for: +//! - Lines advance by themselves: one observer turns `LineFinished` into +//! `AdvanceConversation`, so the sequences pace the conversation. +//! - Sound effects land mid-line at authored times (`sfx(...).at(1.6)`). +//! - "Play a song" chains cues with messages: the coins only clatter +//! `after("song")`, and that cue is `required()`, so skipping the song +//! with Enter still pays the bard. + +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>); + +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))) + .add_systems(Startup, start) + .add_systems(Update, handle_input) + .add_observer(print_subtitle) + .add_observer(print_menu) + .add_observer(finish); + + // The sequences pace the dialogue: a finished line advances itself. + app.add_observer(|line: On, mut commands: Commands| { + commands.trigger(AdvanceConversation { + entity: line.entity, + }); + }); + + // The commands sequences can cue. + app.add_sequencer_command("sfx", sfx); + app.add_sequencer_command("strum", strum); + + app.run(); +} + +/// `sfx(text)`: an instant atmospheric effect. +fn sfx(In((_, text)): In<(Entity, String)>) -> CueLife { + println!(" ({text})"); + CueLife::Instant +} + +/// `strum()`: the bard plays for a while. +fn strum(In((_, ())): In<(Entity, ())>) -> CueLife { + println!(" ~ you strum a slow, sad tune ~"); + CueLife::For(Duration::from_secs(2)) +} + +/// Loads the database and spawns the conversation runner. +fn start(mut commands: Commands, assets: Res) { + println!("--- bevy_talks cutscene demo ---"); + println!("(the scene plays itself; Enter skips a line)\n"); + let database: Handle = assets.load("cutscene.dialogue.ron"); + commands.spawn(DialogueRunner::new( + database, + ConversationRef::Title("Storm".to_owned()), + )); +} + +/// Prints a line; its sequence takes it from here. +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); +} + +/// Prints the response menu and waits for a number. +fn print_menu(menu: On) { + println!("\nYour move:"); + 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--- scene over ---"); + exit.write(AppExit::Success); +} + +/// Enter skips the presented line's staging; a number picks a response. +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(SkipLine { 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/examples/shop.rs b/examples/shop.rs new file mode 100644 index 0000000..eca685e --- /dev/null +++ b/examples/shop.rs @@ -0,0 +1,161 @@ +//! 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 924bd1d..242a675 100644 --- a/src/data/entry.rs +++ b/src/data/entry.rs @@ -29,4 +29,13 @@ pub struct DialogueEntry { /// Custom fields. #[serde(default)] pub fields: Vec, + /// Rhai expression gating whether this entry can be reached. Empty means always. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub condition: String, + /// 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/lib.rs b/src/lib.rs index e1d975d..44852e3 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,15 +24,32 @@ impl Plugin for TalksPlugin { .init_asset_loader::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, ( - runtime::variables::seed_variables, - runtime::runner::start_runners, + 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::>), + 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) + .add_observer(runtime::sequencer::on_skip_line); } } diff --git a/src/loader/validate.rs b/src/loader/validate.rs index 7b46aad..17b365c 100644 --- a/src/loader/validate.rs +++ b/src/loader/validate.rs @@ -134,6 +134,9 @@ mod tests { is_group: false, links: vec![], fields: vec![], + condition: String::new(), + script: String::new(), + sequence: String::new(), }], fields: vec![], }], diff --git a/src/prelude.rs b/src/prelude.rs index 2da323f..999417d 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -8,8 +8,13 @@ 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, CueSkipped, + DialogueRunner, FinishCue, LineFinished, Participants, Phase, PlayingSequence, Response, + ResponseMenuOpened, SequencerSettings, SkipLine, Skipped, Step, Subtitle, SubtitleStarted, + Variables, VisitCount, Visits, }; pub use super::saver::{DialogueDatabaseSaver, SaveError, to_ron_string}; +pub use super::scripting::{ + AddDialogueSystem, AddSequencerCommand, CueLife, DialogueSystems, ScriptArg, ScriptArgs, + ScriptReturn, SequencerCommands, +}; diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 44add5c..b3dc57d 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,9 @@ pub use runner::{ AdvanceConversation, ChooseResponse, ConversationEnded, DialogueRunner, Participants, Phase, ResponseMenuOpened, SubtitleStarted, }; +pub use sequencer::{ + Cue, CueSkipped, FinishCue, LineFinished, PlayingSequence, SequencerSettings, SkipLine, Skipped, +}; pub use step::{ConversationRef, Response, Step, Subtitle}; pub use variables::Variables; pub use visits::{VisitCount, Visits}; diff --git a/src/runtime/runner.rs b/src/runtime/runner.rs index 9d8c0f3..ad5f658 100644 --- a/src/runtime/runner.rs +++ b/src/runtime/runner.rs @@ -10,12 +10,15 @@ use std::collections::HashMap; use bevy::prelude::*; +use super::sequencer::{begin_sequence, build_line_cues, stop_sequences}; 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 +57,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 +71,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 +81,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 +93,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 +153,170 @@ 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); + } + _ => {} + } } -/// 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). +/// 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, 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, ) { + // A new step replaces the presented line; its sequence stops, converging. + stop_sequences(world, entity); 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 { + // Staging is presentation, not logic: unlike the script, the + // sequence replays on resume. + let cues = build_line_cues(world, at, &subtitle.text); + begin_sequence(world, entity, cues); + 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 +324,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::*; @@ -349,6 +379,9 @@ mod tests { }) .collect(), fields: vec![], + condition: String::new(), + script: String::new(), + sequence: String::new(), }; DialogueDatabase { version: "1".to_owned(), @@ -389,6 +422,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::(); @@ -408,7 +446,7 @@ mod tests { let handle = app .world_mut() .resource_mut::>() - .add(db()); + .add(db); let runner = app .world_mut() .spawn(DialogueRunner::new( @@ -513,6 +551,111 @@ 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"]); + } + + /// How many times the `mark` sequencer command ran. + #[derive(Resource, Default)] + struct Marked(u32); + + #[rstest] + fn lines_play_sequences_that_converge_when_the_step_moves_on() { + use crate::runtime::sequencer::PlayingSequence; + use crate::scripting::{AddSequencerCommand, CueLife}; + + let mut db = db(); + db.conversations[0].entries[1].sequence = "mark().at(60).required(); wait(60)".to_owned(); + let (mut app, runner) = app_with(db); + app.init_resource::(); + app.add_sequencer_command( + "mark", + |In((_, ())): In<(Entity, ())>, mut marked: ResMut| { + marked.0 += 1; + CueLife::Instant + }, + ); + + app.update(); // presents "Hello"; its sequence starts + let world = app.world_mut(); + assert_eq!(world.query::<&PlayingSequence>().iter(world).count(), 1); + assert_eq!(world.resource::().0, 0, "mark is a minute out"); + + world.trigger(AdvanceConversation { entity: runner }); + app.update(); // the menu replaces the line; its sequence converges + + assert_eq!( + app.world().resource::().0, + 1, + "required cues still run when the sequence stops" + ); + let world = app.world_mut(); + assert_eq!( + world.query::<&PlayingSequence>().iter(world).count(), + 0, + "menus play no line sequence" + ); + } + + /// `(LineFinished fired, CueSkipped fired)` in the skip test. + #[derive(Resource, Default)] + struct SkipLog(u32, u32); + + #[rstest] + fn skip_line_fast_forwards_without_advancing(test_app: (App, Entity)) { + use crate::runtime::sequencer::{CueSkipped, LineFinished, PlayingSequence, SkipLine}; + + let (mut app, runner) = test_app; + app.init_resource::(); + app.add_observer(|_: On, mut log: ResMut| log.0 += 1); + app.add_observer(|_: On, mut log: ResMut| log.1 += 1); + + app.update(); // presents "Hello" with the default wait(line_end) + app.world_mut().trigger(SkipLine { entity: runner }); + app.update(); + + let log = app.world().resource::(); + assert_eq!((log.0, log.1), (1, 1)); + let world = app.world_mut(); + assert_eq!(world.query::<&PlayingSequence>().iter(world).count(), 0); + let phase = &world.get::(runner).unwrap().phase; + assert!( + matches!(phase, Phase::Presenting { .. }), + "skipping the sequence doesn't advance the line" + ); + } + #[rstest] fn out_of_bounds_choice_is_ignored(test_app: (App, Entity)) { let (mut app, runner) = test_app; diff --git a/src/runtime/sequencer.rs b/src/runtime/sequencer.rs new file mode 100644 index 0000000..44c4936 --- /dev/null +++ b/src/runtime/sequencer.rs @@ -0,0 +1,538 @@ +//! 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, +} + +/// Trigger this on a runner to fast-forward its line's sequence: pending +/// required cues still run, active cues get [`CueSkipped`], and +/// [`LineFinished`] fires. +#[derive(EntityEvent, Debug, Clone, Copy)] +pub struct SkipLine { + /// The runner whose line to skip. + pub entity: Entity, +} + +/// Fired on an active cue when its sequence is skipped, right before the cue +/// entity despawns: snap the cue's effects to their end state. +#[derive(EntityEvent, Debug, Clone, Copy)] +pub struct CueSkipped { + /// The skipped cue. + pub entity: Entity, +} + +/// Marks a required cue that started during a skip, so its handler can apply end state instantly. +#[derive(Component)] +pub struct Skipped; + +/// Skip intent recorded for the driver. +#[derive(Component)] +struct SkipRequested; + +/// Records the [`FinishCue`] intent for the driver. +pub(crate) fn on_finish_cue(finish: On, mut commands: Commands) { + commands.entity(finish.entity).insert(CueDone); +} + +/// Records the [`SkipLine`] intent for the driver. +pub(crate) fn on_skip_line(skip: On, mut commands: Commands) { + commands.entity(skip.entity).insert(SkipRequested); +} + +/// 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, fast-forwarding the ones whose runner asked to skip. +pub fn drive_sequences(world: &mut World) { + let delta = world.resource::