1pub mod script;
19pub mod dialogue;
20
21use glam::Vec3;
22use std::collections::HashMap;
23
24#[derive(Clone, Debug)]
28pub enum TimelineAction {
29 CameraMoveTo { target: Vec3, duration: f32 },
32 CameraLookAt { target: Vec3, duration: f32 },
34 CameraShake { intensity: f32, duration: f32, frequency: f32 },
36 CameraZoom { zoom: f32, duration: f32 },
38
39 FadeOut { duration: f32, color: [f32; 4] },
42 FadeIn { duration: f32 },
44 Flash { color: [f32; 4], duration: f32, intensity: f32 },
46 SetBloom { enabled: bool, intensity: f32, duration: f32 },
48 SetChromaticAberration { amount: f32, duration: f32 },
50 SetFilmGrain { amount: f32 },
52 SetVignette { radius: f32, softness: f32, intensity: f32 },
54
55 SpawnEntity { blueprint: String, position: Vec3, tag: Option<String> },
58 DespawnTag { tag: String },
60 PushTag { tag: String, force: Vec3, duration: f32 },
62 KillTag { tag: String },
64
65 PlaySfx { name: String, volume: f32, position: Option<Vec3> },
68 SetMusicVibe { vibe: String },
70 SetMasterVolume { volume: f32, duration: f32 },
72 StopMusic,
74
75 Dialogue { speaker: String, text: String, duration: Option<f32> },
78 TitleCard { text: String, subtitle: String, duration: f32 },
80 Notify { text: String, duration: f32 },
82 HideDialogue,
84
85 Wait { duration: f32 },
88 GotoLabel { label: String },
90 SetFlag { name: String, value: bool },
92 IfFlag { name: String, then: Box<TimelineAction> },
94 Parallel { actions: Vec<TimelineAction> },
96 Callback { name: String, args: HashMap<String, String> },
98 End,
100}
101
102#[derive(Clone, Debug)]
106pub struct CuePoint {
107 pub time: f32,
108 pub action: TimelineAction,
109 pub label: Option<String>,
111 pub repeat: bool,
113 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#[derive(Clone, Debug, Default)]
137pub struct Timeline {
138 pub cues: Vec<CuePoint>,
139 pub name: String,
140 pub looping: bool,
141 pub speed: f32, }
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 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 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 pub fn duration(&self) -> f32 {
185 self.cues.last().map(|c| c.time).unwrap_or(0.0)
186 }
187
188 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 pub fn reset(&mut self) {
195 for cue in &mut self.cues { cue.fired = false; }
196 }
197}
198
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202pub enum PlaybackState {
203 Stopped,
204 Playing,
205 Paused,
206 Finished,
207}
208
209pub struct TimelinePlayer {
213 pub timeline: Timeline,
214 pub time: f32,
215 pub state: PlaybackState,
216 flags: HashMap<String, bool>,
218 active_waits: Vec<ActiveWait>,
220 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 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 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 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 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 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 for action in &fired {
334 if let TimelineAction::SetFlag { name, value } = action {
335 self.flags.insert(name.clone(), *value);
336 }
337 }
338
339 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 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
366pub 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 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 pub fn stop(&mut self) {
397 if let Some(p) = &mut self.active { p.stop(); }
398 self.active = None;
399 }
400
401 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
425pub struct CutsceneTemplates;
429
430impl CutsceneTemplates {
431 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 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 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 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 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#[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 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}