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                    let node = self.current_node.as_deref()
371                        .and_then(|id| self.tree.get(id))
372                        .cloned();
373                    if let Some(node) = node {
374                        match &node.next {
375                            DialogueNext::End => {
376                                self.state = DialogueState::Waiting;
377                            }
378                            DialogueNext::Node(_) => {
379                                self.state = DialogueState::Waiting;
380                            }
381                            DialogueNext::Choice(choices) => {
382                                let visible: Vec<Choice> = choices.iter()
383                                    .filter(|c| {
384                                        c.requires.as_ref()
385                                            .map(|f| self.flags.get(f.as_str()).copied().unwrap_or(false))
386                                            .unwrap_or(true)
387                                    })
388                                    .cloned()
389                                    .collect();
390                                self.choices = visible;
391                                self.state   = DialogueState::Choosing;
392                                return Some(DialogueEvent::ShowChoices(self.choices.clone()));
393                            }
394                            DialogueNext::Auto { duration, .. } => {
395                                self.auto_timer = Some(*duration);
396                                self.state      = DialogueState::AutoTimer;
397                            }
398                        }
399                    }
400                    return Some(DialogueEvent::TypewriterDone);
401                }
402            }
403            DialogueState::AutoTimer => {
404                if let Some(ref mut timer) = self.auto_timer {
405                    *timer -= dt;
406                    if *timer <= 0.0 {
407                        self.auto_timer = None;
408                        return self.advance_auto();
409                    }
410                }
411            }
412            _ => {}
413        }
414        None
415    }
416
417    fn advance_auto(&mut self) -> Option<DialogueEvent> {
418        let next = self.current_node.as_deref()
419            .and_then(|id| self.tree.get(id))
420            .map(|n| n.next.clone())?;
421
422        if let DialogueNext::Auto { then, .. } = next {
423            match *then {
424                DialogueNext::Node(id) => { self.goto(&id); Some(DialogueEvent::NodeChanged(id)) }
425                DialogueNext::End => { self.state = DialogueState::Finished; Some(DialogueEvent::Finished) }
426                _ => None,
427            }
428        } else {
429            None
430        }
431    }
432
433    /// Player pressed "advance" (confirm/space).
434    pub fn advance(&mut self) -> Option<DialogueEvent> {
435        match self.state {
436            DialogueState::Typing => {
437                // Skip typewriter to end
438                if let Some(tw) = &mut self.typewriter { tw.skip(); }
439                self.state = DialogueState::Waiting;
440                Some(DialogueEvent::TypewriterDone)
441            }
442            DialogueState::Waiting => {
443                let next = self.current_node.as_deref()
444                    .and_then(|id| self.tree.get(id))
445                    .map(|n| n.next.clone());
446                match next {
447                    Some(DialogueNext::Node(id)) => {
448                        self.goto(&id);
449                        Some(DialogueEvent::NodeChanged(id))
450                    }
451                    Some(DialogueNext::End) | None => {
452                        self.state = DialogueState::Finished;
453                        Some(DialogueEvent::Finished)
454                    }
455                    _ => None,
456                }
457            }
458            DialogueState::Choosing => {
459                if self.choices.is_empty() {
460                    self.state = DialogueState::Finished;
461                    return Some(DialogueEvent::Finished);
462                }
463                let choice = self.choices[self.selected_choice].clone();
464                // Apply flags
465                for (flag, val) in &choice.sets {
466                    self.flags.insert(flag.clone(), *val);
467                }
468                let next_id = choice.next.clone();
469                self.goto(&next_id);
470                Some(DialogueEvent::ChoiceMade { index: self.selected_choice, next: next_id })
471            }
472            _ => None,
473        }
474    }
475
476    /// Move selection up/down in choice list.
477    pub fn select_prev(&mut self) {
478        if !self.choices.is_empty() {
479            self.selected_choice = (self.selected_choice + self.choices.len() - 1) % self.choices.len();
480        }
481    }
482
483    pub fn select_next(&mut self) {
484        if !self.choices.is_empty() {
485            self.selected_choice = (self.selected_choice + 1) % self.choices.len();
486        }
487    }
488
489    pub fn select(&mut self, idx: usize) {
490        self.selected_choice = idx.min(self.choices.len().saturating_sub(1));
491    }
492
493    pub fn get_flag(&self, f: &str) -> bool { self.flags.get(f).copied().unwrap_or(false) }
494    pub fn set_flag(&mut self, f: impl Into<String>, v: bool) { self.flags.insert(f.into(), v); }
495}
496
497/// Events emitted by the dialogue player.
498#[derive(Clone, Debug)]
499pub enum DialogueEvent {
500    TypewriterDone,
501    ShowChoices(Vec<Choice>),
502    ChoiceMade { index: usize, next: String },
503    NodeChanged(String),
504    Finished,
505}
506
507// ── Tests ─────────────────────────────────────────────────────────────────────
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    fn make_tree() -> DialogueTree {
514        DialogueTree::new("test")
515            .add_node(NodeBuilder::new("intro", "Hero", "Hello there.").then("end"))
516            .add_node(NodeBuilder::new("end",   "Hero", "Goodbye.").end())
517    }
518
519    #[test]
520    fn typewriter_advances() {
521        let mut tw = TypewriterState::new("Hello", 100.0);
522        tw.tick(0.5);
523        assert!(tw.chars_shown > 0);
524    }
525
526    #[test]
527    fn typewriter_skip() {
528        let mut tw = TypewriterState::new("Long text here", 10.0);
529        tw.skip();
530        assert!(tw.complete);
531        assert_eq!(tw.visible_text(), "Long text here");
532    }
533
534    #[test]
535    fn typewriter_progress() {
536        let mut tw = TypewriterState::new("ABCDE", 100.0);
537        tw.tick(0.02); // 2 chars
538        assert!(tw.progress() > 0.0 && tw.progress() < 1.0);
539    }
540
541    #[test]
542    fn player_starts_and_types() {
543        let tree = make_tree();
544        let mut player = DialoguePlayer::new(tree);
545        player.start();
546        assert_eq!(player.state, DialogueState::Typing);
547    }
548
549    #[test]
550    fn player_skips_typewriter() {
551        let tree = make_tree();
552        let mut player = DialoguePlayer::new(tree);
553        player.start();
554        let ev = player.advance();
555        assert!(matches!(ev, Some(DialogueEvent::TypewriterDone)));
556        assert_eq!(player.state, DialogueState::Waiting);
557    }
558
559    #[test]
560    fn player_advances_node() {
561        let tree = make_tree();
562        let mut player = DialoguePlayer::new(tree);
563        player.start();
564        player.advance(); // skip typewriter
565        let ev = player.advance(); // advance to next node
566        assert!(matches!(ev, Some(DialogueEvent::NodeChanged(_))));
567    }
568
569    #[test]
570    fn player_finishes() {
571        let tree = make_tree();
572        let mut player = DialoguePlayer::new(tree);
573        player.start();
574        player.advance(); // skip typewriter on intro
575        player.advance(); // advance to end node
576        player.advance(); // skip typewriter on end
577        let ev = player.advance(); // finish
578        assert!(matches!(ev, Some(DialogueEvent::Finished)));
579        assert!(player.is_finished());
580    }
581
582    #[test]
583    fn choice_node() {
584        let tree = DialogueTree::new("choices")
585            .add_node(NodeBuilder::new("q", "NPC", "What do you want?").choices(vec![
586                Choice::new("Fight", "fight_node"),
587                Choice::new("Talk",  "talk_node"),
588            ]))
589            .add_node(NodeBuilder::new("fight_node", "NPC", "Let's fight!").end())
590            .add_node(NodeBuilder::new("talk_node",  "NPC", "Let's talk!").end());
591
592        let mut player = DialoguePlayer::new(tree);
593        player.start();
594        player.advance(); // skip typewriter
595        assert_eq!(player.state, DialogueState::Choosing);
596        assert_eq!(player.choices.len(), 2);
597
598        player.select(1); // pick "Talk"
599        let ev = player.advance();
600        assert!(matches!(ev, Some(DialogueEvent::ChoiceMade { index: 1, .. })));
601    }
602
603    #[test]
604    fn emotion_colors_defined() {
605        use DialogueEmotion::*;
606        for em in [Neutral, Happy, Sad, Angry, Surprised, Scared, Suspicious, Mysterious] {
607            let c = em.color();
608            assert!(c[3] > 0.0); // must be non-transparent
609        }
610    }
611}