Skip to main content

proof_engine/timeline/
mod.rs

1//! Cutscene and timeline system — scripted sequences of engine events.
2//!
3//! A `Timeline` is a sorted list of `CuePoint`s, each holding a `TimelineAction`
4//! that fires when the playhead reaches the cue's time.  `TimelinePlayer` drives
5//! the playhead and dispatches actions to engine callbacks.
6//!
7//! # Example
8//! ```text
9//! let mut tl = Timeline::new();
10//! tl.at(0.0,  TimelineAction::FadeIn { duration: 1.0 });
11//! tl.at(2.0,  TimelineAction::SpawnEntity { blueprint: "hero".into(), position: Vec3::ZERO });
12//! tl.at(5.0,  TimelineAction::Dialogue { speaker: "Hero".into(), text: "We must fight!".into() });
13//! tl.at(10.0, TimelineAction::FadeOut { duration: 0.5 });
14//! let mut player = TimelinePlayer::new(tl);
15//! // Each frame: player.tick(dt, &mut ctx);
16//! ```
17
18pub mod script;
19pub mod dialogue;
20
21use glam::Vec3;
22use std::collections::HashMap;
23
24// ── TimelineAction ────────────────────────────────────────────────────────────
25
26/// An action that fires at a specific time in the timeline.
27#[derive(Clone, Debug)]
28pub enum TimelineAction {
29    // ── Camera ───────────────────────────────────────────────────────────────
30    /// Move camera to position over duration.
31    CameraMoveTo { target: Vec3, duration: f32 },
32    /// Look at a world position.
33    CameraLookAt { target: Vec3, duration: f32 },
34    /// Shake the camera.
35    CameraShake  { intensity: f32, duration: f32, frequency: f32 },
36    /// Set camera zoom.
37    CameraZoom   { zoom: f32, duration: f32 },
38
39    // ── Visual ───────────────────────────────────────────────────────────────
40    /// Fade to black.
41    FadeOut { duration: f32, color: [f32; 4] },
42    /// Fade in from black.
43    FadeIn  { duration: f32 },
44    /// Flash screen.
45    Flash   { color: [f32; 4], duration: f32, intensity: f32 },
46    /// Enable/disable bloom.
47    SetBloom { enabled: bool, intensity: f32, duration: f32 },
48    /// Set chromatic aberration.
49    SetChromaticAberration { amount: f32, duration: f32 },
50    /// Enable film grain.
51    SetFilmGrain { amount: f32 },
52    /// Set vignette.
53    SetVignette { radius: f32, softness: f32, intensity: f32 },
54
55    // ── Entities ─────────────────────────────────────────────────────────────
56    /// Spawn an entity blueprint at a position.
57    SpawnEntity { blueprint: String, position: Vec3, tag: Option<String> },
58    /// Despawn entities by tag.
59    DespawnTag  { tag: String },
60    /// Apply a force to entities with a tag.
61    PushTag     { tag: String, force: Vec3, duration: f32 },
62    /// Kill all entities with a tag.
63    KillTag     { tag: String },
64
65    // ── Audio ─────────────────────────────────────────────────────────────────
66    /// Play a named sound effect.
67    PlaySfx { name: String, volume: f32, position: Option<Vec3> },
68    /// Set music vibe.
69    SetMusicVibe { vibe: String },
70    /// Set master volume.
71    SetMasterVolume { volume: f32, duration: f32 },
72    /// Stop all music.
73    StopMusic,
74
75    // ── UI ────────────────────────────────────────────────────────────────────
76    /// Show a dialogue line.
77    Dialogue { speaker: String, text: String, duration: Option<f32> },
78    /// Show a title card (big text center screen).
79    TitleCard { text: String, subtitle: String, duration: f32 },
80    /// Show a HUD notification.
81    Notify  { text: String, duration: f32 },
82    /// Hide all dialogue.
83    HideDialogue,
84
85    // ── Control ───────────────────────────────────────────────────────────────
86    /// Wait (no-op — used as a marker for script pauses).
87    Wait { duration: f32 },
88    /// Jump playhead to a named label.
89    GotoLabel { label: String },
90    /// Set a flag variable.
91    SetFlag { name: String, value: bool },
92    /// Conditional: only fire `then` if flag is true.
93    IfFlag  { name: String, then: Box<TimelineAction> },
94    /// Fire multiple actions simultaneously.
95    Parallel { actions: Vec<TimelineAction> },
96    /// Custom callback by name (engine resolves at runtime).
97    Callback { name: String, args: HashMap<String, String> },
98    /// End the timeline.
99    End,
100}
101
102// ── CuePoint ──────────────────────────────────────────────────────────────────
103
104/// A timed entry in the timeline.
105#[derive(Clone, Debug)]
106pub struct CuePoint {
107    pub time:   f32,
108    pub action: TimelineAction,
109    /// Optional label for GotoLabel.
110    pub label:  Option<String>,
111    /// Whether this cue can fire multiple times (for looping timelines).
112    pub repeat: bool,
113    /// Has been fired this playthrough.
114    pub fired:  bool,
115}
116
117impl CuePoint {
118    pub fn new(time: f32, action: TimelineAction) -> Self {
119        Self { time, action, label: None, repeat: false, fired: false }
120    }
121
122    pub fn with_label(mut self, label: impl Into<String>) -> Self {
123        self.label = Some(label.into());
124        self
125    }
126
127    pub fn repeating(mut self) -> Self {
128        self.repeat = true;
129        self
130    }
131}
132
133// ── Timeline ──────────────────────────────────────────────────────────────────
134
135/// An ordered sequence of timed cue points.
136#[derive(Clone, Debug, Default)]
137pub struct Timeline {
138    pub cues:   Vec<CuePoint>,
139    pub name:   String,
140    pub looping: bool,
141    pub speed:   f32,  // playback speed multiplier
142}
143
144impl Timeline {
145    pub fn new() -> Self {
146        Self {
147            cues:    Vec::new(),
148            name:    String::new(),
149            looping: false,
150            speed:   1.0,
151        }
152    }
153
154    pub fn named(mut self, name: impl Into<String>) -> Self {
155        self.name = name.into();
156        self
157    }
158
159    pub fn looping(mut self) -> Self {
160        self.looping = true;
161        self
162    }
163
164    pub fn with_speed(mut self, s: f32) -> Self {
165        self.speed = s;
166        self
167    }
168
169    /// Add a cue at a given time (will be sorted on insertion).
170    pub fn at(&mut self, time: f32, action: TimelineAction) -> &mut Self {
171        let idx = self.cues.partition_point(|c| c.time <= time);
172        self.cues.insert(idx, CuePoint::new(time, action));
173        self
174    }
175
176    /// Add a labeled cue.
177    pub fn at_labeled(&mut self, time: f32, label: impl Into<String>, action: TimelineAction) -> &mut Self {
178        let idx = self.cues.partition_point(|c| c.time <= time);
179        self.cues.insert(idx, CuePoint::new(time, action).with_label(label));
180        self
181    }
182
183    /// Total duration (time of last cue).
184    pub fn duration(&self) -> f32 {
185        self.cues.last().map(|c| c.time).unwrap_or(0.0)
186    }
187
188    /// Find the time of a label.
189    pub fn label_time(&self, label: &str) -> Option<f32> {
190        self.cues.iter().find(|c| c.label.as_deref() == Some(label)).map(|c| c.time)
191    }
192
193    /// Reset all fired flags.
194    pub fn reset(&mut self) {
195        for cue in &mut self.cues { cue.fired = false; }
196    }
197}
198
199// ── PlaybackState ─────────────────────────────────────────────────────────────
200
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202pub enum PlaybackState {
203    Stopped,
204    Playing,
205    Paused,
206    Finished,
207}
208
209// ── TimelinePlayer ────────────────────────────────────────────────────────────
210
211/// Drives a Timeline forward in time and dispatches actions.
212pub struct TimelinePlayer {
213    pub timeline: Timeline,
214    pub time:     f32,
215    pub state:    PlaybackState,
216    /// Flags set by SetFlag actions.
217    flags:        HashMap<String, bool>,
218    /// Pending callbacks waiting for duration to elapse.
219    active_waits: Vec<ActiveWait>,
220    /// Callbacks registered by name for Callback actions.
221    callbacks:    HashMap<String, Box<dyn Fn(&HashMap<String, String>) + Send + Sync>>,
222}
223
224struct ActiveWait {
225    pub remaining: f32,
226    pub on_done:   Box<dyn FnOnce() + Send>,
227}
228
229impl TimelinePlayer {
230    pub fn new(timeline: Timeline) -> Self {
231        Self {
232            timeline,
233            time:         0.0,
234            state:        PlaybackState::Stopped,
235            flags:        HashMap::new(),
236            active_waits: Vec::new(),
237            callbacks:    HashMap::new(),
238        }
239    }
240
241    pub fn play(&mut self) {
242        self.state = PlaybackState::Playing;
243    }
244
245    pub fn pause(&mut self) {
246        if self.state == PlaybackState::Playing {
247            self.state = PlaybackState::Paused;
248        }
249    }
250
251    pub fn resume(&mut self) {
252        if self.state == PlaybackState::Paused {
253            self.state = PlaybackState::Playing;
254        }
255    }
256
257    pub fn stop(&mut self) {
258        self.state = PlaybackState::Stopped;
259        self.time  = 0.0;
260        self.timeline.reset();
261    }
262
263    pub fn seek(&mut self, time: f32) {
264        self.time = time.clamp(0.0, self.timeline.duration());
265        // Re-arm all cues at or after the seek point
266        for cue in &mut self.timeline.cues {
267            if cue.time >= self.time { cue.fired = false; }
268        }
269    }
270
271    pub fn is_playing(&self) -> bool { self.state == PlaybackState::Playing }
272    pub fn is_finished(&self) -> bool { self.state == PlaybackState::Finished }
273
274    /// Register a named callback.
275    pub fn register_callback(
276        &mut self,
277        name: impl Into<String>,
278        f: impl Fn(&HashMap<String, String>) + Send + Sync + 'static,
279    ) {
280        self.callbacks.insert(name.into(), Box::new(f));
281    }
282
283    /// Advance the timeline by `dt` seconds and fire pending cues.
284    /// Returns a list of actions that fired this tick.
285    pub fn tick(&mut self, dt: f32) -> Vec<TimelineAction> {
286        if self.state != PlaybackState::Playing { return Vec::new(); }
287
288        let effective_dt = dt * self.timeline.speed;
289        self.time += effective_dt;
290
291        // Tick active waits
292        self.active_waits.retain_mut(|w| {
293            w.remaining -= effective_dt;
294            w.remaining > 0.0
295        });
296
297        let duration = self.timeline.duration();
298        if self.time > duration {
299            if self.timeline.looping {
300                self.time -= duration;
301                self.timeline.reset();
302            } else {
303                self.time  = duration;
304                self.state = PlaybackState::Finished;
305            }
306        }
307
308        let current_time = self.time;
309        let mut fired = Vec::new();
310
311        for cue in &mut self.timeline.cues {
312            if cue.fired { continue; }
313            if cue.time > current_time { break; }
314
315            cue.fired = true;
316            fired.push(cue.action.clone());
317        }
318
319        // Process GotoLabel actions
320        let mut goto: Option<String> = None;
321        for action in &fired {
322            if let TimelineAction::GotoLabel { label } = action {
323                goto = Some(label.clone());
324            }
325        }
326        if let Some(label) = goto {
327            if let Some(t) = self.timeline.label_time(&label) {
328                self.seek(t);
329            }
330        }
331
332        // Process SetFlag actions
333        for action in &fired {
334            if let TimelineAction::SetFlag { name, value } = action {
335                self.flags.insert(name.clone(), *value);
336            }
337        }
338
339        // Process Callback actions
340        for action in &fired {
341            if let TimelineAction::Callback { name, args } = action {
342                if let Some(cb) = self.callbacks.get(name.as_str()) {
343                    cb(args);
344                }
345            }
346        }
347
348        fired
349    }
350
351    pub fn get_flag(&self, name: &str) -> bool {
352        self.flags.get(name).copied().unwrap_or(false)
353    }
354
355    pub fn set_flag(&mut self, name: impl Into<String>, value: bool) {
356        self.flags.insert(name.into(), value);
357    }
358
359    /// Progress [0, 1] through the timeline.
360    pub fn progress(&self) -> f32 {
361        let d = self.timeline.duration();
362        if d < f32::EPSILON { 1.0 } else { (self.time / d).clamp(0.0, 1.0) }
363    }
364}
365
366// ── CutsceneLibrary ───────────────────────────────────────────────────────────
367
368/// Manages a collection of named timelines.
369pub struct CutsceneLibrary {
370    timelines: HashMap<String, Timeline>,
371    pub active: Option<TimelinePlayer>,
372}
373
374impl CutsceneLibrary {
375    pub fn new() -> Self {
376        Self { timelines: HashMap::new(), active: None }
377    }
378
379    pub fn register(&mut self, timeline: Timeline) {
380        self.timelines.insert(timeline.name.clone(), timeline);
381    }
382
383    /// Start playing a named cutscene. Returns false if not found.
384    pub fn play(&mut self, name: &str) -> bool {
385        if let Some(tl) = self.timelines.get(name).cloned() {
386            let mut player = TimelinePlayer::new(tl);
387            player.play();
388            self.active = Some(player);
389            true
390        } else {
391            false
392        }
393    }
394
395    /// Stop the active cutscene.
396    pub fn stop(&mut self) {
397        if let Some(p) = &mut self.active { p.stop(); }
398        self.active = None;
399    }
400
401    /// Tick the active player, returning fired actions.
402    pub fn tick(&mut self, dt: f32) -> Vec<TimelineAction> {
403        if let Some(player) = &mut self.active {
404            let actions = player.tick(dt);
405            if player.is_finished() { self.active = None; }
406            actions
407        } else {
408            Vec::new()
409        }
410    }
411
412    pub fn is_playing(&self) -> bool {
413        self.active.as_ref().map(|p| p.is_playing()).unwrap_or(false)
414    }
415
416    pub fn names(&self) -> Vec<&str> {
417        self.timelines.keys().map(|s| s.as_str()).collect()
418    }
419}
420
421impl Default for CutsceneLibrary {
422    fn default() -> Self { Self::new() }
423}
424
425// ── Built-in timeline builders ────────────────────────────────────────────────
426
427/// Factory methods for common cutscene patterns.
428pub struct CutsceneTemplates;
429
430impl CutsceneTemplates {
431    /// Simple intro: fade in, wait, show title, fade out.
432    pub fn intro(title: &str, subtitle: &str, duration: f32) -> Timeline {
433        let mut tl = Timeline::new().named("intro");
434        tl.at(0.0,        TimelineAction::FadeOut { duration: 0.0, color: [0.0,0.0,0.0,1.0] });
435        tl.at(0.5,        TimelineAction::FadeIn  { duration: 1.5 });
436        tl.at(2.0,        TimelineAction::TitleCard {
437            text:     title.into(),
438            subtitle: subtitle.into(),
439            duration,
440        });
441        tl.at(2.0 + duration, TimelineAction::FadeOut { duration: 1.0, color: [0.0,0.0,0.0,1.0] });
442        tl.at(3.0 + duration, TimelineAction::End);
443        tl
444    }
445
446    /// Boss encounter intro: screen flash, camera shake, music sting.
447    pub fn boss_intro(boss_name: &str, position: Vec3) -> Timeline {
448        let mut tl = Timeline::new().named("boss_intro");
449        tl.at(0.0, TimelineAction::SetMusicVibe { vibe: "boss".into() });
450        tl.at(0.0, TimelineAction::CameraShake  { intensity: 0.3, duration: 0.5, frequency: 20.0 });
451        tl.at(0.0, TimelineAction::Flash        { color: [1.0,0.2,0.0,1.0], duration: 0.3, intensity: 2.0 });
452        tl.at(0.5, TimelineAction::SpawnEntity  { blueprint: boss_name.into(), position, tag: Some("boss".into()) });
453        tl.at(1.0, TimelineAction::CameraLookAt { target: position, duration: 0.5 });
454        tl.at(1.5, TimelineAction::TitleCard {
455            text:     boss_name.into(),
456            subtitle: "BOSS ENCOUNTER".into(),
457            duration: 2.5,
458        });
459        tl.at(4.0, TimelineAction::SetBloom    { enabled: true, intensity: 1.5, duration: 0.3 });
460        tl.at(4.5, TimelineAction::End);
461        tl
462    }
463
464    /// Victory sequence: music swell, sparkles, score tally.
465    pub fn victory() -> Timeline {
466        let mut tl = Timeline::new().named("victory");
467        tl.at(0.0, TimelineAction::SetMusicVibe { vibe: "victory".into() });
468        tl.at(0.0, TimelineAction::SetBloom     { enabled: true, intensity: 2.0, duration: 0.5 });
469        tl.at(0.3, TimelineAction::Flash        { color: [1.0,1.0,0.5,1.0], duration: 0.4, intensity: 1.5 });
470        tl.at(0.5, TimelineAction::TitleCard    {
471            text: "VICTORY".into(), subtitle: "".into(), duration: 3.0,
472        });
473        tl.at(3.8, TimelineAction::FadeOut      { duration: 1.2, color: [0.0,0.0,0.0,1.0] });
474        tl.at(5.0, TimelineAction::End);
475        tl
476    }
477
478    /// Death / game-over sequence.
479    pub fn death() -> Timeline {
480        let mut tl = Timeline::new().named("death");
481        tl.at(0.0, TimelineAction::CameraShake         { intensity: 0.5, duration: 0.8, frequency: 15.0 });
482        tl.at(0.0, TimelineAction::SetMusicVibe         { vibe: "silence".into() });
483        tl.at(0.0, TimelineAction::SetChromaticAberration { amount: 0.04, duration: 0.1 });
484        tl.at(0.1, TimelineAction::SetFilmGrain         { amount: 0.3 });
485        tl.at(0.5, TimelineAction::SetMasterVolume      { volume: 0.0, duration: 0.5 });
486        tl.at(0.6, TimelineAction::FadeOut              { duration: 1.5, color: [0.4,0.0,0.0,1.0] });
487        tl.at(2.0, TimelineAction::TitleCard            {
488            text: "YOU DIED".into(), subtitle: "".into(), duration: 2.5,
489        });
490        tl.at(4.5, TimelineAction::End);
491        tl
492    }
493
494    /// Simple level transition.
495    pub fn level_transition(level_name: &str) -> Timeline {
496        let mut tl = Timeline::new().named("level_transition");
497        tl.at(0.0, TimelineAction::FadeOut { duration: 0.5, color: [0.0,0.0,0.0,1.0] });
498        tl.at(0.5, TimelineAction::DespawnTag { tag: "level_geometry".into() });
499        tl.at(1.0, TimelineAction::Callback { name: "load_level".into(), args: {
500            let mut m = HashMap::new(); m.insert("name".into(), level_name.into()); m
501        }});
502        tl.at(1.5, TimelineAction::FadeIn  { duration: 0.8 });
503        tl.at(2.3, TimelineAction::TitleCard {
504            text:     level_name.into(),
505            subtitle: "".into(),
506            duration: 1.5,
507        });
508        tl.at(3.8, TimelineAction::End);
509        tl
510    }
511}
512
513// ── Tests ─────────────────────────────────────────────────────────────────────
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    #[test]
520    fn timeline_fires_in_order() {
521        let mut tl = Timeline::new();
522        tl.at(0.5, TimelineAction::Wait { duration: 0.0 });
523        tl.at(1.0, TimelineAction::End);
524        tl.at(0.1, TimelineAction::Flash { color: [1.0,0.0,0.0,1.0], duration: 0.1, intensity: 1.0 });
525
526        // Check sorted order
527        assert!(tl.cues[0].time <= tl.cues[1].time);
528        assert!(tl.cues[1].time <= tl.cues[2].time);
529    }
530
531    #[test]
532    fn player_fires_actions() {
533        let mut tl = Timeline::new();
534        tl.at(0.1, TimelineAction::Flash { color: [1.0,0.0,0.0,1.0], duration: 0.1, intensity: 1.0 });
535        tl.at(0.5, TimelineAction::End);
536
537        let mut player = TimelinePlayer::new(tl);
538        player.play();
539
540        let actions = player.tick(0.2);
541        assert!(!actions.is_empty(), "Expected Flash to fire");
542    }
543
544    #[test]
545    fn player_does_not_fire_future_cues() {
546        let mut tl = Timeline::new();
547        tl.at(5.0, TimelineAction::End);
548        let mut player = TimelinePlayer::new(tl);
549        player.play();
550        let actions = player.tick(0.1);
551        assert!(actions.is_empty());
552    }
553
554    #[test]
555    fn player_finishes() {
556        let mut tl = Timeline::new();
557        tl.at(0.1, TimelineAction::End);
558        let mut player = TimelinePlayer::new(tl);
559        player.play();
560        player.tick(1.0);
561        assert!(player.is_finished());
562    }
563
564    #[test]
565    fn flag_set_and_get() {
566        let mut player = TimelinePlayer::new(Timeline::new());
567        player.set_flag("combat_started", true);
568        assert!(player.get_flag("combat_started"));
569        assert!(!player.get_flag("other_flag"));
570    }
571
572    #[test]
573    fn progress_zero_at_start() {
574        let mut tl = Timeline::new();
575        tl.at(10.0, TimelineAction::End);
576        let player = TimelinePlayer::new(tl);
577        assert!((player.progress() - 0.0).abs() < 1e-5);
578    }
579
580    #[test]
581    fn library_play_unknown() {
582        let mut lib = CutsceneLibrary::new();
583        assert!(!lib.play("nonexistent"));
584    }
585
586    #[test]
587    fn library_play_registered() {
588        let mut lib = CutsceneLibrary::new();
589        let tl = CutsceneTemplates::victory();
590        lib.register(tl);
591        assert!(lib.play("victory"));
592        assert!(lib.is_playing());
593    }
594
595    #[test]
596    fn template_intro_has_cues() {
597        let tl = CutsceneTemplates::intro("Test", "Subtitle", 3.0);
598        assert!(!tl.cues.is_empty());
599        assert!(tl.duration() > 0.0);
600    }
601}