Skip to main content

proof_engine/game/
transitions.rs

1//! Screen Transition Manager — handles visual transitions between game screens.
2//!
3//! Supports multiple transition types: FadeBlack, Dissolve, Slide, ZoomIn, ChaosWipe.
4//! Each transition captures the outgoing screen state, tweens a visual effect, and
5//! reveals the incoming screen.
6//!
7//! # Usage
8//!
9//! ```rust,no_run
10//! use proof_engine::game::transitions::*;
11//!
12//! # let (dt, screen_width, screen_height) = (1.0 / 60.0, 1280.0, 720.0);
13//! let mut tm = TransitionManager::new();
14//! tm.start(TransitionType::FadeBlack {
15//!     out_time: 0.2, hold_time: 0.05, in_time: 0.2,
16//! });
17//! // Each frame:
18//! tm.tick(dt);
19//! if tm.should_swap_state() {
20//!     // swap game state here
21//!     tm.acknowledge_swap();
22//! }
23//! // Render the transition overlay:
24//! let overlay = tm.render_overlay(screen_width, screen_height);
25//! ```
26
27use glam::{Vec2, Vec3, Vec4};
28use std::collections::HashMap;
29
30// ── Transition State ────────────────────────────────────────────────────────
31
32/// Current phase of a screen transition.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum TransitionState {
35    /// No transition active — normal rendering.
36    None,
37    /// Old screen is fading/wiping out.
38    FadingOut,
39    /// Black/blank screen while game state swaps.
40    Hold,
41    /// New screen is fading/wiping in.
42    FadingIn,
43    /// Transition just completed this frame.
44    Completed,
45}
46
47// ── Transition Type ─────────────────────────────────────────────────────────
48
49/// Visual effect used for the screen transition.
50#[derive(Debug, Clone)]
51pub enum TransitionType {
52    /// Classic fade to/from black.
53    FadeBlack {
54        out_time: f32,
55        hold_time: f32,
56        in_time: f32,
57    },
58    /// Noise-based dissolve between screens.
59    Dissolve {
60        duration: f32,
61    },
62    /// Slide the old screen out to the left, new screen in from the right.
63    SlideLeft {
64        duration: f32,
65    },
66    /// Slide the old screen out to the right, new screen in from the left.
67    SlideRight {
68        duration: f32,
69    },
70    /// Zoom into center, then new screen appears.
71    ZoomIn {
72        duration: f32,
73    },
74    /// Chaos field particles sweep across the screen as a wave.
75    ChaosWipe {
76        duration: f32,
77    },
78    /// No visual — instant cut.
79    Cut,
80}
81
82impl TransitionType {
83    /// Total duration of the transition in seconds.
84    pub fn total_duration(&self) -> f32 {
85        match self {
86            Self::FadeBlack { out_time, hold_time, in_time } => out_time + hold_time + in_time,
87            Self::Dissolve { duration } => *duration,
88            Self::SlideLeft { duration } => *duration,
89            Self::SlideRight { duration } => *duration,
90            Self::ZoomIn { duration } => *duration,
91            Self::ChaosWipe { duration } => *duration,
92            Self::Cut => 0.0,
93        }
94    }
95
96    /// Normalized time at which the state swap should occur (0.0 to 1.0).
97    pub fn swap_point(&self) -> f32 {
98        match self {
99            Self::FadeBlack { out_time, hold_time, in_time } => {
100                let total = out_time + hold_time + in_time;
101                if total < 1e-6 { return 0.5; }
102                (out_time + hold_time * 0.5) / total
103            }
104            Self::Dissolve { .. } => 0.5,
105            Self::SlideLeft { .. } => 0.5,
106            Self::SlideRight { .. } => 0.5,
107            Self::ZoomIn { .. } => 0.5,
108            Self::ChaosWipe { .. } => 0.5,
109            Self::Cut => 0.0,
110        }
111    }
112}
113
114// ── Overlay quad ────────────────────────────────────────────────────────────
115
116/// A full-screen overlay quad produced by the transition for rendering.
117#[derive(Debug, Clone)]
118pub struct TransitionOverlay {
119    /// RGBA color of the overlay. Alpha controls visibility.
120    pub color: Vec4,
121    /// 0.0 = no effect, 1.0 = fully covering screen.
122    pub coverage: f32,
123    /// For dissolve: noise threshold (pixels below this show new screen).
124    pub dissolve_threshold: f32,
125    /// For slide: horizontal offset in normalized screen coords (-1 to 1).
126    pub slide_offset: f32,
127    /// For zoom: scale factor (1.0 = normal, >1.0 = zoomed in).
128    pub zoom_scale: f32,
129    /// For chaos wipe: the wave front position (0.0 = left, 1.0 = right).
130    pub wipe_front: f32,
131    /// Number of chaos particles to spawn for ChaosWipe (0 if not applicable).
132    pub chaos_particle_count: u32,
133    /// The active transition type (for the renderer to select the right shader/technique).
134    pub effect: TransitionEffect,
135}
136
137/// Which visual effect the renderer should use.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum TransitionEffect {
140    None,
141    FadeBlack,
142    Dissolve,
143    SlideLeft,
144    SlideRight,
145    ZoomIn,
146    ChaosWipe,
147}
148
149impl Default for TransitionOverlay {
150    fn default() -> Self {
151        Self {
152            color: Vec4::new(0.0, 0.0, 0.0, 0.0),
153            coverage: 0.0,
154            dissolve_threshold: 0.0,
155            slide_offset: 0.0,
156            zoom_scale: 1.0,
157            wipe_front: 0.0,
158            chaos_particle_count: 0,
159            effect: TransitionEffect::None,
160        }
161    }
162}
163
164// ── Screenshot (framebuffer capture placeholder) ────────────────────────────
165
166/// Captured framebuffer of the previous screen for cross-fade transitions.
167///
168/// In a real implementation this would hold a GL texture handle. Here we store
169/// the metadata; the actual capture is done by the render pipeline.
170#[derive(Debug, Clone)]
171pub struct Screenshot {
172    pub width: u32,
173    pub height: u32,
174    pub captured_at: f32,  // scene time when captured
175    /// GL texture handle (if captured). None if not yet captured.
176    pub texture_id: Option<u32>,
177}
178
179impl Screenshot {
180    pub fn placeholder(w: u32, h: u32, time: f32) -> Self {
181        Self { width: w, height: h, captured_at: time, texture_id: None }
182    }
183}
184
185// ── Easing functions ────────────────────────────────────────────────────────
186
187/// Easing functions for transition curves.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum TransitionEasing {
190    Linear,
191    EaseIn,
192    EaseOut,
193    EaseInOut,
194    SmoothStep,
195}
196
197impl TransitionEasing {
198    pub fn apply(&self, t: f32) -> f32 {
199        let t = t.clamp(0.0, 1.0);
200        match self {
201            Self::Linear => t,
202            Self::EaseIn => t * t,
203            Self::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
204            Self::EaseInOut => {
205                if t < 0.5 {
206                    2.0 * t * t
207                } else {
208                    1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
209                }
210            }
211            Self::SmoothStep => t * t * (3.0 - 2.0 * t),
212        }
213    }
214}
215
216// ── Transition Manager ──────────────────────────────────────────────────────
217
218/// Manages screen-to-screen visual transitions.
219///
220/// The game loop should:
221/// 1. Call `start()` to begin a transition
222/// 2. Call `tick(dt)` each frame
223/// 3. Check `should_swap_state()` to know when to swap game state
224/// 4. Call `acknowledge_swap()` after swapping
225/// 5. Call `render_overlay()` to get the overlay for rendering
226/// 6. Check `is_done()` to know when the transition is complete
227pub struct TransitionManager {
228    state: TransitionState,
229    progress: f32,
230    elapsed: f32,
231    transition_type: TransitionType,
232    easing: TransitionEasing,
233    from_screen: Option<Screenshot>,
234    swap_pending: bool,
235    swap_acknowledged: bool,
236    /// Callback tag for identifying which transition this is.
237    pub tag: String,
238    /// Per-frame stats.
239    pub stats: TransitionStats,
240}
241
242/// Per-frame statistics.
243#[derive(Debug, Clone, Default)]
244pub struct TransitionStats {
245    pub state: &'static str,
246    pub progress: f32,
247    pub elapsed: f32,
248    pub total_duration: f32,
249}
250
251impl TransitionManager {
252    pub fn new() -> Self {
253        Self {
254            state: TransitionState::None,
255            progress: 0.0,
256            elapsed: 0.0,
257            transition_type: TransitionType::Cut,
258            easing: TransitionEasing::SmoothStep,
259            from_screen: None,
260            swap_pending: false,
261            swap_acknowledged: false,
262            tag: String::new(),
263            stats: TransitionStats::default(),
264        }
265    }
266
267    /// Start a new transition. Any in-progress transition is immediately replaced.
268    pub fn start(&mut self, transition: TransitionType) {
269        self.transition_type = transition;
270        self.state = if self.transition_type.total_duration() < 1e-6 {
271            // Instant cut
272            self.swap_pending = true;
273            TransitionState::Hold
274        } else {
275            TransitionState::FadingOut
276        };
277        self.progress = 0.0;
278        self.elapsed = 0.0;
279        self.swap_pending = false;
280        self.swap_acknowledged = false;
281    }
282
283    /// Start a transition with a tag for identification.
284    pub fn start_tagged(&mut self, transition: TransitionType, tag: impl Into<String>) {
285        self.tag = tag.into();
286        self.start(transition);
287    }
288
289    /// Start a transition with custom easing.
290    pub fn start_with_easing(&mut self, transition: TransitionType, easing: TransitionEasing) {
291        self.easing = easing;
292        self.start(transition);
293    }
294
295    /// Capture the current screen for cross-fade transitions.
296    pub fn capture_screen(&mut self, width: u32, height: u32, time: f32) {
297        self.from_screen = Some(Screenshot::placeholder(width, height, time));
298    }
299
300    /// Advance the transition by `dt` seconds.
301    pub fn tick(&mut self, dt: f32) {
302        if self.state == TransitionState::None || self.state == TransitionState::Completed {
303            return;
304        }
305
306        self.elapsed += dt;
307        let total = self.transition_type.total_duration();
308
309        if total < 1e-6 {
310            // Instant
311            self.progress = 1.0;
312            self.state = TransitionState::Completed;
313            self.swap_pending = true;
314            self.update_stats();
315            return;
316        }
317
318        self.progress = (self.elapsed / total).clamp(0.0, 1.0);
319        let swap_point = self.transition_type.swap_point();
320
321        // Determine phase
322        match &self.transition_type {
323            TransitionType::FadeBlack { out_time, hold_time, in_time } => {
324                let total = out_time + hold_time + in_time;
325                if self.elapsed < *out_time {
326                    self.state = TransitionState::FadingOut;
327                } else if self.elapsed < out_time + hold_time {
328                    self.state = TransitionState::Hold;
329                    if !self.swap_pending && !self.swap_acknowledged {
330                        self.swap_pending = true;
331                    }
332                } else if self.elapsed < total {
333                    self.state = TransitionState::FadingIn;
334                } else {
335                    self.state = TransitionState::Completed;
336                }
337            }
338            _ => {
339                // For non-FadeBlack: FadingOut until swap_point, FadingIn after
340                if self.progress < swap_point {
341                    self.state = TransitionState::FadingOut;
342                } else if !self.swap_acknowledged {
343                    self.state = TransitionState::Hold;
344                    if !self.swap_pending {
345                        self.swap_pending = true;
346                    }
347                } else if self.progress < 1.0 {
348                    self.state = TransitionState::FadingIn;
349                } else {
350                    self.state = TransitionState::Completed;
351                }
352            }
353        }
354
355        if self.elapsed >= total {
356            self.state = TransitionState::Completed;
357        }
358
359        self.update_stats();
360    }
361
362    fn update_stats(&self) {
363        // Stats are read by the caller — we just set the public field
364    }
365
366    /// Whether the game should swap state now.
367    pub fn should_swap_state(&self) -> bool {
368        self.swap_pending && !self.swap_acknowledged
369    }
370
371    /// Call after swapping game state to continue the fade-in phase.
372    pub fn acknowledge_swap(&mut self) {
373        self.swap_acknowledged = true;
374        self.swap_pending = false;
375    }
376
377    /// Whether the transition has fully completed.
378    pub fn is_done(&self) -> bool {
379        self.state == TransitionState::None || self.state == TransitionState::Completed
380    }
381
382    /// Whether any transition is currently active (not None and not Completed).
383    pub fn is_active(&self) -> bool {
384        !self.is_done()
385    }
386
387    /// Current transition state.
388    pub fn state(&self) -> TransitionState { self.state }
389
390    /// Current progress (0.0 to 1.0).
391    pub fn progress(&self) -> f32 { self.progress }
392
393    /// Reset to no transition.
394    pub fn clear(&mut self) {
395        self.state = TransitionState::None;
396        self.progress = 0.0;
397        self.elapsed = 0.0;
398        self.swap_pending = false;
399        self.swap_acknowledged = false;
400        self.from_screen = None;
401    }
402
403    // ── Overlay rendering ───────────────────────────────────────────────────
404
405    /// Compute the overlay parameters for the current frame.
406    ///
407    /// The renderer uses this to draw the transition effect on top of the scene.
408    pub fn render_overlay(&self, _screen_w: f32, _screen_h: f32) -> TransitionOverlay {
409        if self.state == TransitionState::None || self.state == TransitionState::Completed {
410            return TransitionOverlay::default();
411        }
412
413        match &self.transition_type {
414            TransitionType::FadeBlack { out_time, hold_time, in_time } => {
415                self.render_fade_black(*out_time, *hold_time, *in_time)
416            }
417            TransitionType::Dissolve { duration } => {
418                self.render_dissolve(*duration)
419            }
420            TransitionType::SlideLeft { duration } => {
421                self.render_slide(*duration, -1.0)
422            }
423            TransitionType::SlideRight { duration } => {
424                self.render_slide(*duration, 1.0)
425            }
426            TransitionType::ZoomIn { duration } => {
427                self.render_zoom(*duration)
428            }
429            TransitionType::ChaosWipe { duration } => {
430                self.render_chaos_wipe(*duration)
431            }
432            TransitionType::Cut => TransitionOverlay::default(),
433        }
434    }
435
436    // ── FadeBlack ───────────────────────────────────────────────────────────
437
438    fn render_fade_black(&self, out_time: f32, hold_time: f32, in_time: f32) -> TransitionOverlay {
439        let alpha = if self.elapsed < out_time {
440            // Fading out: 0 → 1
441            let t = if out_time > 1e-6 { self.elapsed / out_time } else { 1.0 };
442            self.easing.apply(t)
443        } else if self.elapsed < out_time + hold_time {
444            // Hold: fully black
445            1.0
446        } else {
447            // Fading in: 1 → 0
448            let fade_in_elapsed = self.elapsed - out_time - hold_time;
449            let t = if in_time > 1e-6 { fade_in_elapsed / in_time } else { 1.0 };
450            1.0 - self.easing.apply(t)
451        };
452
453        TransitionOverlay {
454            color: Vec4::new(0.0, 0.0, 0.0, alpha),
455            coverage: alpha,
456            effect: TransitionEffect::FadeBlack,
457            ..Default::default()
458        }
459    }
460
461    // ── Dissolve ────────────────────────────────────────────────────────────
462
463    fn render_dissolve(&self, duration: f32) -> TransitionOverlay {
464        let t = if duration > 1e-6 { self.elapsed / duration } else { 1.0 };
465        let threshold = self.easing.apply(t.clamp(0.0, 1.0));
466
467        TransitionOverlay {
468            dissolve_threshold: threshold,
469            coverage: threshold,
470            effect: TransitionEffect::Dissolve,
471            ..Default::default()
472        }
473    }
474
475    // ── Slide ───────────────────────────────────────────────────────────────
476
477    fn render_slide(&self, duration: f32, direction: f32) -> TransitionOverlay {
478        let t = if duration > 1e-6 { self.elapsed / duration } else { 1.0 };
479        let eased = self.easing.apply(t.clamp(0.0, 1.0));
480        let offset = eased * direction;
481
482        let effect = if direction < 0.0 {
483            TransitionEffect::SlideLeft
484        } else {
485            TransitionEffect::SlideRight
486        };
487
488        TransitionOverlay {
489            slide_offset: offset,
490            coverage: eased.min(1.0 - eased) * 2.0, // peaks at 0.5
491            effect,
492            ..Default::default()
493        }
494    }
495
496    // ── ZoomIn ──────────────────────────────────────────────────────────────
497
498    fn render_zoom(&self, duration: f32) -> TransitionOverlay {
499        let t = if duration > 1e-6 { self.elapsed / duration } else { 1.0 };
500        let eased = self.easing.apply(t.clamp(0.0, 1.0));
501
502        // Zoom: 1.0 → 3.0 in first half, then snap to new screen zoomed in → 1.0
503        let zoom = if eased < 0.5 {
504            1.0 + eased * 4.0  // 1.0 → 3.0
505        } else {
506            3.0 - (eased - 0.5) * 4.0  // 3.0 → 1.0
507        };
508
509        // Fade to white at midpoint for the "flash"
510        let flash_alpha = if eased > 0.4 && eased < 0.6 {
511            let flash_t = ((eased - 0.4) / 0.2).clamp(0.0, 1.0);
512            if flash_t < 0.5 {
513                flash_t * 2.0
514            } else {
515                (1.0 - flash_t) * 2.0
516            }
517        } else {
518            0.0
519        };
520
521        TransitionOverlay {
522            color: Vec4::new(1.0, 1.0, 1.0, flash_alpha),
523            zoom_scale: zoom.max(0.01),
524            coverage: flash_alpha,
525            effect: TransitionEffect::ZoomIn,
526            ..Default::default()
527        }
528    }
529
530    // ── ChaosWipe ───────────────────────────────────────────────────────────
531
532    fn render_chaos_wipe(&self, duration: f32) -> TransitionOverlay {
533        let t = if duration > 1e-6 { self.elapsed / duration } else { 1.0 };
534        let eased = self.easing.apply(t.clamp(0.0, 1.0));
535
536        // Wave front sweeps left to right (0.0 → 1.0)
537        // Particles spawn at the wave front
538        let wipe_front = eased;
539
540        // Number of chaos particles: peaks at midpoint
541        let intensity = if eased < 0.5 { eased * 2.0 } else { (1.0 - eased) * 2.0 };
542        let particle_count = (intensity * 200.0) as u32;
543
544        TransitionOverlay {
545            wipe_front,
546            chaos_particle_count: particle_count,
547            coverage: eased,
548            color: Vec4::new(0.0, 0.0, 0.0, 0.0), // no solid overlay
549            effect: TransitionEffect::ChaosWipe,
550            ..Default::default()
551        }
552    }
553}
554
555impl Default for TransitionManager {
556    fn default() -> Self { Self::new() }
557}
558
559// ── Game transition presets ─────────────────────────────────────────────────
560
561/// Pre-configured transitions for specific game state changes.
562pub struct GameTransitions;
563
564impl GameTransitions {
565    /// Title Screen → Character Creation
566    pub fn title_to_character_creation() -> TransitionType {
567        TransitionType::FadeBlack {
568            out_time: 0.2,
569            hold_time: 0.05,
570            in_time: 0.2,
571        }
572    }
573
574    /// Character Creation → Floor Navigation
575    pub fn character_creation_to_floor_nav() -> TransitionType {
576        TransitionType::FadeBlack {
577            out_time: 0.15,
578            hold_time: 0.05,
579            in_time: 0.2,
580        }
581    }
582
583    /// Floor Navigation → Combat
584    pub fn floor_nav_to_combat() -> TransitionType {
585        TransitionType::ChaosWipe {
586            duration: 0.3,
587        }
588    }
589
590    /// Combat → Floor Navigation
591    pub fn combat_to_floor_nav() -> TransitionType {
592        TransitionType::FadeBlack {
593            out_time: 0.2,
594            hold_time: 0.05,
595            in_time: 0.2,
596        }
597    }
598
599    /// Any → Death Screen (slow, dramatic)
600    pub fn to_death() -> TransitionType {
601        TransitionType::FadeBlack {
602            out_time: 0.5,
603            hold_time: 0.5,
604            in_time: 0.3,
605        }
606    }
607
608    /// Any → Boss Encounter (zoom in, dramatic)
609    pub fn to_boss() -> TransitionType {
610        TransitionType::ZoomIn {
611            duration: 0.3,
612        }
613    }
614
615    /// Floor → Floor (noise dissolve)
616    pub fn floor_transition() -> TransitionType {
617        TransitionType::Dissolve {
618            duration: 0.4,
619        }
620    }
621
622    /// Quick menu transition
623    pub fn menu_transition() -> TransitionType {
624        TransitionType::FadeBlack {
625            out_time: 0.1,
626            hold_time: 0.02,
627            in_time: 0.1,
628        }
629    }
630
631    /// Settings / pause overlay
632    pub fn pause_overlay() -> TransitionType {
633        TransitionType::FadeBlack {
634            out_time: 0.08,
635            hold_time: 0.0,
636            in_time: 0.08,
637        }
638    }
639
640    /// Victory screen
641    pub fn to_victory() -> TransitionType {
642        TransitionType::FadeBlack {
643            out_time: 0.3,
644            hold_time: 0.2,
645            in_time: 0.5,
646        }
647    }
648
649    /// Slide left for inventory/menu panels
650    pub fn panel_slide_left() -> TransitionType {
651        TransitionType::SlideLeft { duration: 0.25 }
652    }
653
654    /// Slide right for inventory/menu panels
655    pub fn panel_slide_right() -> TransitionType {
656        TransitionType::SlideRight { duration: 0.25 }
657    }
658}
659
660// ── Transition queue ────────────────────────────────────────────────────────
661
662/// Queued transition request — allows scheduling transitions from game logic.
663#[derive(Debug, Clone)]
664pub struct TransitionRequest {
665    pub transition: TransitionType,
666    pub tag: String,
667    pub easing: TransitionEasing,
668    pub delay: f32,
669}
670
671impl TransitionRequest {
672    pub fn new(transition: TransitionType) -> Self {
673        Self {
674            transition,
675            tag: String::new(),
676            easing: TransitionEasing::SmoothStep,
677            delay: 0.0,
678        }
679    }
680
681    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
682        self.tag = tag.into();
683        self
684    }
685
686    pub fn with_easing(mut self, easing: TransitionEasing) -> Self {
687        self.easing = easing;
688        self
689    }
690
691    pub fn with_delay(mut self, delay: f32) -> Self {
692        self.delay = delay;
693        self
694    }
695}
696
697/// A queue of pending transitions. Useful when multiple transitions might be
698/// requested in quick succession (e.g. combat → floor nav → shop).
699pub struct TransitionQueue {
700    pub manager: TransitionManager,
701    pending: Vec<TransitionRequest>,
702    delay_timer: f32,
703}
704
705impl TransitionQueue {
706    pub fn new() -> Self {
707        Self {
708            manager: TransitionManager::new(),
709            pending: Vec::new(),
710            delay_timer: 0.0,
711        }
712    }
713
714    /// Enqueue a transition request.
715    pub fn enqueue(&mut self, request: TransitionRequest) {
716        self.pending.push(request);
717    }
718
719    /// Enqueue a simple transition with no delay.
720    pub fn enqueue_simple(&mut self, transition: TransitionType) {
721        self.pending.push(TransitionRequest::new(transition));
722    }
723
724    /// Tick the queue and active transition.
725    pub fn tick(&mut self, dt: f32) {
726        // Tick active transition
727        self.manager.tick(dt);
728
729        // If no transition is active and there's a pending one, start it
730        if self.manager.is_done() && !self.pending.is_empty() {
731            // Handle delay
732            if self.delay_timer > 0.0 {
733                self.delay_timer -= dt;
734                return;
735            }
736
737            let request = self.pending.remove(0);
738            if request.delay > 0.0 && self.delay_timer <= 0.0 {
739                self.delay_timer = request.delay;
740                self.pending.insert(0, TransitionRequest {
741                    delay: 0.0,
742                    ..request
743                });
744                return;
745            }
746
747            self.manager.easing = request.easing;
748            self.manager.start_tagged(request.transition, request.tag);
749        }
750    }
751
752    /// Whether the game should swap state.
753    pub fn should_swap_state(&self) -> bool {
754        self.manager.should_swap_state()
755    }
756
757    /// Acknowledge state swap.
758    pub fn acknowledge_swap(&mut self) {
759        self.manager.acknowledge_swap();
760    }
761
762    /// Get the overlay for rendering.
763    pub fn render_overlay(&self, w: f32, h: f32) -> TransitionOverlay {
764        self.manager.render_overlay(w, h)
765    }
766
767    /// Whether any transition is active or pending.
768    pub fn is_busy(&self) -> bool {
769        self.manager.is_active() || !self.pending.is_empty()
770    }
771
772    /// Clear all pending and active transitions.
773    pub fn clear(&mut self) {
774        self.manager.clear();
775        self.pending.clear();
776        self.delay_timer = 0.0;
777    }
778
779    /// Number of pending transitions in the queue.
780    pub fn pending_count(&self) -> usize {
781        self.pending.len()
782    }
783}
784
785impl Default for TransitionQueue {
786    fn default() -> Self { Self::new() }
787}
788
789// ── Tests ───────────────────────────────────────────────────────────────────
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    #[test]
796    fn fade_black_phases() {
797        let mut tm = TransitionManager::new();
798        tm.start(TransitionType::FadeBlack {
799            out_time: 0.2,
800            hold_time: 0.1,
801            in_time: 0.2,
802        });
803
804        assert_eq!(tm.state(), TransitionState::FadingOut);
805
806        // Advance through fade out
807        tm.tick(0.15);
808        assert_eq!(tm.state(), TransitionState::FadingOut);
809
810        // Into hold
811        tm.tick(0.1);
812        assert_eq!(tm.state(), TransitionState::Hold);
813        assert!(tm.should_swap_state());
814
815        tm.acknowledge_swap();
816        assert!(!tm.should_swap_state());
817
818        // Into fade in
819        tm.tick(0.1);
820        assert_eq!(tm.state(), TransitionState::FadingIn);
821
822        // Complete
823        tm.tick(0.2);
824        assert_eq!(tm.state(), TransitionState::Completed);
825        assert!(tm.is_done());
826    }
827
828    #[test]
829    fn dissolve_transition() {
830        let mut tm = TransitionManager::new();
831        tm.start(TransitionType::Dissolve { duration: 1.0 });
832
833        tm.tick(0.25);
834        assert!(tm.is_active());
835        let overlay = tm.render_overlay(800.0, 600.0);
836        assert_eq!(overlay.effect, TransitionEffect::Dissolve);
837        assert!(overlay.dissolve_threshold > 0.0);
838
839        tm.tick(0.75);
840        assert!(tm.is_done());
841    }
842
843    #[test]
844    fn chaos_wipe_particles() {
845        let mut tm = TransitionManager::new();
846        tm.start(TransitionType::ChaosWipe { duration: 0.3 });
847
848        tm.tick(0.15);
849        let overlay = tm.render_overlay(800.0, 600.0);
850        assert_eq!(overlay.effect, TransitionEffect::ChaosWipe);
851        assert!(overlay.chaos_particle_count > 0);
852        assert!(overlay.wipe_front > 0.0);
853    }
854
855    #[test]
856    fn zoom_in_flash() {
857        let mut tm = TransitionManager::new();
858        tm.start(TransitionType::ZoomIn { duration: 1.0 });
859
860        // At midpoint, there should be a flash
861        tm.tick(0.5);
862        let overlay = tm.render_overlay(800.0, 600.0);
863        assert_eq!(overlay.effect, TransitionEffect::ZoomIn);
864        assert!(overlay.zoom_scale > 1.0);
865    }
866
867    #[test]
868    fn instant_cut() {
869        let mut tm = TransitionManager::new();
870        tm.start(TransitionType::Cut);
871        tm.tick(0.0);
872        assert!(tm.is_done());
873    }
874
875    #[test]
876    fn slide_left() {
877        let mut tm = TransitionManager::new();
878        tm.start(TransitionType::SlideLeft { duration: 0.5 });
879        tm.tick(0.25);
880        let overlay = tm.render_overlay(800.0, 600.0);
881        assert_eq!(overlay.effect, TransitionEffect::SlideLeft);
882        assert!(overlay.slide_offset < 0.0);
883    }
884
885    #[test]
886    fn game_preset_durations() {
887        assert!(GameTransitions::title_to_character_creation().total_duration() > 0.0);
888        assert!(GameTransitions::to_death().total_duration() > 1.0);
889        assert!(GameTransitions::to_boss().total_duration() > 0.0);
890        assert!(GameTransitions::floor_transition().total_duration() > 0.0);
891    }
892
893    #[test]
894    fn easing_bounds() {
895        for easing in &[
896            TransitionEasing::Linear,
897            TransitionEasing::EaseIn,
898            TransitionEasing::EaseOut,
899            TransitionEasing::EaseInOut,
900            TransitionEasing::SmoothStep,
901        ] {
902            assert!((easing.apply(0.0) - 0.0).abs() < 1e-6, "{:?} at 0", easing);
903            assert!((easing.apply(1.0) - 1.0).abs() < 1e-6, "{:?} at 1", easing);
904            // Monotonic check: midpoint should be between 0 and 1
905            let mid = easing.apply(0.5);
906            assert!(mid >= 0.0 && mid <= 1.0, "{:?} mid={}", easing, mid);
907        }
908    }
909
910    #[test]
911    fn transition_queue_sequences() {
912        let mut queue = TransitionQueue::new();
913        queue.enqueue_simple(TransitionType::FadeBlack {
914            out_time: 0.1, hold_time: 0.0, in_time: 0.1,
915        });
916        queue.enqueue_simple(TransitionType::Dissolve { duration: 0.2 });
917
918        // First transition starts
919        queue.tick(0.01);
920        assert!(queue.is_busy());
921        assert_eq!(queue.pending_count(), 1);
922
923        // Complete first (swap + finish)
924        queue.tick(0.05);
925        queue.acknowledge_swap();
926        queue.tick(0.15);
927
928        // Second should start
929        queue.tick(0.01);
930        assert!(queue.is_busy());
931        assert_eq!(queue.pending_count(), 0);
932    }
933
934    #[test]
935    fn overlay_default_when_inactive() {
936        let tm = TransitionManager::new();
937        let overlay = tm.render_overlay(800.0, 600.0);
938        assert_eq!(overlay.effect, TransitionEffect::None);
939        assert_eq!(overlay.coverage, 0.0);
940    }
941}