Skip to main content

proof_engine/timeline/
dialogue.rs

1//! Dialogue system — typewriter effect, choice trees, speaker portraits.
2//!
3//! A `DialogueTree` is a directed graph of `DialogueNode`s connected by `Choice`s.
4//! The `DialoguePlayer` renders the current node character-by-character with a
5//! configurable typewriter effect and waits for player input to advance.
6
7use std::collections::HashMap;
8
9// ── DialogueNode ──────────────────────────────────────────────────────────────
10
11/// A single node in the dialogue tree.
12#[derive(Clone, Debug)]
13pub struct DialogueNode {
14    pub id:       String,
15    pub speaker:  String,
16    pub text:     String,
17    /// Optional portrait key (maps to an atlas glyph or texture name).
18    pub portrait: Option<String>,
19    /// Emotion tag for expression/color changes.
20    pub emotion:  DialogueEmotion,
21    /// What comes next.
22    pub next:     DialogueNext,
23}
24
25/// How to advance after a node.
26#[derive(Clone, Debug)]
27pub enum DialogueNext {
28    /// Jump to another node by ID.
29    Node(String),
30    /// Present choices to the player.
31    Choice(Vec<Choice>),
32    /// The dialogue tree ends.
33    End,
34    /// Jump to End after a timer (auto-advance).
35    Auto { duration: f32, then: Box<DialogueNext> },
36}
37
38/// A selectable choice in the dialogue.
39#[derive(Clone, Debug)]
40pub struct Choice {
41    pub text:     String,
42    pub next:     String,  // node ID
43    /// Condition flag — only shown if this flag is true (or None = always shown).
44    pub requires: Option<String>,
45    /// Consequence flags to set when chosen.
46    pub sets:     Vec<(String, bool)>,
47}
48
49impl Choice {
50    pub fn new(text: impl Into<String>, next: impl Into<String>) -> Self {
51        Self { text: text.into(), next: next.into(), requires: None, sets: Vec::new() }
52    }
53
54    pub fn requires(mut self, flag: impl Into<String>) -> Self {
55        self.requires = Some(flag.into());
56        self
57    }
58
59    pub fn sets_flag(mut self, flag: impl Into<String>, value: bool) -> Self {
60        self.sets.push((flag.into(), value));
61        self
62    }
63}
64
65/// Speaker emotion — affects text color and portrait expression.
66#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
67pub enum DialogueEmotion {
68    #[default]
69    Neutral,
70    Happy,
71    Sad,
72    Angry,
73    Surprised,
74    Scared,
75    Suspicious,
76    Mysterious,
77}
78
79impl DialogueEmotion {
80    /// RGBA color associated with this emotion (for text tinting).
81    pub fn color(self) -> [f32; 4] {
82        match self {
83            DialogueEmotion::Neutral    => [1.0, 1.0, 1.0, 1.0],
84            DialogueEmotion::Happy      => [1.0, 0.95, 0.5, 1.0],
85            DialogueEmotion::Sad        => [0.5, 0.6, 0.9, 1.0],
86            DialogueEmotion::Angry      => [1.0, 0.3, 0.2, 1.0],
87            DialogueEmotion::Surprised  => [0.9, 0.7, 1.0, 1.0],
88            DialogueEmotion::Scared     => [0.6, 0.9, 0.7, 1.0],
89            DialogueEmotion::Suspicious => [0.8, 0.8, 0.4, 1.0],
90            DialogueEmotion::Mysterious => [0.5, 0.4, 0.9, 1.0],
91        }
92    }
93
94    pub fn name(self) -> &'static str {
95        match self {
96            DialogueEmotion::Neutral    => "neutral",
97            DialogueEmotion::Happy      => "happy",
98            DialogueEmotion::Sad        => "sad",
99            DialogueEmotion::Angry      => "angry",
100            DialogueEmotion::Surprised  => "surprised",
101            DialogueEmotion::Scared     => "scared",
102            DialogueEmotion::Suspicious => "suspicious",
103            DialogueEmotion::Mysterious => "mysterious",
104        }
105    }
106}
107
108// ── DialogueTree ──────────────────────────────────────────────────────────────
109
110/// A collection of nodes forming a branching conversation.
111#[derive(Clone, Debug, Default)]
112pub struct DialogueTree {
113    pub name:       String,
114    pub nodes:      HashMap<String, DialogueNode>,
115    pub start_node: String,
116}
117
118impl DialogueTree {
119    pub fn new(name: impl Into<String>) -> Self {
120        Self { name: name.into(), nodes: HashMap::new(), start_node: String::new() }
121    }
122
123    pub fn add_node(mut self, node: DialogueNode) -> Self {
124        if self.start_node.is_empty() {
125            self.start_node = node.id.clone();
126        }
127        self.nodes.insert(node.id.clone(), node);
128        self
129    }
130
131    pub fn with_start(mut self, id: impl Into<String>) -> Self {
132        self.start_node = id.into();
133        self
134    }
135
136    pub fn get(&self, id: &str) -> Option<&DialogueNode> {
137        self.nodes.get(id)
138    }
139}
140
141// ── NodeBuilder ───────────────────────────────────────────────────────────────
142
143/// Fluent builder for DialogueNode.
144pub struct NodeBuilder {
145    id:       String,
146    speaker:  String,
147    text:     String,
148    portrait: Option<String>,
149    emotion:  DialogueEmotion,
150}
151
152impl NodeBuilder {
153    pub fn new(id: impl Into<String>, speaker: impl Into<String>, text: impl Into<String>) -> Self {
154        Self {
155            id:       id.into(),
156            speaker:  speaker.into(),
157            text:     text.into(),
158            portrait: None,
159            emotion:  DialogueEmotion::Neutral,
160        }
161    }
162
163    pub fn portrait(mut self, p: impl Into<String>) -> Self { self.portrait = Some(p.into()); self }
164    pub fn emotion(mut self, e: DialogueEmotion) -> Self { self.emotion = e; self }
165
166    pub fn end(self) -> DialogueNode {
167        DialogueNode { id: self.id, speaker: self.speaker, text: self.text,
168                       portrait: self.portrait, emotion: self.emotion,
169                       next: DialogueNext::End }
170    }
171
172    pub fn then(self, next_id: impl Into<String>) -> DialogueNode {
173        DialogueNode { id: self.id, speaker: self.speaker, text: self.text,
174                       portrait: self.portrait, emotion: self.emotion,
175                       next: DialogueNext::Node(next_id.into()) }
176    }
177
178    pub fn choices(self, choices: Vec<Choice>) -> DialogueNode {
179        DialogueNode { id: self.id, speaker: self.speaker, text: self.text,
180                       portrait: self.portrait, emotion: self.emotion,
181                       next: DialogueNext::Choice(choices) }
182    }
183
184    pub fn auto(self, duration: f32, then: DialogueNext) -> DialogueNode {
185        DialogueNode { id: self.id, speaker: self.speaker, text: self.text,
186                       portrait: self.portrait, emotion: self.emotion,
187                       next: DialogueNext::Auto { duration, then: Box::new(then) } }
188    }
189}
190
191// ── Typewriter state ──────────────────────────────────────────────────────────
192
193/// Typewriter render state for a line of text.
194#[derive(Clone, Debug)]
195pub struct TypewriterState {
196    pub full_text:    String,
197    pub chars_shown:  usize,  // how many chars have been revealed
198    pub chars_per_sec: f32,
199    pub accumulator:  f32,    // fractional char accumulator
200    pub complete:     bool,
201    /// Pause accumulator for punctuation delays.
202    pub pause_timer:  f32,
203}
204
205impl TypewriterState {
206    pub fn new(text: impl Into<String>, chars_per_sec: f32) -> Self {
207        let text = text.into();
208        let complete = text.is_empty();
209        Self {
210            full_text: text,
211            chars_shown: 0,
212            chars_per_sec,
213            accumulator: 0.0,
214            complete,
215            pause_timer: 0.0,
216        }
217    }
218
219    /// Advance by dt seconds. Returns true if newly completed.
220    pub fn tick(&mut self, dt: f32) -> bool {
221        if self.complete { return false; }
222
223        // Punctuation pause
224        if self.pause_timer > 0.0 {
225            self.pause_timer -= dt;
226            return false;
227        }
228
229        self.accumulator += dt * self.chars_per_sec;
230        let new_chars = self.accumulator as usize;
231        self.accumulator -= new_chars as f32;
232
233        for _ in 0..new_chars {
234            if self.chars_shown < self.full_text.len() {
235                // Pause after sentence-ending punctuation
236                let ch = self.full_text.chars().nth(self.chars_shown).unwrap_or(' ');
237                self.chars_shown += 1;
238                match ch {
239                    '.' | '!' | '?' => self.pause_timer = 0.25,
240                    ',' | ';'       => self.pause_timer = 0.1,
241                    _ => {}
242                }
243                if self.chars_shown >= self.full_text.chars().count() {
244                    self.complete = true;
245                    return true;
246                }
247            }
248        }
249        false
250    }
251
252    /// Skip to end immediately.
253    pub fn skip(&mut self) {
254        self.chars_shown = self.full_text.chars().count();
255        self.complete    = true;
256        self.pause_timer = 0.0;
257    }
258
259    /// The currently visible portion of the text.
260    pub fn visible_text(&self) -> &str {
261        if self.chars_shown >= self.full_text.len() {
262            &self.full_text
263        } else {
264            &self.full_text[..self.char_byte_offset(self.chars_shown)]
265        }
266    }
267
268    fn char_byte_offset(&self, n: usize) -> usize {
269        self.full_text.char_indices().nth(n).map(|(i, _)| i).unwrap_or(self.full_text.len())
270    }
271
272    /// Progress [0, 1].
273    pub fn progress(&self) -> f32 {
274        let total = self.full_text.chars().count();
275        if total == 0 { 1.0 } else { self.chars_shown as f32 / total as f32 }
276    }
277}
278
279// ── DialoguePlayer ────────────────────────────────────────────────────────────
280
281/// Drives a DialogueTree.
282pub struct DialoguePlayer {
283    pub tree:         DialogueTree,
284    pub current_node: Option<String>,
285    pub typewriter:   Option<TypewriterState>,
286    pub state:        DialogueState,
287    pub flags:        HashMap<String, bool>,
288    pub history:      Vec<String>,          // node ids visited in order
289    pub chars_per_sec: f32,
290    auto_timer:       Option<f32>,
291    /// Available choices (after typewriter completes on a Choice node).
292    pub choices:      Vec<Choice>,
293    pub selected_choice: usize,
294}
295
296#[derive(Clone, Copy, Debug, PartialEq, Eq)]
297pub enum DialogueState {
298    Idle,
299    /// Typewriter is running.
300    Typing,
301    /// Typewriter done, waiting for advance input.
302    Waiting,
303    /// Showing choices.
304    Choosing,
305    /// Auto-advance timer running.
306    AutoTimer,
307    /// Done.
308    Finished,
309}
310
311impl DialoguePlayer {
312    pub fn new(tree: DialogueTree) -> Self {
313        Self {
314            tree,
315            current_node:    None,
316            typewriter:      None,
317            state:           DialogueState::Idle,
318            flags:           HashMap::new(),
319            history:         Vec::new(),
320            chars_per_sec:   28.0,
321            auto_timer:      None,
322            choices:         Vec::new(),
323            selected_choice: 0,
324        }
325    }
326
327    pub fn with_speed(mut self, chars_per_sec: f32) -> Self {
328        self.chars_per_sec = chars_per_sec;
329        self
330    }
331
332    /// Start the dialogue from its start node.
333    pub fn start(&mut self) {
334        let id = self.tree.start_node.clone();
335        self.goto(&id);
336    }
337
338    /// Jump to a specific node by ID.
339    pub fn goto(&mut self, id: &str) {
340        if let Some(node) = self.tree.get(id).cloned() {
341            self.history.push(id.to_string());
342            self.current_node = Some(id.to_string());
343            self.typewriter   = Some(TypewriterState::new(&node.text, self.chars_per_sec));
344            self.state        = DialogueState::Typing;
345            self.choices.clear();
346            self.selected_choice = 0;
347            self.auto_timer = None;
348        }
349    }
350
351    pub fn is_finished(&self) -> bool { self.state == DialogueState::Finished }
352    pub fn is_typing(&self) -> bool   { self.state == DialogueState::Typing  }
353
354    /// Current visible text (typewriter output).
355    pub fn visible_text(&self) -> &str {
356        self.typewriter.as_ref().map(|tw| tw.visible_text()).unwrap_or("")
357    }
358
359    /// The current node (for speaker/portrait/emotion access).
360    pub fn current(&self) -> Option<&DialogueNode> {
361        self.current_node.as_deref().and_then(|id| self.tree.get(id))
362    }
363
364    /// Advance by dt.  Returns an event if something notable happened.
365    pub fn tick(&mut self, dt: f32) -> Option<DialogueEvent> {
366        match self.state {
367            DialogueState::Typing => {
368                let done = self.typewriter.as_mut().map(|tw| tw.tick(dt)).unwrap_or(false);
369                if done {
370                    return Some(self.finish_typing());
371                }
372            }
373            DialogueState::AutoTimer => {
374                if let Some(ref mut timer) = self.auto_timer {
375                    *timer -= dt;
376                    if *timer <= 0.0 {
377                        self.auto_timer = None;
378                        return self.advance_auto();
379                    }
380                }
381            }
382            _ => {}
383        }
384        None
385    }
386
387    /// The current line has been fully shown: move to the state its `next`
388    /// asks for (wait, choose, or start an auto timer).
389    fn finish_typing(&mut self) -> DialogueEvent {
390        let node = self.current_node.as_deref()
391            .and_then(|id| self.tree.get(id))
392            .cloned();
393        self.state = DialogueState::Waiting;
394        if let Some(node) = node {
395            match &node.next {
396                DialogueNext::End | DialogueNext::Node(_) => {}
397                DialogueNext::Choice(choices) => {
398                    let visible: Vec<Choice> = choices.iter()
399                        .filter(|c| {
400                            c.requires.as_ref()
401                                .map(|f| self.flags.get(f.as_str()).copied().unwrap_or(false))
402                                .unwrap_or(true)
403                        })
404                        .cloned()
405                        .collect();
406                    self.choices = visible;
407                    self.state   = DialogueState::Choosing;
408                    return DialogueEvent::ShowChoices(self.choices.clone());
409                }
410                DialogueNext::Auto { duration, .. } => {
411                    self.auto_timer = Some(*duration);
412                    self.state      = DialogueState::AutoTimer;
413                }
414            }
415        }
416        DialogueEvent::TypewriterDone
417    }
418
419    fn advance_auto(&mut self) -> Option<DialogueEvent> {
420        let next = self.current_node.as_deref()
421            .and_then(|id| self.tree.get(id))
422            .map(|n| n.next.clone())?;
423
424        if let DialogueNext::Auto { then, .. } = next {
425            match *then {
426                DialogueNext::Node(id) => { self.goto(&id); Some(DialogueEvent::NodeChanged(id)) }
427                DialogueNext::End => { self.state = DialogueState::Finished; Some(DialogueEvent::Finished) }
428                _ => None,
429            }
430        } else {
431            None
432        }
433    }
434
435    /// Player pressed "advance" (confirm/space).
436    pub fn advance(&mut self) -> Option<DialogueEvent> {
437        match self.state {
438            DialogueState::Typing => {
439                // Skip typewriter to end. This used to go straight to
440                // Waiting, so skipping the text of a choice node (or an auto
441                // node) never showed the choices and the dialogue stuck.
442                if let Some(tw) = &mut self.typewriter { tw.skip(); }
443                Some(self.finish_typing())
444            }
445            DialogueState::Waiting => {
446                let next = self.current_node.as_deref()
447                    .and_then(|id| self.tree.get(id))
448                    .map(|n| n.next.clone());
449                match next {
450                    Some(DialogueNext::Node(id)) => {
451                        self.goto(&id);
452                        Some(DialogueEvent::NodeChanged(id))
453                    }
454                    Some(DialogueNext::End) | None => {
455                        self.state = DialogueState::Finished;
456                        Some(DialogueEvent::Finished)
457                    }
458                    _ => None,
459                }
460            }
461            DialogueState::Choosing => {
462                if self.choices.is_empty() {
463                    self.state = DialogueState::Finished;
464                    return Some(DialogueEvent::Finished);
465                }
466                let index = self.selected_choice;
467                let choice = self.choices[index].clone();
468                // Apply flags
469                for (flag, val) in &choice.sets {
470                    self.flags.insert(flag.clone(), *val);
471                }
472                let next_id = choice.next.clone();
473                self.goto(&next_id);
474                // Report the index chosen, read before goto() resets the
475                // selection (it reported 0 for every choice).
476                Some(DialogueEvent::ChoiceMade { index, next: next_id })
477            }
478            _ => None,
479        }
480    }
481
482    /// Move selection up/down in choice list.
483    pub fn select_prev(&mut self) {
484        if !self.choices.is_empty() {
485            self.selected_choice = (self.selected_choice + self.choices.len() - 1) % self.choices.len();
486        }
487    }
488
489    pub fn select_next(&mut self) {
490        if !self.choices.is_empty() {
491            self.selected_choice = (self.selected_choice + 1) % self.choices.len();
492        }
493    }
494
495    pub fn select(&mut self, idx: usize) {
496        self.selected_choice = idx.min(self.choices.len().saturating_sub(1));
497    }
498
499    pub fn get_flag(&self, f: &str) -> bool { self.flags.get(f).copied().unwrap_or(false) }
500    pub fn set_flag(&mut self, f: impl Into<String>, v: bool) { self.flags.insert(f.into(), v); }
501}
502
503/// Events emitted by the dialogue player.
504#[derive(Clone, Debug)]
505pub enum DialogueEvent {
506    TypewriterDone,
507    ShowChoices(Vec<Choice>),
508    ChoiceMade { index: usize, next: String },
509    NodeChanged(String),
510    Finished,
511}
512
513// ── Tests ─────────────────────────────────────────────────────────────────────
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    fn make_tree() -> DialogueTree {
520        DialogueTree::new("test")
521            .add_node(NodeBuilder::new("intro", "Hero", "Hello there.").then("end"))
522            .add_node(NodeBuilder::new("end",   "Hero", "Goodbye.").end())
523    }
524
525    #[test]
526    fn typewriter_advances() {
527        let mut tw = TypewriterState::new("Hello", 100.0);
528        tw.tick(0.5);
529        assert!(tw.chars_shown > 0);
530    }
531
532    #[test]
533    fn typewriter_skip() {
534        let mut tw = TypewriterState::new("Long text here", 10.0);
535        tw.skip();
536        assert!(tw.complete);
537        assert_eq!(tw.visible_text(), "Long text here");
538    }
539
540    #[test]
541    fn typewriter_progress() {
542        let mut tw = TypewriterState::new("ABCDE", 100.0);
543        tw.tick(0.02); // 2 chars
544        assert!(tw.progress() > 0.0 && tw.progress() < 1.0);
545    }
546
547    #[test]
548    fn player_starts_and_types() {
549        let tree = make_tree();
550        let mut player = DialoguePlayer::new(tree);
551        player.start();
552        assert_eq!(player.state, DialogueState::Typing);
553    }
554
555    #[test]
556    fn player_skips_typewriter() {
557        let tree = make_tree();
558        let mut player = DialoguePlayer::new(tree);
559        player.start();
560        let ev = player.advance();
561        assert!(matches!(ev, Some(DialogueEvent::TypewriterDone)));
562        assert_eq!(player.state, DialogueState::Waiting);
563    }
564
565    #[test]
566    fn player_advances_node() {
567        let tree = make_tree();
568        let mut player = DialoguePlayer::new(tree);
569        player.start();
570        player.advance(); // skip typewriter
571        let ev = player.advance(); // advance to next node
572        assert!(matches!(ev, Some(DialogueEvent::NodeChanged(_))));
573    }
574
575    #[test]
576    fn player_finishes() {
577        let tree = make_tree();
578        let mut player = DialoguePlayer::new(tree);
579        player.start();
580        player.advance(); // skip typewriter on intro
581        player.advance(); // advance to end node
582        player.advance(); // skip typewriter on end
583        let ev = player.advance(); // finish
584        assert!(matches!(ev, Some(DialogueEvent::Finished)));
585        assert!(player.is_finished());
586    }
587
588    #[test]
589    fn choice_node() {
590        let tree = DialogueTree::new("choices")
591            .add_node(NodeBuilder::new("q", "NPC", "What do you want?").choices(vec![
592                Choice::new("Fight", "fight_node"),
593                Choice::new("Talk",  "talk_node"),
594            ]))
595            .add_node(NodeBuilder::new("fight_node", "NPC", "Let's fight!").end())
596            .add_node(NodeBuilder::new("talk_node",  "NPC", "Let's talk!").end());
597
598        let mut player = DialoguePlayer::new(tree);
599        player.start();
600        player.advance(); // skip typewriter
601        assert_eq!(player.state, DialogueState::Choosing);
602        assert_eq!(player.choices.len(), 2);
603
604        player.select(1); // pick "Talk"
605        let ev = player.advance();
606        assert!(matches!(ev, Some(DialogueEvent::ChoiceMade { index: 1, .. })));
607    }
608
609    #[test]
610    fn emotion_colors_defined() {
611        use DialogueEmotion::*;
612        for em in [Neutral, Happy, Sad, Angry, Surprised, Scared, Suspicious, Mysterious] {
613            let c = em.color();
614            assert!(c[3] > 0.0); // must be non-transparent
615        }
616    }
617}