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