Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
104 changes: 104 additions & 0 deletions assets/cutscene.dialogue.ron
Original file line number Diff line number Diff line change
@@ -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: [],
),
],
)
149 changes: 149 additions & 0 deletions assets/shop.dialogue.ron
Original file line number Diff line number Diff line change
@@ -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: [],
),
],
)
3 changes: 3 additions & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion docs/src/concepts/format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 2 additions & 0 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions docs/src/runtime/cues.md
Original file line number Diff line number Diff line change
@@ -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<LineFinished>, 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
```
Loading
Loading