Skip to main content

proof_engine/timeline/
script.rs

1//! Script DSL for building timelines in a fluent, readable way.
2//!
3//! `CutsceneScript` provides a builder that accumulates actions with an
4//! implicit cursor time — no need to manually specify timestamps for
5//! every action when sequencing a dialogue scene or cutscene.
6
7use glam::Vec3;
8use std::collections::HashMap;
9
10use super::{Timeline, TimelineAction};
11
12// ── ScriptCursor ──────────────────────────────────────────────────────────────
13
14/// Tracks the "pen position" — the time at which the next action will be placed.
15#[derive(Clone, Copy, Debug, Default)]
16pub struct ScriptCursor {
17    pub time: f32,
18}
19
20impl ScriptCursor {
21    pub fn advance(&mut self, dt: f32) -> f32 {
22        self.time += dt;
23        self.time
24    }
25
26    pub fn current(&self) -> f32 { self.time }
27}
28
29// ── CutsceneScript ────────────────────────────────────────────────────────────
30
31/// Fluent builder for Timeline.  Maintains an implicit cursor time.
32///
33/// # Usage
34/// ```text
35/// let tl = CutsceneScript::new("my_scene")
36///     .fade_in(1.0)
37///     .wait(0.5)
38///     .say("Hero", "Time to fight!")
39///     .camera_shake(0.3, 0.6, 20.0)
40///     .wait(1.0)
41///     .fade_out(1.0)
42///     .build();
43/// ```
44pub struct CutsceneScript {
45    name:    String,
46    cursor:  ScriptCursor,
47    entries: Vec<(f32, TimelineAction)>,
48    looping: bool,
49    speed:   f32,
50}
51
52impl CutsceneScript {
53    pub fn new(name: impl Into<String>) -> Self {
54        Self {
55            name:    name.into(),
56            cursor:  ScriptCursor::default(),
57            entries: Vec::new(),
58            looping: false,
59            speed:   1.0,
60        }
61    }
62
63    // ── Cursor control ───────────────────────────────────────────────────────
64
65    /// Place cursor at an absolute time.
66    pub fn at(mut self, time: f32) -> Self {
67        self.cursor.time = time;
68        self
69    }
70
71    /// Advance cursor by dt seconds (explicit wait).
72    pub fn wait(mut self, dt: f32) -> Self {
73        let t = self.cursor.time;
74        self.entries.push((t, TimelineAction::Wait { duration: dt }));
75        self.cursor.advance(dt);
76        self
77    }
78
79    /// Add a label at the current cursor position without advancing.
80    pub fn label(mut self, name: impl Into<String>) -> Self {
81        let t = self.cursor.time;
82        self.entries.push((t, TimelineAction::GotoLabel { label: format!("__label_{}", name.into()) }));
83        self
84    }
85
86    // ── Visual ───────────────────────────────────────────────────────────────
87
88    pub fn fade_in(mut self, duration: f32) -> Self {
89        let t = self.cursor.time;
90        self.entries.push((t, TimelineAction::FadeIn { duration }));
91        self.cursor.advance(duration);
92        self
93    }
94
95    pub fn fade_out(mut self, duration: f32) -> Self {
96        let t = self.cursor.time;
97        self.entries.push((t, TimelineAction::FadeOut {
98            duration,
99            color: [0.0, 0.0, 0.0, 1.0],
100        }));
101        self.cursor.advance(duration);
102        self
103    }
104
105    pub fn fade_to(mut self, color: [f32; 4], duration: f32) -> Self {
106        let t = self.cursor.time;
107        self.entries.push((t, TimelineAction::FadeOut { color, duration }));
108        self.cursor.advance(duration);
109        self
110    }
111
112    pub fn flash(mut self, color: [f32; 4], duration: f32, intensity: f32) -> Self {
113        let t = self.cursor.time;
114        self.entries.push((t, TimelineAction::Flash { color, duration, intensity }));
115        // Flash is instantaneous from script perspective — no cursor advance
116        self
117    }
118
119    pub fn bloom(mut self, enabled: bool, intensity: f32, duration: f32) -> Self {
120        let t = self.cursor.time;
121        self.entries.push((t, TimelineAction::SetBloom { enabled, intensity, duration }));
122        self
123    }
124
125    pub fn chromatic_aberration(mut self, amount: f32, duration: f32) -> Self {
126        let t = self.cursor.time;
127        self.entries.push((t, TimelineAction::SetChromaticAberration { amount, duration }));
128        self
129    }
130
131    pub fn film_grain(mut self, amount: f32) -> Self {
132        let t = self.cursor.time;
133        self.entries.push((t, TimelineAction::SetFilmGrain { amount }));
134        self
135    }
136
137    pub fn vignette(mut self, radius: f32, softness: f32, intensity: f32) -> Self {
138        let t = self.cursor.time;
139        self.entries.push((t, TimelineAction::SetVignette { radius, softness, intensity }));
140        self
141    }
142
143    // ── Camera ───────────────────────────────────────────────────────────────
144
145    pub fn camera_move(mut self, target: Vec3, duration: f32) -> Self {
146        let t = self.cursor.time;
147        self.entries.push((t, TimelineAction::CameraMoveTo { target, duration }));
148        self.cursor.advance(duration);
149        self
150    }
151
152    pub fn camera_look_at(mut self, target: Vec3, duration: f32) -> Self {
153        let t = self.cursor.time;
154        self.entries.push((t, TimelineAction::CameraLookAt { target, duration }));
155        self
156    }
157
158    pub fn camera_shake(mut self, intensity: f32, duration: f32, frequency: f32) -> Self {
159        let t = self.cursor.time;
160        self.entries.push((t, TimelineAction::CameraShake { intensity, duration, frequency }));
161        self
162    }
163
164    pub fn camera_zoom(mut self, zoom: f32, duration: f32) -> Self {
165        let t = self.cursor.time;
166        self.entries.push((t, TimelineAction::CameraZoom { zoom, duration }));
167        self
168    }
169
170    // ── Entities ─────────────────────────────────────────────────────────────
171
172    pub fn spawn(mut self, blueprint: impl Into<String>, position: Vec3) -> Self {
173        let t = self.cursor.time;
174        self.entries.push((t, TimelineAction::SpawnEntity {
175            blueprint: blueprint.into(),
176            position,
177            tag: None,
178        }));
179        self
180    }
181
182    pub fn spawn_tagged(mut self, blueprint: impl Into<String>, position: Vec3, tag: impl Into<String>) -> Self {
183        let t = self.cursor.time;
184        self.entries.push((t, TimelineAction::SpawnEntity {
185            blueprint: blueprint.into(),
186            position,
187            tag: Some(tag.into()),
188        }));
189        self
190    }
191
192    pub fn despawn_tag(mut self, tag: impl Into<String>) -> Self {
193        let t = self.cursor.time;
194        self.entries.push((t, TimelineAction::DespawnTag { tag: tag.into() }));
195        self
196    }
197
198    // ── Audio ─────────────────────────────────────────────────────────────────
199
200    pub fn play_sfx(mut self, name: impl Into<String>, volume: f32) -> Self {
201        let t = self.cursor.time;
202        self.entries.push((t, TimelineAction::PlaySfx {
203            name: name.into(),
204            volume,
205            position: None,
206        }));
207        self
208    }
209
210    pub fn play_sfx_at(mut self, name: impl Into<String>, volume: f32, position: Vec3) -> Self {
211        let t = self.cursor.time;
212        self.entries.push((t, TimelineAction::PlaySfx {
213            name: name.into(),
214            volume,
215            position: Some(position),
216        }));
217        self
218    }
219
220    pub fn music_vibe(mut self, vibe: impl Into<String>) -> Self {
221        let t = self.cursor.time;
222        self.entries.push((t, TimelineAction::SetMusicVibe { vibe: vibe.into() }));
223        self
224    }
225
226    pub fn master_volume(mut self, volume: f32, duration: f32) -> Self {
227        let t = self.cursor.time;
228        self.entries.push((t, TimelineAction::SetMasterVolume { volume, duration }));
229        self
230    }
231
232    // ── UI / Dialogue ─────────────────────────────────────────────────────────
233
234    /// Say a line of dialogue and advance cursor by `duration`.
235    pub fn say(mut self, speaker: impl Into<String>, text: impl Into<String>, duration: f32) -> Self {
236        let t = self.cursor.time;
237        self.entries.push((t, TimelineAction::Dialogue {
238            speaker:  speaker.into(),
239            text:     text.into(),
240            duration: Some(duration),
241        }));
242        self.cursor.advance(duration);
243        self
244    }
245
246    /// Say a line without advancing cursor (fire-and-forget).
247    pub fn say_async(mut self, speaker: impl Into<String>, text: impl Into<String>) -> Self {
248        let t = self.cursor.time;
249        self.entries.push((t, TimelineAction::Dialogue {
250            speaker:  speaker.into(),
251            text:     text.into(),
252            duration: None,
253        }));
254        self
255    }
256
257    pub fn title_card(mut self, text: impl Into<String>, subtitle: impl Into<String>, duration: f32) -> Self {
258        let t = self.cursor.time;
259        self.entries.push((t, TimelineAction::TitleCard {
260            text:     text.into(),
261            subtitle: subtitle.into(),
262            duration,
263        }));
264        self.cursor.advance(duration);
265        self
266    }
267
268    pub fn notify(mut self, text: impl Into<String>, duration: f32) -> Self {
269        let t = self.cursor.time;
270        self.entries.push((t, TimelineAction::Notify { text: text.into(), duration }));
271        self
272    }
273
274    pub fn hide_dialogue(mut self) -> Self {
275        let t = self.cursor.time;
276        self.entries.push((t, TimelineAction::HideDialogue));
277        self
278    }
279
280    // ── Control ───────────────────────────────────────────────────────────────
281
282    pub fn set_flag(mut self, name: impl Into<String>, value: bool) -> Self {
283        let t = self.cursor.time;
284        self.entries.push((t, TimelineAction::SetFlag { name: name.into(), value }));
285        self
286    }
287
288    pub fn callback(mut self, name: impl Into<String>) -> Self {
289        let t = self.cursor.time;
290        self.entries.push((t, TimelineAction::Callback {
291            name: name.into(),
292            args: HashMap::new(),
293        }));
294        self
295    }
296
297    pub fn callback_with_args(mut self, name: impl Into<String>, args: HashMap<String, String>) -> Self {
298        let t = self.cursor.time;
299        self.entries.push((t, TimelineAction::Callback { name: name.into(), args }));
300        self
301    }
302
303    /// Run multiple actions at the same cursor time.
304    pub fn parallel(mut self, actions: Vec<TimelineAction>) -> Self {
305        let t = self.cursor.time;
306        self.entries.push((t, TimelineAction::Parallel { actions }));
307        self
308    }
309
310    pub fn set_looping(mut self) -> Self { self.looping = true; self }
311    pub fn set_speed(mut self, s: f32) -> Self { self.speed = s; self }
312
313    /// Finalize and build the Timeline.
314    pub fn build(mut self) -> Timeline {
315        // Append End marker
316        let end_t = self.cursor.time;
317        self.entries.push((end_t, TimelineAction::End));
318
319        let mut tl = Timeline::new()
320            .named(self.name)
321            .with_speed(self.speed);
322        if self.looping { tl = tl.looping(); }
323
324        for (time, action) in self.entries {
325            tl.at(time, action);
326        }
327        tl
328    }
329}
330
331// ── DialogueSequence — multiple lines in order ────────────────────────────────
332
333/// Builds a dialogue-only sequence with automatic timing.
334pub struct DialogueSequence {
335    script:    CutsceneScript,
336    chars_per_second: f32,
337    pause_after: f32,  // seconds between lines
338}
339
340impl DialogueSequence {
341    pub fn new(name: impl Into<String>) -> Self {
342        Self {
343            script:    CutsceneScript::new(name),
344            chars_per_second: 25.0,
345            pause_after: 0.4,
346        }
347    }
348
349    pub fn with_speed(mut self, chars_per_second: f32) -> Self {
350        self.chars_per_second = chars_per_second;
351        self
352    }
353
354    pub fn with_pause(mut self, pause: f32) -> Self {
355        self.pause_after = pause;
356        self
357    }
358
359    /// Add a line.  Duration is computed from character count.
360    pub fn line(mut self, speaker: impl Into<String>, text: impl Into<String>) -> Self {
361        let t      = text.into();
362        let dur    = (t.chars().count() as f32 / self.chars_per_second).max(1.0);
363        let pause  = self.pause_after;
364        self.script = self.script.say(speaker, t, dur).wait(pause);
365        self
366    }
367
368    pub fn build(self) -> Timeline {
369        self.script.hide_dialogue().build()
370    }
371}
372
373// ── Tests ─────────────────────────────────────────────────────────────────────
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn script_builds_timeline() {
381        let tl = CutsceneScript::new("test")
382            .fade_in(1.0)
383            .wait(0.5)
384            .fade_out(0.5)
385            .build();
386        assert!(!tl.cues.is_empty());
387        assert!(tl.duration() > 0.0);
388    }
389
390    #[test]
391    fn script_cursor_advances() {
392        let tl = CutsceneScript::new("test")
393            .fade_in(1.0)    // cursor → 1.0
394            .wait(2.0)       // cursor → 3.0
395            .fade_out(0.5)   // cursor → 3.5
396            .build();
397        // fade_out should be at t=3.0 and End at t=3.5
398        let fade_out_time = tl.cues.iter().find(|c| {
399            matches!(&c.action, TimelineAction::FadeOut { .. })
400        }).map(|c| c.time).unwrap();
401        assert!((fade_out_time - 3.0).abs() < 0.01);
402    }
403
404    #[test]
405    fn dialogue_sequence() {
406        let tl = DialogueSequence::new("intro_dialogue")
407            .with_speed(30.0)
408            .line("Hero", "Hello there.")
409            .line("Villain", "I've been expecting you.")
410            .build();
411        // Should have dialogue + hide
412        let has_dialogue = tl.cues.iter().any(|c| matches!(&c.action, TimelineAction::Dialogue { .. }));
413        assert!(has_dialogue);
414    }
415
416    #[test]
417    fn script_at_positions_cursor() {
418        let tl = CutsceneScript::new("test")
419            .at(5.0)
420            .flash([1.0,0.0,0.0,1.0], 0.2, 1.0)
421            .build();
422        let flash_time = tl.cues.iter().find(|c| {
423            matches!(&c.action, TimelineAction::Flash { .. })
424        }).map(|c| c.time).unwrap();
425        assert!((flash_time - 5.0).abs() < 0.01);
426    }
427}