Skip to main content

Crate replique

Crate replique 

Source
Expand description

A pure Rust dialogue system.

Replique reads dialogues written in plain text, compiles them, and runs them on a small virtual machine. It knows nothing about any engine: the VM hands out events (say this line, offer these choices, run this command) and waits for the caller to answer. bevy_replique is the Bevy plugin built on top of this crate.

§The format

:= start
Alice: Hi! How are you?
-> Good, thanks!
    Alice: Glad to hear it.
-> Not great.
    Alice: Oh no, what happened?
=> END
---

The language is described in the Replique Book, which is the place to look for how a dialogue is written. This documentation is about reading and running one.

§Tags

The #name and #name:value tags of a line reach the caller apart from its text:

use replique::RepliqueFile;
use replique::dialogue::Tag;
use replique::vm::{DialogueEvent, DialogueVm};

let file = RepliqueFile::from_source(
    ":= start\nAlice: #angry Give it back! #sound:alice_01\n---\n",
);

let mut vm = DialogueVm::default();
let DialogueEvent::Say { text, tags, .. } =
    vm.start(file.dialogue.unwrap(), "start").unwrap()
else {
    panic!("expected a line");
};

assert_eq!(text, "Give it back!");
assert_eq!(tags, [Tag::new("angry"), Tag::with_value("sound", "alice_01")]);

A choice takes tags the same way, -> #hostile Attack #cost:3, and each Choice of a DialogueEvent::Choices carries its own.

§Schema

With a RepliqueSchema, the parsed dialogue can be validated against speaker, tags, functions and commands.

use replique::parser::parse;
use replique::parser::validation::RepliqueSchema;

let schema = RepliqueSchema::from_toml(
    r#"
    speakers = ["Robin", "Fanny"]

    [tags]
    sad = { scope = ["Fanny"] }

    [commands]
    change_mood = "2"
    "#,
)
.unwrap();

let mut parsed = parse(
    ":= start\nFany: Hi!\nRobin: #sad Hello.\n>> change_mood(Fanny)\n---\n",
);
parsed.validate(&schema);

let codes: Vec<_> = parsed.diagnostics.iter().map(|d| d.kind.code()).collect();
assert_eq!(codes, ["unknown-speaker", "tag-out-of-scope", "wrong-arity"]);

What a schema finds is a warning: the dialogue still compiles and runs. Each section is optional and is not checked when empty. The builtins are known to every schema. See parser::validation for the format of the file.

§Compiling

RepliqueFile is the front door: it parses and compiles a source in one step, and keeps the Diagnostics either way. A file with errors yields no Dialogue, so check RepliqueFile::has_errors before running anything.

use replique::RepliqueFile;
use replique::parser::diagnostic::Color;

let file = RepliqueFile::from_source(":= start\nAlice: Hi!\n=> nope\n---\n");

assert!(file.has_errors());
print!("{}", file.render_diagnostics(Color::Never));

§Running

DialogueVm is a coroutine: it walks the dialogue until it reaches something only the caller can handle, then suspends and returns a DialogueEvent. Answering it with DialogueVm::resume carries on.

use replique::RepliqueFile;
use replique::vm::{DialogueEvent, DialogueVm, ResumeEvent};

let file = RepliqueFile::from_source(
    ":= start\nAlice: Hi!\n-> Hello\n    Alice: Hello!\n-> Bye\n    Alice: Bye!\n---\n",
);
let dialogue = file.dialogue.unwrap();

let mut vm = DialogueVm::default();
let mut event = vm.start(dialogue, "start").unwrap();

loop {
    event = match event {
        DialogueEvent::Say { speaker, text, .. } => {
            println!("{}: {}", speaker.unwrap_or_default(), text);
            vm.resume(ResumeEvent::Advance).unwrap()
        }
        // A real game would show these and wait for the player.
        DialogueEvent::Choices { .. } => vm.resume(ResumeEvent::Select(0)).unwrap(),
        DialogueEvent::Finished => break,
        _ => vm.resume(ResumeEvent::Advance).unwrap(),
    };
}

A Choice whose [if] condition does not hold is still listed, with Choice::available set to false, so that the indexes do not move. Selecting it is a VmError::ChoiceNotAvailable.

DialogueVm::vars reads back every variable a run has written.

A run never panics on a malformed dialogue: every way it can go wrong is a VmError, including the step limit that catches nodes jumping to each other forever.

§Commands and functions

A >> command(...) does something. It surfaces as DialogueEvent::Command, which suspends the VM like a line does, so an animation or a sound can run to completion before the dialogue moves on.

A [function(...)] answers something, and never suspends. The VM resolves it through the RepliqueHost the caller passes to DialogueVm::start_with and DialogueVm::resume_with. The same host answers has when it is asked of a subject rather than of a dictionary, and gives the value of a feature read with a ., as in Alice.Mood:

use std::collections::HashMap;

use replique::RepliqueFile;
use replique::dialogue::Value;
use replique::host::{HostError, RepliqueHost};
use replique::vm::{DialogueEvent, DialogueVm};

struct Game {
    gold: i64,
    moods: HashMap<&'static str, &'static str>,
}

impl RepliqueHost for Game {
    fn call(&mut self, name: &str, _args: Vec<Value>) -> Result<Value, HostError> {
        match name {
            "gold" => Ok(Value::Int(self.gold)),
            _ => Err(HostError::UnknownFunction(name.into())),
        }
    }

    fn has_feature(&mut self, subject: &str, name: &str) -> Result<bool, HostError> {
        if !["Angry", "Happy"].contains(&name) {
            return Err(HostError::UnknownFeature(name.into()));
        }

        match self.moods.get(subject) {
            Some(mood) => Ok(*mood == name),
            None => Err(HostError::UnknownSubject(subject.into())),
        }
    }

    fn feature(&mut self, subject: &str, name: &str) -> Result<Value, HostError> {
        match (self.moods.get(subject), name) {
            (Some(mood), "Mood") => Ok(Value::String((*mood).into())),
            (Some(_), _) => Err(HostError::UnknownFeature(name.into())),
            (None, _) => Err(HostError::UnknownSubject(subject.into())),
        }
    }
}

let file = RepliqueFile::from_source(
    r#":= start
[if Alice has Angry]
    Alice: Give me back my [gold()] coins!
[else]
    Alice: You have [gold()] coins.
---
"#,
);
let dialogue = file.dialogue.unwrap();

let mut game = Game {
    gold: 12,
    moods: HashMap::from([("Alice", "Angry")]),
};
let mut vm = DialogueVm::default();

let DialogueEvent::Say { text, .. } = vm.start_with(&mut game, dialogue, "start").unwrap()
else {
    panic!("expected a line");
};
assert_eq!(text, "Give me back my 12 coins!");

The builtins are available to every dialogue, whatever the host.

§Modules

  • parser turns a source into an AST and Diagnostics.
  • dialogue is the compiled form the VM walks, and its Value type.
  • vm runs it.
  • host is how a dialogue calls into the game.
  • builtins are the functions every dialogue can call.

Modules§

builtins
The functions every dialogue can call, whatever the host calling the VM.
dialogue
Runnable form of a .rep file.
host
Define the RepliqueHost trait for the Virtual Machine to execute function on the host side without sending events.
parser
Parser for Replique
vm
Virtual machine executing a compiled Dialogue.

Structs§

RepliqueFile