use std::collections::{HashMap, VecDeque};
use bevy::light::NotShadowCaster;
use bevy::picking::Pickable;
use bevy::picking::events::{PointerClick, PointerOut, PointerOver};
use bevy::prelude::*;
use bevy::time::Real;
use serde::{Deserialize, Serialize};
use crate::balloon::{
Bubble, BubbleAssets, BubbleOffsetPx, BubblePx, BubbleStyle, BubbleTtl, DefaultBalloonSkin,
build_bubble, dwell_secs,
};
use crate::dialog_box::{Typewriter, complete_typing};
use crate::style::{BubbleKind, Emotion};
fn default_speech() -> BubbleKind {
BubbleKind::Speech
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Choice {
pub text: String,
pub next: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Node {
Line {
speaker: String,
#[serde(default = "default_speech")]
kind: BubbleKind,
#[serde(default)]
emotion: Emotion,
text: String,
next: Option<String>,
},
Choice {
speaker: String,
#[serde(default)]
emotion: Emotion,
prompt: String,
options: Vec<Choice>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Conversation {
pub start: String,
pub nodes: HashMap<String, Node>,
}
#[derive(Resource, Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DialogueScript {
pub conversations: HashMap<String, Conversation>,
}
impl DialogueScript {
pub fn conversation(&self, id: &str) -> Option<&Conversation> {
self.conversations.get(id)
}
pub fn merge(&mut self, other: DialogueScript) -> Result<(), String> {
if let Some(id) = other
.conversations
.keys()
.find(|id| self.conversations.contains_key(*id))
{
return Err(format!("dialogue: conversation '{id}' is defined twice"));
}
self.conversations.extend(other.conversations);
Ok(())
}
}
pub fn validate_script(s: &DialogueScript) -> Result<(), String> {
if s.conversations.is_empty() {
return Err("dialogue: no conversations defined".into());
}
for (cid, conv) in &s.conversations {
if !conv.nodes.contains_key(&conv.start) {
return Err(format!(
"dialogue '{cid}': start node '{}' not found",
conv.start
));
}
for (nid, node) in &conv.nodes {
match node {
Node::Line { speaker, next, .. } => {
check_speaker(speaker, cid, nid)?;
if let Some(n) = next
&& !conv.nodes.contains_key(n)
{
return Err(format!(
"dialogue '{cid}' node '{nid}': next '{n}' not found"
));
}
}
Node::Choice {
speaker, options, ..
} => {
check_speaker(speaker, cid, nid)?;
if options.is_empty() {
return Err(format!(
"dialogue '{cid}' node '{nid}': choice has no options"
));
}
for o in options {
if !conv.nodes.contains_key(&o.next) {
return Err(format!(
"dialogue '{cid}' node '{nid}': option next '{}' not found",
o.next
));
}
}
}
}
}
}
Ok(())
}
fn check_speaker(s: &str, cid: &str, nid: &str) -> Result<(), String> {
if s.trim().is_empty() {
return Err(format!("dialogue '{cid}' node '{nid}': speaker is empty"));
}
Ok(())
}
#[derive(Debug, Clone, Default)]
pub struct Speaker {
pub entity: Option<Entity>,
pub name: String,
pub portrait: Option<Handle<Image>>,
}
#[derive(Resource, Debug, Clone, Default)]
pub struct Speakers(pub HashMap<String, Speaker>);
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Presenter {
#[default]
Balloons,
DialogBox,
}
#[derive(Message, Debug, Clone)]
pub struct StartConversation {
pub id: String,
}
#[derive(Message, Debug, Clone)]
pub struct ChoicePicked {
pub index: usize,
}
#[derive(Message, Debug, Clone)]
pub struct ConversationEnded {
pub id: String,
}
#[derive(Message, Debug, Clone, PartialEq, Eq)]
pub struct DialogueNodeEntered {
pub conversation: String,
pub node: String,
}
#[derive(Resource, Debug)]
pub struct ConversationLock;
#[derive(Message, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Bark {
pub speaker: String,
pub kind: BubbleKind,
pub emotion: Emotion,
pub text: String,
}
#[derive(Resource, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct BarkSettings {
pub gap: f32,
pub stale: f32,
pub queue_cap: usize,
}
impl Default for BarkSettings {
fn default() -> Self {
Self {
gap: 1.0,
stale: 3.0,
queue_cap: 4,
}
}
}
struct PendingBark {
bark: Bark,
queued_at: f32,
}
#[derive(Resource, Default)]
pub struct BarkQueue {
pending: VecDeque<PendingBark>,
next_at: f32,
}
impl BarkQueue {
pub(crate) fn push(&mut self, bark: Bark, now: f32, settings: &BarkSettings) {
self.pending.push_back(PendingBark {
bark,
queued_at: now,
});
while self.pending.len() > settings.queue_cap {
self.pending.pop_front();
}
}
pub(crate) fn take_ready(&mut self, now: f32, settings: &BarkSettings) -> Option<Bark> {
while self
.pending
.front()
.is_some_and(|p| now - p.queued_at > settings.stale)
{
self.pending.pop_front();
}
if now < self.next_at {
return None;
}
self.pending.pop_front().map(|p| p.bark)
}
pub(crate) fn spoke_at(&mut self, now: f32, settings: &BarkSettings) {
self.next_at = now + settings.gap;
}
pub fn len(&self) -> usize {
self.pending.len()
}
pub fn is_empty(&self) -> bool {
self.pending.is_empty()
}
}
#[derive(Resource)]
pub(crate) struct Active {
pub(crate) conv: String,
pub(crate) node: String,
pub(crate) presented: bool,
pub(crate) advance_at: f32,
}
#[derive(Component)]
pub(crate) struct ConversationEntity;
#[derive(Component)]
pub(crate) struct AmbientBubble {
owner: Entity,
}
const CHOICE_GAP_PX: f32 = 25.0;
const CHOICE_BASE_PX: f32 = 31.0;
const CHOICE_DIM: Color = Color::srgb(0.78, 0.78, 0.78);
fn speaker_entity(speakers: &Speakers, key: &str) -> Option<Entity> {
speakers.0.get(key).and_then(|s| s.entity)
}
pub(crate) fn open_conversation(
mut commands: Commands,
mut starts: MessageReader<StartConversation>,
active: Option<Res<Active>>,
script: Res<DialogueScript>,
mut entered: MessageWriter<DialogueNodeEntered>,
) {
let id = {
let Some(start) = starts.read().next() else {
return;
};
start.id.clone()
};
starts.clear();
if active.is_some() {
return;
}
let Some(conv) = script.conversation(&id) else {
warn!("bevy_speech_bubbles: no conversation '{id}'");
return;
};
commands.insert_resource(Active {
conv: id.clone(),
node: conv.start.clone(),
presented: false,
advance_at: 0.0,
});
entered.write(DialogueNodeEntered {
conversation: id.clone(),
node: conv.start.clone(),
});
commands.insert_resource(ConversationLock);
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn present_current(
mut commands: Commands,
time: Res<Time<Real>>,
presenter: Res<Presenter>,
active: Option<ResMut<Active>>,
script: Res<DialogueScript>,
speakers: Res<Speakers>,
skin: Res<DefaultBalloonSkin>,
assets: Option<Res<BubbleAssets>>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<StandardMaterial>>,
existing: Query<Entity, With<ConversationEntity>>,
mut ended: MessageWriter<ConversationEnded>,
) {
if *presenter != Presenter::Balloons {
return;
}
let Some(mut active) = active else {
return;
};
if active.presented {
return;
}
for e in &existing {
commands.entity(e).despawn();
}
let Some(assets) = assets else {
error!("bevy_speech_bubbles: Presenter::Balloons needs a BubbleAssets resource");
let id = active.conv.clone();
finish(&mut commands, &existing, &mut ended, id);
return;
};
let Some(node) = script
.conversation(&active.conv)
.and_then(|c| c.nodes.get(&active.node))
else {
warn!(
"bevy_speech_bubbles: node '{}' of '{}' is missing; ending",
active.node, active.conv
);
let id = active.conv.clone();
finish(&mut commands, &existing, &mut ended, id);
return;
};
let now = time.elapsed_secs();
match node {
Node::Line {
speaker,
kind,
emotion,
text,
..
} => {
match speaker_entity(&speakers, speaker) {
Some(owner) => {
spawn_line_bubble(
&mut commands,
&assets,
&mut images,
&mut materials,
owner,
BubbleStyle {
kind: *kind,
emotion: *emotion,
tail: true,
skin: skin.0,
},
text,
);
active.advance_at = now + dwell_secs(text);
}
None => active.advance_at = now,
}
}
Node::Choice {
speaker,
emotion,
prompt,
options,
} => {
let Some(owner) = speaker_entity(&speakers, speaker) else {
warn!(
"bevy_speech_bubbles: choice '{}' needs a speaker with an entity in balloon mode \
('{speaker}' has none); ending",
active.node
);
let id = active.conv.clone();
finish(&mut commands, &existing, &mut ended, id);
return;
};
let prompt_height_px = spawn_line_bubble(
&mut commands,
&assets,
&mut images,
&mut materials,
owner,
BubbleStyle {
kind: BubbleKind::Speech,
emotion: *emotion,
tail: true,
skin: skin.0,
},
prompt,
)
.y;
let glyph_k = skin.0.text_px / 40.0;
let gap_px = (CHOICE_GAP_PX * glyph_k).round();
let mut offset_y = (CHOICE_BASE_PX * glyph_k).round() + prompt_height_px;
for (i, opt) in options.iter().enumerate() {
let rendered = build_bubble(
&assets,
&mut images,
&mut materials,
&BubbleStyle {
kind: BubbleKind::Speech,
emotion: Emotion::Neutral,
tail: false,
skin: skin.0,
},
&format!("{}. {}", i + 1, opt.text),
);
if let Some(mut m) = materials.get_mut(&rendered.material) {
m.base_color = CHOICE_DIM;
}
let mat = rendered.material.clone();
let mat_over = mat.clone();
let mat_out = mat.clone();
let center_offset = offset_y + rendered.px.y as f32 * 0.5;
commands
.spawn((
Bubble {
owner,
offset: Vec2::ZERO,
},
BubbleOffsetPx(Vec2::new(0.0, center_offset)),
BubblePx(rendered.px),
ConversationEntity,
Pickable::default(),
Mesh3d(assets.quad.clone()),
MeshMaterial3d(mat),
NotShadowCaster,
Transform::from_scale(Vec3::new(rendered.size.x, rendered.size.y, 1.0)),
))
.observe(
move |_ev: On<PointerClick>, mut picked: MessageWriter<ChoicePicked>| {
picked.write(ChoicePicked { index: i });
},
)
.observe(
move |_ev: On<PointerOver>, mut mats: ResMut<Assets<StandardMaterial>>| {
if let Some(mut m) = mats.get_mut(&mat_over) {
m.base_color = Color::WHITE;
}
},
)
.observe(
move |_ev: On<PointerOut>, mut mats: ResMut<Assets<StandardMaterial>>| {
if let Some(mut m) = mats.get_mut(&mat_out) {
m.base_color = CHOICE_DIM;
}
},
);
offset_y += rendered.px.y as f32 + gap_px;
}
active.advance_at = f32::INFINITY; }
}
active.presented = true;
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn advance_line(
mut commands: Commands,
time: Res<Time<Real>>,
mut mouse: ResMut<ButtonInput<MouseButton>>,
mut keys: ResMut<ButtonInput<KeyCode>>,
presenter: Res<Presenter>,
active: Option<ResMut<Active>>,
script: Res<DialogueScript>,
existing: Query<Entity, With<ConversationEntity>>,
mut typewriters: Query<&mut Typewriter>,
mut ended: MessageWriter<ConversationEnded>,
mut entered: MessageWriter<DialogueNodeEntered>,
) {
let Some(mut active) = active else {
return;
};
if !active.presented {
return;
}
let next = match script
.conversation(&active.conv)
.and_then(|c| c.nodes.get(&active.node))
{
Some(Node::Line { next, .. }) => next.clone(),
_ => return,
};
let mut pressed = mouse.get_just_pressed().any(|b| *b == MouseButton::Left);
for key in [KeyCode::Enter, KeyCode::NumpadEnter, KeyCode::Space] {
pressed |= keys.just_pressed(key);
}
let timed_out = *presenter == Presenter::Balloons && time.elapsed_secs() >= active.advance_at;
if !pressed && !timed_out {
return;
}
if pressed {
mouse.clear_just_pressed(MouseButton::Left);
for key in [KeyCode::Enter, KeyCode::NumpadEnter, KeyCode::Space] {
keys.clear_just_pressed(key);
}
if complete_typing(&mut typewriters) {
return;
}
}
match next {
Some(n) => {
entered.write(DialogueNodeEntered {
conversation: active.conv.clone(),
node: n.clone(),
});
active.node = n;
active.presented = false;
}
None => {
let id = active.conv.clone();
finish(&mut commands, &existing, &mut ended, id);
}
}
}
pub(crate) fn choice_hotkeys(
mut keys: ResMut<ButtonInput<KeyCode>>,
active: Option<Res<Active>>,
script: Res<DialogueScript>,
mut picked: MessageWriter<ChoicePicked>,
) {
let Some(active) = active else { return };
if !active.presented {
return;
}
let Some(Node::Choice { options, .. }) = script
.conversation(&active.conv)
.and_then(|c| c.nodes.get(&active.node))
else {
return;
};
const DIGITS: [(KeyCode, KeyCode); 9] = [
(KeyCode::Digit1, KeyCode::Numpad1),
(KeyCode::Digit2, KeyCode::Numpad2),
(KeyCode::Digit3, KeyCode::Numpad3),
(KeyCode::Digit4, KeyCode::Numpad4),
(KeyCode::Digit5, KeyCode::Numpad5),
(KeyCode::Digit6, KeyCode::Numpad6),
(KeyCode::Digit7, KeyCode::Numpad7),
(KeyCode::Digit8, KeyCode::Numpad8),
(KeyCode::Digit9, KeyCode::Numpad9),
];
for (index, (row, numpad)) in DIGITS.iter().enumerate().take(options.len()) {
let row_pressed = keys.clear_just_pressed(*row);
let numpad_pressed = keys.clear_just_pressed(*numpad);
if row_pressed || numpad_pressed {
picked.write(ChoicePicked { index });
return; }
}
}
pub(crate) fn resolve_choice(
mut picks: MessageReader<ChoicePicked>,
active: Option<ResMut<Active>>,
script: Res<DialogueScript>,
mut entered: MessageWriter<DialogueNodeEntered>,
) {
let Some(mut active) = active else {
picks.clear();
return;
};
let Some(pick) = picks.read().next() else {
return;
};
let index = pick.index;
picks.clear();
if !active.presented {
return; }
let next = match script
.conversation(&active.conv)
.and_then(|c| c.nodes.get(&active.node))
{
Some(Node::Choice { options, .. }) => options.get(index).map(|o| o.next.clone()),
_ => return,
};
if let Some(n) = next {
entered.write(DialogueNodeEntered {
conversation: active.conv.clone(),
node: n.clone(),
});
active.node = n;
active.presented = false;
}
}
pub(crate) fn finish(
commands: &mut Commands,
existing: &Query<Entity, With<ConversationEntity>>,
ended: &mut MessageWriter<ConversationEnded>,
id: String,
) {
for e in existing {
commands.entity(e).despawn();
}
commands.remove_resource::<Active>();
commands.remove_resource::<ConversationLock>();
ended.write(ConversationEnded { id });
}
pub(crate) fn queue_barks(
time: Res<Time<Real>>,
settings: Res<BarkSettings>,
mut barks: MessageReader<Bark>,
mut queue: ResMut<BarkQueue>,
) {
let now = time.elapsed_secs();
for bark in barks.read() {
queue.push(bark.clone(), now, &settings);
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn release_bark(
mut commands: Commands,
time: Res<Time<Real>>,
lock: Option<Res<ConversationLock>>,
settings: Res<BarkSettings>,
mut queue: ResMut<BarkQueue>,
speakers: Res<Speakers>,
skin: Res<DefaultBalloonSkin>,
assets: Option<Res<BubbleAssets>>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<StandardMaterial>>,
ambient: Query<(Entity, &AmbientBubble)>,
) {
if lock.is_some() {
return;
}
let now = time.elapsed_secs();
let Some(bark) = queue.take_ready(now, &settings) else {
return;
};
let Some(assets) = assets else {
warn_once!("bevy_speech_bubbles: barks need a BubbleAssets resource; dropping them");
return;
};
let Some(owner) = speaker_entity(&speakers, &bark.speaker) else {
return;
};
queue.spoke_at(now, &settings);
for (e, a) in &ambient {
if a.owner == owner {
commands.entity(e).despawn();
}
}
let rendered = build_bubble(
&assets,
&mut images,
&mut materials,
&BubbleStyle {
kind: bark.kind,
emotion: bark.emotion,
tail: true,
skin: skin.0,
},
&bark.text,
);
commands.spawn((
Bubble {
owner,
offset: Vec2::ZERO,
},
AmbientBubble { owner },
BubblePx(rendered.px),
BubbleTtl {
expires_at: now + dwell_secs(&bark.text),
},
Mesh3d(assets.quad.clone()),
MeshMaterial3d(rendered.material),
NotShadowCaster,
Transform::from_scale(Vec3::new(rendered.size.x, rendered.size.y, 1.0)),
));
}
fn spawn_line_bubble(
commands: &mut Commands,
assets: &BubbleAssets,
images: &mut Assets<Image>,
materials: &mut Assets<StandardMaterial>,
owner: Entity,
style: BubbleStyle,
text: &str,
) -> Vec2 {
let rendered = build_bubble(assets, images, materials, &style, text);
commands.spawn((
Bubble {
owner,
offset: Vec2::ZERO,
},
BubblePx(rendered.px),
ConversationEntity,
Mesh3d(assets.quad.clone()),
MeshMaterial3d(rendered.material),
NotShadowCaster,
Transform::from_scale(Vec3::new(rendered.size.x, rendered.size.y, 1.0)),
));
rendered.px.as_vec2()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
(
conversations: {
"intro": (
start: "a",
nodes: {
"a": Line(speaker: "lead", kind: Speech, text: "Move up.", next: Some("b")),
"b": Line(speaker: "scout", kind: Thought, emotion: Fear, text: "This place is wrong.", next: Some("c")),
"c": Choice(speaker: "lead", prompt: "Which way?", options: [
(text: "Left", next: "d"),
(text: "Hold", next: "d"),
]),
"d": Line(speaker: "lead", text: "Copy.", next: None),
},
),
},
)
"#;
fn sample() -> DialogueScript {
ron::from_str(SAMPLE).expect("sample parses")
}
#[test]
fn parses_and_validates_sample() {
let script = sample();
validate_script(&script).expect("sample validates");
let conv = script.conversation("intro").expect("intro exists");
assert_eq!(conv.start, "a");
assert_eq!(conv.nodes.len(), 4);
}
#[test]
fn kind_defaults_to_speech() {
let script = sample();
let conv = script.conversation("intro").expect("intro exists");
match conv.nodes.get("d") {
Some(Node::Line { kind, .. }) => assert_eq!(*kind, BubbleKind::Speech),
other => panic!("d is a Line, got {other:?}"),
}
}
#[test]
fn a_script_survives_a_ron_round_trip() {
let script = sample();
let text = ron::to_string(&script).expect("script serializes");
let back: DialogueScript = ron::from_str(&text).expect("script parses back");
assert_eq!(back, script);
}
fn invalid(src: &str) -> String {
let script: DialogueScript = ron::from_str(src).expect("parses");
validate_script(&script).expect_err("must not validate")
}
#[test]
fn rejects_dangling_next() {
let e = invalid(
r#"( conversations: { "x": ( start: "a", nodes: {
"a": Line(speaker: "s", text: "hi", next: Some("nope")),
} ) } )"#,
);
assert!(e.contains("next 'nope' not found"), "{e}");
}
#[test]
fn rejects_missing_start() {
let e = invalid(
r#"( conversations: { "x": ( start: "z", nodes: {
"a": Line(speaker: "s", text: "hi", next: None),
} ) } )"#,
);
assert!(e.contains("start node 'z' not found"), "{e}");
}
#[test]
fn rejects_empty_speaker() {
let e = invalid(
r#"( conversations: { "x": ( start: "a", nodes: {
"a": Line(speaker: " ", text: "hi", next: None),
} ) } )"#,
);
assert!(e.contains("speaker is empty"), "{e}");
}
#[test]
fn rejects_a_choice_without_options() {
let e = invalid(
r#"( conversations: { "x": ( start: "a", nodes: {
"a": Choice(speaker: "s", prompt: "?", options: []),
} ) } )"#,
);
assert!(e.contains("choice has no options"), "{e}");
}
#[test]
fn rejects_a_dangling_option() {
let e = invalid(
r#"( conversations: { "x": ( start: "a", nodes: {
"a": Choice(speaker: "s", prompt: "?", options: [(text: "t", next: "gone")]),
} ) } )"#,
);
assert!(e.contains("option next 'gone' not found"), "{e}");
}
#[test]
fn rejects_an_empty_script() {
let e = validate_script(&DialogueScript::default()).expect_err("empty script");
assert!(e.contains("no conversations"), "{e}");
}
#[test]
fn unknown_fields_are_refused() {
let bad = r#"( conversations: { "x": ( start: "a", extra: 1, nodes: {} ) } )"#;
assert!(ron::from_str::<DialogueScript>(bad).is_err());
}
#[test]
fn merge_adds_and_refuses_duplicates() {
let mut a = sample();
let mut other = DialogueScript::default();
other
.conversations
.insert("outro".into(), a.conversations["intro"].clone());
a.merge(other.clone()).expect("a new id merges");
assert!(a.conversation("outro").is_some());
let before = a.clone();
assert!(a.merge(other).is_err(), "a duplicate id must be refused");
assert_eq!(a, before, "a refused merge changed the script");
}
fn machine() -> App {
let mut app = App::new();
app.add_message::<ChoicePicked>()
.add_message::<ConversationEnded>()
.add_message::<DialogueNodeEntered>()
.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<ButtonInput<MouseButton>>()
.init_resource::<Time<Real>>()
.init_resource::<Presenter>()
.insert_resource(ConversationLock)
.add_systems(
Update,
(choice_hotkeys, resolve_choice, advance_line).chain(),
);
app
}
fn script() -> DialogueScript {
let mut nodes = HashMap::new();
nodes.insert(
"ask".into(),
Node::Choice {
speaker: "lead".into(),
emotion: Emotion::Neutral,
prompt: "Orders?".into(),
options: vec![
Choice {
text: "Hold.".into(),
next: "held".into(),
},
Choice {
text: "Advance.".into(),
next: "moved".into(),
},
],
},
);
for (id, text) in [("held", "Holding."), ("moved", "Moving up.")] {
nodes.insert(
id.into(),
Node::Line {
speaker: "lead".into(),
kind: BubbleKind::Speech,
emotion: Emotion::Neutral,
text: text.into(),
next: None,
},
);
}
let mut conversations = HashMap::new();
conversations.insert(
"t".into(),
Conversation {
start: "ask".into(),
nodes,
},
);
DialogueScript { conversations }
}
fn park(app: &mut App, node: &str) {
app.insert_resource(script());
app.insert_resource(Active {
conv: "t".into(),
node: node.into(),
presented: true,
advance_at: f32::INFINITY,
});
}
fn node_of(app: &App) -> Option<String> {
app.world().get_resource::<Active>().map(|a| a.node.clone())
}
fn tap(app: &mut App, key: KeyCode) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(key);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
}
#[test]
fn the_keyboard_can_answer_a_choice_so_a_conversation_can_never_soft_lock() {
let mut app = machine();
park(&mut app, "ask");
tap(&mut app, KeyCode::Digit2);
assert_eq!(
node_of(&app).as_deref(),
Some("moved"),
"'2' takes the second option"
);
if let Some(mut a) = app.world_mut().get_resource_mut::<Active>() {
a.presented = true;
}
tap(&mut app, KeyCode::Space);
assert!(
app.world().get_resource::<Active>().is_none(),
"the conversation cursor outlived the conversation"
);
assert!(
app.world().get_resource::<ConversationLock>().is_none(),
"the lock survived — the host would still be yielding input"
);
let ended: Vec<_> = app
.world_mut()
.resource_mut::<Messages<ConversationEnded>>()
.drain()
.collect();
assert_eq!(ended.len(), 1);
assert_eq!(ended[0].id, "t");
}
#[test]
fn the_numpad_picks_the_same_option_as_the_number_row() {
let mut app = machine();
park(&mut app, "ask");
tap(&mut app, KeyCode::Numpad1);
assert_eq!(node_of(&app).as_deref(), Some("held"));
}
#[test]
fn a_digit_past_the_last_option_does_nothing() {
let mut app = machine();
park(&mut app, "ask");
tap(&mut app, KeyCode::Digit5);
assert_eq!(node_of(&app).as_deref(), Some("ask"));
}
#[test]
fn a_digit_pressed_during_a_line_does_not_answer_the_next_choice() {
let mut app = machine();
park(&mut app, "held"); tap(&mut app, KeyCode::Digit2);
park(&mut app, "ask");
app.update();
assert_eq!(node_of(&app).as_deref(), Some("ask"));
}
#[test]
fn enter_advances_a_line() {
let mut app = machine();
park(&mut app, "held");
tap(&mut app, KeyCode::Enter);
assert!(app.world().get_resource::<Active>().is_none());
}
#[test]
fn a_left_click_advances_a_line() {
let mut app = machine();
park(&mut app, "held");
app.world_mut()
.resource_mut::<ButtonInput<MouseButton>>()
.press(MouseButton::Left);
app.update();
assert!(app.world().get_resource::<Active>().is_none());
}
fn bark(text: &str) -> Bark {
Bark {
speaker: "lead".into(),
kind: BubbleKind::Speech,
emotion: Emotion::Neutral,
text: text.into(),
}
}
#[test]
fn a_second_bark_waits_a_full_gap() {
let s = BarkSettings::default();
let mut q = BarkQueue::default();
q.push(bark("Contact. Hold your lane."), 0.0, &s);
q.push(bark("Copy."), 0.0, &s);
assert_eq!(
q.take_ready(0.0, &s).map(|b| b.text),
Some("Contact. Hold your lane.".into())
);
q.spoke_at(0.0, &s);
assert!(q.take_ready(0.5, &s).is_none(), "reply jumped the gap");
assert!(
q.take_ready(s.gap - 0.01, &s).is_none(),
"reply jumped the gap"
);
assert_eq!(
q.take_ready(s.gap, &s).map(|b| b.text),
Some("Copy.".into())
);
}
#[test]
fn first_bark_is_never_delayed() {
let s = BarkSettings::default();
let mut q = BarkQueue::default();
q.push(bark("Moving."), 0.0, &s);
assert!(q.take_ready(0.0, &s).is_some());
}
#[test]
fn a_bark_that_waited_too_long_is_dropped_not_narrated_late() {
let s = BarkSettings::default();
let mut q = BarkQueue::default();
q.push(bark("Contact."), 0.0, &s);
assert!(q.take_ready(s.stale + 0.01, &s).is_none());
assert!(q.is_empty());
}
#[test]
fn a_stale_head_does_not_starve_a_fresh_bark() {
let s = BarkSettings::default();
let mut q = BarkQueue::default();
q.push(bark("old"), 0.0, &s);
q.push(bark("fresh"), 2.5, &s);
assert_eq!(q.take_ready(3.5, &s).map(|b| b.text), Some("fresh".into()));
}
#[test]
fn a_burst_keeps_the_freshest_barks() {
let s = BarkSettings::default();
let mut q = BarkQueue::default();
for i in 0..s.queue_cap + 2 {
q.push(bark(&i.to_string()), 0.0, &s);
}
assert_eq!(q.len(), s.queue_cap);
assert_eq!(q.take_ready(0.0, &s).map(|b| b.text), Some("2".into()));
}
#[test]
fn a_burst_drains_one_balloon_per_gap() {
let s = BarkSettings::default();
let mut q = BarkQueue::default();
for i in 0..s.queue_cap {
q.push(bark(&i.to_string()), 0.0, &s);
}
let mut spoken = Vec::new();
let mut t = 0.0_f32;
while t <= s.stale {
if let Some(b) = q.take_ready(t, &s) {
q.spoke_at(t, &s);
spoken.push((b.text, t));
}
t += 0.1;
}
assert!(spoken.len() >= 3, "expected a paced drain, got {spoken:?}");
assert!(
spoken.len() <= s.queue_cap,
"released more than were queued"
);
for w in spoken.windows(2) {
assert!(
w[1].1 - w[0].1 >= s.gap - 1.0e-3,
"balloons {:?} and {:?} overlap",
w[0],
w[1]
);
}
}
}