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};
#[derive(Resource)]
pub struct SequencerSettings {
pub chars_per_second: f32,
pub min_seconds: f32,
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(),
}
}
}
#[derive(Component)]
pub struct PlayingSequence {
runner: Entity,
pending: Vec<CueRecord>,
active: Vec<Entity>,
elapsed: f32,
fired: HashSet<String>,
}
#[derive(Component)]
pub struct Cue {
pub name: String,
emits: Option<String>,
}
#[derive(Component)]
struct CueTimer(Timer);
#[derive(Component)]
struct CueDone;
#[derive(EntityEvent, Debug, Clone, Copy)]
pub struct FinishCue {
pub entity: Entity,
}
#[derive(EntityEvent, Debug, Clone, Copy)]
pub struct LineFinished {
pub entity: Entity,
}
#[derive(EntityEvent, Debug, Clone, Copy)]
pub struct SkipLine {
pub entity: Entity,
}
#[derive(EntityEvent, Debug, Clone, Copy)]
pub struct CueSkipped {
pub entity: Entity,
}
#[derive(Component)]
pub struct Skipped;
#[derive(Component)]
struct SkipRequested;
pub(crate) fn on_finish_cue(finish: On<FinishCue>, mut commands: Commands) {
commands.entity(finish.entity).insert(CueDone);
}
pub(crate) fn on_skip_line(skip: On<SkipLine>, mut commands: Commands) {
commands.entity(skip.entity).insert(SkipRequested);
}
pub fn build_line_cues(
world: &mut World,
key: (ConversationId, EntryId),
text: &str,
) -> Vec<CueRecord> {
let line_end = line_end(world.resource::<SequencerSettings>(), text);
world
.resource::<CompiledScripts>()
.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))
}
fn line_end(settings: &SequencerSettings, text: &str) -> f32 {
(text.chars().count() as f32 / settings.chars_per_second.max(1.0)).max(settings.min_seconds)
}
fn default_cues(world: &mut World, line_end: f32) -> Vec<CueRecord> {
let source = world
.resource::<SequencerSettings>()
.default_sequence
.clone();
if source.is_empty() {
return Vec::new();
}
let engine = world.resource::<ScriptEngine>().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()
}
pub fn begin_sequence(world: &mut World, runner: Entity, cues: Vec<CueRecord>) {
world.spawn((
PlayingSequence {
runner,
pending: cues,
active: Vec::new(),
elapsed: 0.0,
fired: HashSet::new(),
},
ChildOf(runner),
));
}
pub fn drive_sequences(world: &mut World) {
let delta = world.resource::<Time>().delta();
let sequences: Vec<(Entity, Entity)> = world
.query::<(Entity, &PlayingSequence)>()
.iter(world)
.map(|(sequence, playing)| (sequence, playing.runner))
.collect();
for (sequence, runner) in sequences {
if world.get::<SkipRequested>(runner).is_some() {
world.entity_mut(runner).remove::<SkipRequested>();
stop_sequence(world, sequence, true);
} else {
drive_sequence(world, sequence, delta);
}
}
}
pub fn stop_sequences(world: &mut World, runner: Entity) {
if world.get::<SkipRequested>(runner).is_some() {
world.entity_mut(runner).remove::<SkipRequested>();
}
let sequences: Vec<Entity> = world
.query::<(Entity, &PlayingSequence)>()
.iter(world)
.filter(|(_, playing)| playing.runner == runner)
.map(|(sequence, _)| sequence)
.collect();
for sequence in sequences {
stop_sequence(world, sequence, false);
}
}
fn stop_sequence(world: &mut World, sequence: Entity, report: bool) {
let Some(mut playing) = world.get_mut::<PlayingSequence>(sequence) else {
return;
};
let runner = playing.runner;
let required: Vec<CueRecord> = playing
.pending
.drain(..)
.filter(|record| record.required)
.collect();
for record in required {
start_cue(world, sequence, record, true);
}
let active = world
.get::<PlayingSequence>(sequence)
.map(|playing| playing.active.clone())
.unwrap_or_default();
for cue in active {
world.trigger(CueSkipped { entity: cue });
finish_cue(world, sequence, cue);
}
world.entity_mut(sequence).despawn();
if report {
world.trigger(LineFinished { entity: runner });
}
}
fn drive_sequence(world: &mut World, sequence: Entity, delta: Duration) {
let Some(mut playing) = world.get_mut::<PlayingSequence>(sequence) else {
return;
};
playing.elapsed += delta.as_secs_f32();
let active = playing.active.clone();
let ended: Vec<Entity> = active
.into_iter()
.filter(|&cue| cue_ended(world, cue, delta))
.collect();
for cue in ended {
finish_cue(world, sequence, cue);
}
loop {
let Some(mut playing) = world.get_mut::<PlayingSequence>(sequence) else {
return;
};
let elapsed = playing.elapsed;
let fired = playing.fired.clone();
let (ready, waiting): (Vec<_>, Vec<_>) = playing.pending.drain(..).partition(|record| {
record.at <= elapsed
&& record
.after
.as_ref()
.is_none_or(|message| fired.contains(message))
});
playing.pending = waiting;
if ready.is_empty() {
break;
}
ready
.into_iter()
.for_each(|record| start_cue(world, sequence, record, false));
}
let Some(playing) = world.get::<PlayingSequence>(sequence) else {
return;
};
if playing.pending.is_empty() && playing.active.is_empty() {
let runner = playing.runner;
world.entity_mut(sequence).despawn();
world.trigger(LineFinished { entity: runner });
}
}
fn cue_ended(world: &mut World, cue: Entity, delta: Duration) -> bool {
if world.get::<CueDone>(cue).is_some() {
return true;
}
world
.get_mut::<CueTimer>(cue)
.is_some_and(|mut timer| timer.0.tick(delta).is_finished())
}
fn start_cue(world: &mut World, sequence: Entity, record: CueRecord, skipped: bool) {
let cue = world
.spawn((
Cue {
name: record.name.clone(),
emits: record.emits.clone(),
},
ChildOf(sequence),
))
.id();
if skipped {
world.entity_mut(cue).insert(Skipped);
}
let life = match record.name.as_str() {
"wait" => CueLife::For(Duration::from_secs_f32(
record
.args
.first()
.and_then(|secs| secs.as_float().ok())
.unwrap_or(0.0) as f32,
)),
"emit" => {
let message = record
.args
.first()
.and_then(|message| message.clone().into_string().ok());
fire(world, sequence, message);
CueLife::Instant
}
name => world
.resource::<SequencerCommands>()
.bridge(name)
.map_or_else(
|| {
warn!("no sequencer command `{name}` is registered");
CueLife::Instant
},
|bridge| {
bridge(world, cue, record.args.clone()).unwrap_or_else(|error| {
warn!("sequencer command `{name}` failed: {error}");
CueLife::Instant
})
},
),
};
match life {
CueLife::Instant => {
fire(world, sequence, record.emits);
world.entity_mut(cue).despawn();
}
CueLife::For(duration) => {
world
.entity_mut(cue)
.insert(CueTimer(Timer::new(duration, TimerMode::Once)));
push_active(world, sequence, cue);
}
CueLife::Until => push_active(world, sequence, cue),
}
}
fn finish_cue(world: &mut World, sequence: Entity, cue: Entity) {
let emits = world.get::<Cue>(cue).and_then(|cue| cue.emits.clone());
world.entity_mut(cue).despawn();
if let Some(mut playing) = world.get_mut::<PlayingSequence>(sequence) {
playing.active.retain(|&alive| alive != cue);
}
fire(world, sequence, emits);
}
fn fire(world: &mut World, sequence: Entity, message: Option<String>) {
if let (Some(message), Some(mut playing)) =
(message, world.get_mut::<PlayingSequence>(sequence))
{
playing.fired.insert(message);
}
}
fn push_active(world: &mut World, sequence: Entity, cue: Entity) {
if let Some(mut playing) = world.get_mut::<PlayingSequence>(sequence) {
playing.active.push(cue);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::DialogueDatabase;
use crate::scripting::AddSequencerCommand;
use rstest::{fixture, rstest};
#[derive(Resource, Default)]
struct Finished(u32);
#[fixture]
fn app() -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default(), crate::TalksPlugin));
app.init_resource::<Finished>();
app.add_observer(|_: On<LineFinished>, mut finished: ResMut<Finished>| finished.0 += 1);
app
}
fn begin(code: &str, world: &mut World) -> Entity {
let engine = world.resource::<ScriptEngine>().0.clone();
let ast = engine.compile(code).unwrap();
let cues = eval_cues(world, &ast, 3.0).unwrap();
let runner = world.spawn_empty().id();
begin_sequence(world, runner, cues);
runner
}
fn drive(world: &mut World, secs: f32) {
world
.resource_mut::<Time>()
.advance_by(Duration::from_secs_f32(secs));
drive_sequences(world);
}
fn finished(world: &World) -> u32 {
world.resource::<Finished>().0
}
#[rstest]
fn wait_holds_the_line_until_its_time_passes(mut app: App) {
let world = app.world_mut();
begin("wait(1.0)", world);
drive(world, 0.1); drive(world, 0.6);
assert_eq!(finished(world), 0);
drive(world, 0.5); assert_eq!(finished(world), 1);
drive(world, 1.0);
assert_eq!(finished(world), 1, "a finished sequence is gone");
}
#[rstest]
fn messages_unlock_after_cues_in_the_same_frame(mut app: App) {
let world = app.world_mut();
begin(r#"emit("go"); wait(0.2).after("go")"#, world);
drive(world, 0.1); assert_eq!(finished(world), 0);
drive(world, 0.3);
assert_eq!(finished(world), 1);
}
#[rstest]
fn at_delays_a_cue(mut app: App) {
let world = app.world_mut();
begin(r#"emit("late").at(1.0)"#, world);
drive(world, 0.1);
assert_eq!(finished(world), 0, "the delayed cue is still pending");
drive(world, 1.0);
assert_eq!(finished(world), 1);
}
#[rstest]
fn until_cues_end_on_finish_cue(mut app: App) {
app.add_sequencer_command("hold", |In((_, ())): In<(Entity, ())>| CueLife::Until);
app.update(); let world = app.world_mut();
begin("hold()", world);
drive(world, 0.1);
drive(world, 5.0);
assert_eq!(finished(world), 0, "an Until cue outlives any clock");
let cue = world
.query_filtered::<Entity, With<Cue>>()
.single(world)
.unwrap();
world.trigger(FinishCue { entity: cue });
world.flush(); drive(world, 0.1);
assert_eq!(finished(world), 1);
}
#[rstest]
fn lines_without_a_sequence_play_the_default(mut app: App) {
let mut db = DialogueDatabase::default();
db.conversations.push(crate::data::Conversation {
id: ConversationId(1),
entries: vec![
crate::data::DialogueEntry {
id: EntryId(1),
sequence: r#"emit("authored")"#.to_owned(),
..Default::default()
},
crate::data::DialogueEntry {
id: EntryId(2),
..Default::default()
},
],
..Default::default()
});
let _handle = app
.world_mut()
.resource_mut::<Assets<DialogueDatabase>>()
.add(db);
app.update();
app.update();
let world = app.world_mut();
let authored = build_line_cues(world, (ConversationId(1), EntryId(1)), "hi");
assert_eq!(authored[0].name, "emit");
let default = build_line_cues(world, (ConversationId(1), EntryId(2)), &"x".repeat(60));
assert_eq!(default[0].name, "wait");
assert_eq!(default[0].args[0].as_float().unwrap(), 2.0);
}
}