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};
#[derive(Component, Debug)]
pub struct DialogueRunner {
pub database: Handle<DialogueDatabase>,
pub conversation: ConversationRef,
pub phase: Phase,
}
impl DialogueRunner {
pub fn new(database: Handle<DialogueDatabase>, conversation: ConversationRef) -> Self {
Self {
database,
conversation,
phase: Phase::Starting,
}
}
pub fn resume(database: Handle<DialogueDatabase>, at: (ConversationId, EntryId)) -> Self {
Self {
database,
conversation: ConversationRef::Id(at.0),
phase: Phase::Resuming { at },
}
}
pub fn save_point(&self) -> Option<(ConversationId, EntryId)> {
match &self.phase {
Phase::Presenting { at }
| Phase::AwaitingChoice { at, .. }
| Phase::Resuming { at }
| Phase::Advancing { from: at }
| Phase::Choosing { to: at } => Some(*at),
Phase::Starting | Phase::Ended => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Phase {
Starting,
Resuming {
at: (ConversationId, EntryId),
},
Presenting {
at: (ConversationId, EntryId),
},
Advancing {
from: (ConversationId, EntryId),
},
AwaitingChoice {
at: (ConversationId, EntryId),
responses: Vec<Response>,
},
Choosing {
to: (ConversationId, EntryId),
},
Ended,
}
#[derive(Component, Debug, Default)]
pub struct Participants(pub HashMap<ActorId, Entity>);
#[derive(EntityEvent, Debug, Clone, Copy)]
pub struct AdvanceConversation {
pub entity: Entity,
}
#[derive(EntityEvent, Debug, Clone, Copy)]
pub struct ChooseResponse {
pub entity: Entity,
pub index: usize,
}
#[derive(EntityEvent, Debug, Clone)]
pub struct SubtitleStarted {
pub entity: Entity,
pub subtitle: Subtitle,
pub speaker: Option<Entity>,
pub listener: Option<Entity>,
}
#[derive(EntityEvent, Debug, Clone)]
pub struct ResponseMenuOpened {
pub entity: Entity,
pub responses: Vec<Response>,
}
#[derive(EntityEvent, Debug, Clone, Copy)]
pub struct ConversationEnded {
pub entity: Entity,
}
pub fn on_advance(advance: On<AdvanceConversation>, 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;
};
runner.phase = Phase::Advancing { from: at };
}
pub fn on_choose(choose: On<ChooseResponse>, 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) else {
warn!("ChooseResponse index {} out of bounds", choose.index);
return;
};
runner.phase = Phase::Choosing {
to: (response.conversation, response.entry),
};
}
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);
}
}
fn drive_runner(
world: &mut World,
entity: Entity,
database: &Handle<DialogueDatabase>,
conversation: ConversationRef,
phase: Phase,
) {
let Some(db) = world
.resource::<Assets<DialogueDatabase>>()
.get(database)
.cloned()
else {
return;
};
if matches!(phase, Phase::Starting | Phase::Resuming { .. }) {
world.resource_mut::<Variables>().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);
}
_ => {}
}
}
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);
}
fn apply_step(
world: &mut World,
entity: Entity,
step: Step,
from: (ConversationId, EntryId),
fresh: bool,
) {
stop_sequences(world, entity);
match step {
Step::Line(subtitle) => {
let at = (subtitle.conversation, subtitle.entry);
set_phase(world, entity, Phase::Presenting { at });
if fresh {
world.resource_mut::<Visits>().record_displayed(at);
run_script(world, at);
}
let cues = build_line_cues(world, at, &subtitle.text);
begin_sequence(world, entity, cues);
let bound = |world: &World, actor: ActorId| {
world
.get::<Participants>(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,
listener,
});
}
Step::Menu(responses) => {
if fresh {
let mut visits = world.resource_mut::<Visits>();
for response in &responses {
visits.record_offered((response.conversation, response.entry));
}
}
set_phase(
world,
entity,
Phase::AwaitingChoice {
at: from,
responses: responses.clone(),
},
);
world.trigger(ResponseMenuOpened { entity, responses });
}
Step::End => {
set_phase(world, entity, Phase::Ended);
world.trigger(ConversationEnded { entity });
}
}
}
fn set_phase(world: &mut World, entity: Entity, phase: Phase) {
if let Some(mut runner) = world.get_mut::<DialogueRunner>(entity) {
runner.phase = phase;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::TalksPlugin;
use crate::data::{Actor, Conversation, DialogueEntry, Link};
use rstest::{fixture, rstest};
fn db() -> DialogueDatabase {
let entry = |id: i32, actor: i32, menu: &str, text: &str, links: Vec<i32>| DialogueEntry {
id: EntryId(id),
actor: ActorId(actor),
conversant: ActorId(1 - actor),
menu_text: menu.to_owned(),
dialogue_text: text.to_owned(),
is_root: id == 1,
is_group: false,
links: links
.into_iter()
.map(|to| Link {
dest_conversation: ConversationId(1),
dest_entry: EntryId(to),
})
.collect(),
fields: vec![],
condition: String::new(),
script: String::new(),
sequence: String::new(),
};
DialogueDatabase {
version: "1".to_owned(),
variables: vec![],
actors: vec![
Actor {
id: ActorId(0),
name: "Player".to_owned(),
is_player: true,
fields: vec![],
},
Actor {
id: ActorId(1),
name: "Feri".to_owned(),
is_player: false,
fields: vec![],
},
],
conversations: vec![Conversation {
id: ConversationId(1),
title: "Test".to_owned(),
actor: ActorId(0),
conversant: ActorId(1),
entries: vec![
entry(1, 1, "", "", vec![2]),
entry(2, 1, "", "Hello", vec![3]),
entry(3, 0, "Ask", "What is this?", vec![4]),
entry(4, 1, "", "It's Bevy Talks!", vec![]),
],
fields: vec![],
}],
}
}
#[derive(Resource, Default)]
struct Emitted(Vec<String>);
#[fixture]
fn test_app() -> (App, Entity) {
app_with(db())
}
fn app_with(db: DialogueDatabase) -> (App, Entity) {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default(), TalksPlugin));
app.init_resource::<Emitted>();
app.add_observer(|line: On<SubtitleStarted>, mut emitted: ResMut<Emitted>| {
emitted.0.push(format!("line: {}", line.subtitle.text));
});
app.add_observer(
|menu: On<ResponseMenuOpened>, mut emitted: ResMut<Emitted>| {
let labels: Vec<&str> = menu.responses.iter().map(|r| r.text.as_str()).collect();
emitted.0.push(format!("menu: {}", labels.join(", ")));
},
);
app.add_observer(|_: On<ConversationEnded>, mut emitted: ResMut<Emitted>| {
emitted.0.push("ended".to_owned());
});
let handle = app
.world_mut()
.resource_mut::<Assets<DialogueDatabase>>()
.add(db);
let runner = app
.world_mut()
.spawn(DialogueRunner::new(
handle,
ConversationRef::Title("Test".to_owned()),
))
.id();
(app, runner)
}
#[rstest]
fn plays_a_conversation_end_to_end(test_app: (App, Entity)) {
let (mut app, runner) = test_app;
app.update();
app.world_mut()
.trigger(AdvanceConversation { entity: runner });
app.update();
app.world_mut().trigger(ChooseResponse {
entity: runner,
index: 0,
});
app.update();
app.world_mut()
.trigger(AdvanceConversation { entity: runner });
app.update();
app.world_mut()
.trigger(AdvanceConversation { entity: runner });
app.update();
let emitted = &app.world().resource::<Emitted>().0;
assert_eq!(
emitted,
&[
"line: Hello",
"menu: Ask",
"line: What is this?",
"line: It's Bevy Talks!",
"ended",
]
);
let phase = &app.world().get::<DialogueRunner>(runner).unwrap().phase;
assert_eq!(*phase, Phase::Ended);
}
#[rstest]
fn tracks_visits_and_save_point(test_app: (App, Entity)) {
let (mut app, runner) = test_app;
app.update(); assert_eq!(
app.world()
.get::<DialogueRunner>(runner)
.unwrap()
.save_point(),
Some((ConversationId(1), EntryId(2)))
);
app.world_mut()
.trigger(AdvanceConversation { entity: runner });
app.update();
let visits = app.world().resource::<Visits>();
assert_eq!(visits.displayed((ConversationId(1), EntryId(2))), 1);
assert_eq!(visits.offered((ConversationId(1), EntryId(3))), 1);
assert_eq!(
app.world()
.get::<DialogueRunner>(runner)
.unwrap()
.save_point(),
Some((ConversationId(1), EntryId(2)))
);
}
#[rstest]
fn resume_re_presents_the_saved_line_without_counting_a_visit(test_app: (App, Entity)) {
let (mut app, runner) = test_app;
let handle = app
.world()
.get::<DialogueRunner>(runner)
.unwrap()
.database
.clone();
let resumed = app
.world_mut()
.spawn(DialogueRunner::resume(
handle,
(ConversationId(1), EntryId(4)),
))
.id();
app.update();
let emitted = &app.world().resource::<Emitted>().0;
assert!(emitted.contains(&"line: It's Bevy Talks!".to_owned()));
assert_eq!(
app.world()
.resource::<Visits>()
.displayed((ConversationId(1), EntryId(4))),
0
);
let phase = &app.world().get::<DialogueRunner>(resumed).unwrap().phase;
assert!(matches!(phase, Phase::Presenting { .. }));
}
#[rstest]
fn conditions_gate_menus_and_scripts_run_on_presentation() {
use crate::data::{FieldValue, Variable};
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(); assert!(app.world().resource::<Variables>().truthy("Greeted"));
app.world_mut()
.trigger(AdvanceConversation { entity: runner });
app.update();
let emitted = &app.world().resource::<Emitted>().0;
assert_eq!(emitted, &["line: Hello", "menu: Leave"]);
}
#[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::<Marked>();
app.add_sequencer_command(
"mark",
|In((_, ())): In<(Entity, ())>, mut marked: ResMut<Marked>| {
marked.0 += 1;
CueLife::Instant
},
);
app.update(); let world = app.world_mut();
assert_eq!(world.query::<&PlayingSequence>().iter(world).count(), 1);
assert_eq!(world.resource::<Marked>().0, 0, "mark is a minute out");
world.trigger(AdvanceConversation { entity: runner });
app.update();
assert_eq!(
app.world().resource::<Marked>().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"
);
}
#[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::<SkipLog>();
app.add_observer(|_: On<LineFinished>, mut log: ResMut<SkipLog>| log.0 += 1);
app.add_observer(|_: On<CueSkipped>, mut log: ResMut<SkipLog>| log.1 += 1);
app.update(); app.world_mut().trigger(SkipLine { entity: runner });
app.update();
let log = app.world().resource::<SkipLog>();
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::<DialogueRunner>(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;
app.update();
app.world_mut()
.trigger(AdvanceConversation { entity: runner });
app.update();
app.world_mut().trigger(ChooseResponse {
entity: runner,
index: 7,
});
app.update();
let phase = &app.world().get::<DialogueRunner>(runner).unwrap().phase;
assert!(matches!(phase, Phase::AwaitingChoice { .. }));
}
}