use glam::{Vec2, Vec3, Vec4};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransitionState {
None,
FadingOut,
Hold,
FadingIn,
Completed,
}
#[derive(Debug, Clone)]
pub enum TransitionType {
FadeBlack {
out_time: f32,
hold_time: f32,
in_time: f32,
},
Dissolve {
duration: f32,
},
SlideLeft {
duration: f32,
},
SlideRight {
duration: f32,
},
ZoomIn {
duration: f32,
},
ChaosWipe {
duration: f32,
},
Cut,
}
impl TransitionType {
pub fn total_duration(&self) -> f32 {
match self {
Self::FadeBlack { out_time, hold_time, in_time } => out_time + hold_time + in_time,
Self::Dissolve { duration } => *duration,
Self::SlideLeft { duration } => *duration,
Self::SlideRight { duration } => *duration,
Self::ZoomIn { duration } => *duration,
Self::ChaosWipe { duration } => *duration,
Self::Cut => 0.0,
}
}
pub fn swap_point(&self) -> f32 {
match self {
Self::FadeBlack { out_time, hold_time, in_time } => {
let total = out_time + hold_time + in_time;
if total < 1e-6 { return 0.5; }
(out_time + hold_time * 0.5) / total
}
Self::Dissolve { .. } => 0.5,
Self::SlideLeft { .. } => 0.5,
Self::SlideRight { .. } => 0.5,
Self::ZoomIn { .. } => 0.5,
Self::ChaosWipe { .. } => 0.5,
Self::Cut => 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct TransitionOverlay {
pub color: Vec4,
pub coverage: f32,
pub dissolve_threshold: f32,
pub slide_offset: f32,
pub zoom_scale: f32,
pub wipe_front: f32,
pub chaos_particle_count: u32,
pub effect: TransitionEffect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransitionEffect {
None,
FadeBlack,
Dissolve,
SlideLeft,
SlideRight,
ZoomIn,
ChaosWipe,
}
impl Default for TransitionOverlay {
fn default() -> Self {
Self {
color: Vec4::new(0.0, 0.0, 0.0, 0.0),
coverage: 0.0,
dissolve_threshold: 0.0,
slide_offset: 0.0,
zoom_scale: 1.0,
wipe_front: 0.0,
chaos_particle_count: 0,
effect: TransitionEffect::None,
}
}
}
#[derive(Debug, Clone)]
pub struct Screenshot {
pub width: u32,
pub height: u32,
pub captured_at: f32, pub texture_id: Option<u32>,
}
impl Screenshot {
pub fn placeholder(w: u32, h: u32, time: f32) -> Self {
Self { width: w, height: h, captured_at: time, texture_id: None }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransitionEasing {
Linear,
EaseIn,
EaseOut,
EaseInOut,
SmoothStep,
}
impl TransitionEasing {
pub fn apply(&self, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
match self {
Self::Linear => t,
Self::EaseIn => t * t,
Self::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
Self::EaseInOut => {
if t < 0.5 {
2.0 * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
}
}
Self::SmoothStep => t * t * (3.0 - 2.0 * t),
}
}
}
pub struct TransitionManager {
state: TransitionState,
progress: f32,
elapsed: f32,
transition_type: TransitionType,
easing: TransitionEasing,
from_screen: Option<Screenshot>,
swap_pending: bool,
swap_acknowledged: bool,
pub tag: String,
pub stats: TransitionStats,
}
#[derive(Debug, Clone, Default)]
pub struct TransitionStats {
pub state: &'static str,
pub progress: f32,
pub elapsed: f32,
pub total_duration: f32,
}
impl TransitionManager {
pub fn new() -> Self {
Self {
state: TransitionState::None,
progress: 0.0,
elapsed: 0.0,
transition_type: TransitionType::Cut,
easing: TransitionEasing::SmoothStep,
from_screen: None,
swap_pending: false,
swap_acknowledged: false,
tag: String::new(),
stats: TransitionStats::default(),
}
}
pub fn start(&mut self, transition: TransitionType) {
self.transition_type = transition;
self.state = if self.transition_type.total_duration() < 1e-6 {
self.swap_pending = true;
TransitionState::Hold
} else {
TransitionState::FadingOut
};
self.progress = 0.0;
self.elapsed = 0.0;
self.swap_pending = false;
self.swap_acknowledged = false;
}
pub fn start_tagged(&mut self, transition: TransitionType, tag: impl Into<String>) {
self.tag = tag.into();
self.start(transition);
}
pub fn start_with_easing(&mut self, transition: TransitionType, easing: TransitionEasing) {
self.easing = easing;
self.start(transition);
}
pub fn capture_screen(&mut self, width: u32, height: u32, time: f32) {
self.from_screen = Some(Screenshot::placeholder(width, height, time));
}
pub fn tick(&mut self, dt: f32) {
if self.state == TransitionState::None || self.state == TransitionState::Completed {
return;
}
self.elapsed += dt;
let total = self.transition_type.total_duration();
if total < 1e-6 {
self.progress = 1.0;
self.state = TransitionState::Completed;
self.swap_pending = true;
self.update_stats();
return;
}
self.progress = (self.elapsed / total).clamp(0.0, 1.0);
let swap_point = self.transition_type.swap_point();
match &self.transition_type {
TransitionType::FadeBlack { out_time, hold_time, in_time } => {
let total = out_time + hold_time + in_time;
if self.elapsed < *out_time {
self.state = TransitionState::FadingOut;
} else if self.elapsed < out_time + hold_time {
self.state = TransitionState::Hold;
if !self.swap_pending && !self.swap_acknowledged {
self.swap_pending = true;
}
} else if self.elapsed < total {
self.state = TransitionState::FadingIn;
} else {
self.state = TransitionState::Completed;
}
}
_ => {
if self.progress < swap_point {
self.state = TransitionState::FadingOut;
} else if !self.swap_acknowledged {
self.state = TransitionState::Hold;
if !self.swap_pending {
self.swap_pending = true;
}
} else if self.progress < 1.0 {
self.state = TransitionState::FadingIn;
} else {
self.state = TransitionState::Completed;
}
}
}
if self.elapsed >= total {
self.state = TransitionState::Completed;
}
self.update_stats();
}
fn update_stats(&self) {
}
pub fn should_swap_state(&self) -> bool {
self.swap_pending && !self.swap_acknowledged
}
pub fn acknowledge_swap(&mut self) {
self.swap_acknowledged = true;
self.swap_pending = false;
}
pub fn is_done(&self) -> bool {
self.state == TransitionState::None || self.state == TransitionState::Completed
}
pub fn is_active(&self) -> bool {
!self.is_done()
}
pub fn state(&self) -> TransitionState { self.state }
pub fn progress(&self) -> f32 { self.progress }
pub fn clear(&mut self) {
self.state = TransitionState::None;
self.progress = 0.0;
self.elapsed = 0.0;
self.swap_pending = false;
self.swap_acknowledged = false;
self.from_screen = None;
}
pub fn render_overlay(&self, _screen_w: f32, _screen_h: f32) -> TransitionOverlay {
if self.state == TransitionState::None || self.state == TransitionState::Completed {
return TransitionOverlay::default();
}
match &self.transition_type {
TransitionType::FadeBlack { out_time, hold_time, in_time } => {
self.render_fade_black(*out_time, *hold_time, *in_time)
}
TransitionType::Dissolve { duration } => {
self.render_dissolve(*duration)
}
TransitionType::SlideLeft { duration } => {
self.render_slide(*duration, -1.0)
}
TransitionType::SlideRight { duration } => {
self.render_slide(*duration, 1.0)
}
TransitionType::ZoomIn { duration } => {
self.render_zoom(*duration)
}
TransitionType::ChaosWipe { duration } => {
self.render_chaos_wipe(*duration)
}
TransitionType::Cut => TransitionOverlay::default(),
}
}
fn render_fade_black(&self, out_time: f32, hold_time: f32, in_time: f32) -> TransitionOverlay {
let alpha = if self.elapsed < out_time {
let t = if out_time > 1e-6 { self.elapsed / out_time } else { 1.0 };
self.easing.apply(t)
} else if self.elapsed < out_time + hold_time {
1.0
} else {
let fade_in_elapsed = self.elapsed - out_time - hold_time;
let t = if in_time > 1e-6 { fade_in_elapsed / in_time } else { 1.0 };
1.0 - self.easing.apply(t)
};
TransitionOverlay {
color: Vec4::new(0.0, 0.0, 0.0, alpha),
coverage: alpha,
effect: TransitionEffect::FadeBlack,
..Default::default()
}
}
fn render_dissolve(&self, duration: f32) -> TransitionOverlay {
let t = if duration > 1e-6 { self.elapsed / duration } else { 1.0 };
let threshold = self.easing.apply(t.clamp(0.0, 1.0));
TransitionOverlay {
dissolve_threshold: threshold,
coverage: threshold,
effect: TransitionEffect::Dissolve,
..Default::default()
}
}
fn render_slide(&self, duration: f32, direction: f32) -> TransitionOverlay {
let t = if duration > 1e-6 { self.elapsed / duration } else { 1.0 };
let eased = self.easing.apply(t.clamp(0.0, 1.0));
let offset = eased * direction;
let effect = if direction < 0.0 {
TransitionEffect::SlideLeft
} else {
TransitionEffect::SlideRight
};
TransitionOverlay {
slide_offset: offset,
coverage: eased.min(1.0 - eased) * 2.0, effect,
..Default::default()
}
}
fn render_zoom(&self, duration: f32) -> TransitionOverlay {
let t = if duration > 1e-6 { self.elapsed / duration } else { 1.0 };
let eased = self.easing.apply(t.clamp(0.0, 1.0));
let zoom = if eased < 0.5 {
1.0 + eased * 4.0 } else {
3.0 - (eased - 0.5) * 4.0 };
let flash_alpha = if eased > 0.4 && eased < 0.6 {
let flash_t = ((eased - 0.4) / 0.2).clamp(0.0, 1.0);
if flash_t < 0.5 {
flash_t * 2.0
} else {
(1.0 - flash_t) * 2.0
}
} else {
0.0
};
TransitionOverlay {
color: Vec4::new(1.0, 1.0, 1.0, flash_alpha),
zoom_scale: zoom.max(0.01),
coverage: flash_alpha,
effect: TransitionEffect::ZoomIn,
..Default::default()
}
}
fn render_chaos_wipe(&self, duration: f32) -> TransitionOverlay {
let t = if duration > 1e-6 { self.elapsed / duration } else { 1.0 };
let eased = self.easing.apply(t.clamp(0.0, 1.0));
let wipe_front = eased;
let intensity = if eased < 0.5 { eased * 2.0 } else { (1.0 - eased) * 2.0 };
let particle_count = (intensity * 200.0) as u32;
TransitionOverlay {
wipe_front,
chaos_particle_count: particle_count,
coverage: eased,
color: Vec4::new(0.0, 0.0, 0.0, 0.0), effect: TransitionEffect::ChaosWipe,
..Default::default()
}
}
}
impl Default for TransitionManager {
fn default() -> Self { Self::new() }
}
pub struct GameTransitions;
impl GameTransitions {
pub fn title_to_character_creation() -> TransitionType {
TransitionType::FadeBlack {
out_time: 0.2,
hold_time: 0.05,
in_time: 0.2,
}
}
pub fn character_creation_to_floor_nav() -> TransitionType {
TransitionType::FadeBlack {
out_time: 0.15,
hold_time: 0.05,
in_time: 0.2,
}
}
pub fn floor_nav_to_combat() -> TransitionType {
TransitionType::ChaosWipe {
duration: 0.3,
}
}
pub fn combat_to_floor_nav() -> TransitionType {
TransitionType::FadeBlack {
out_time: 0.2,
hold_time: 0.05,
in_time: 0.2,
}
}
pub fn to_death() -> TransitionType {
TransitionType::FadeBlack {
out_time: 0.5,
hold_time: 0.5,
in_time: 0.3,
}
}
pub fn to_boss() -> TransitionType {
TransitionType::ZoomIn {
duration: 0.3,
}
}
pub fn floor_transition() -> TransitionType {
TransitionType::Dissolve {
duration: 0.4,
}
}
pub fn menu_transition() -> TransitionType {
TransitionType::FadeBlack {
out_time: 0.1,
hold_time: 0.02,
in_time: 0.1,
}
}
pub fn pause_overlay() -> TransitionType {
TransitionType::FadeBlack {
out_time: 0.08,
hold_time: 0.0,
in_time: 0.08,
}
}
pub fn to_victory() -> TransitionType {
TransitionType::FadeBlack {
out_time: 0.3,
hold_time: 0.2,
in_time: 0.5,
}
}
pub fn panel_slide_left() -> TransitionType {
TransitionType::SlideLeft { duration: 0.25 }
}
pub fn panel_slide_right() -> TransitionType {
TransitionType::SlideRight { duration: 0.25 }
}
}
#[derive(Debug, Clone)]
pub struct TransitionRequest {
pub transition: TransitionType,
pub tag: String,
pub easing: TransitionEasing,
pub delay: f32,
}
impl TransitionRequest {
pub fn new(transition: TransitionType) -> Self {
Self {
transition,
tag: String::new(),
easing: TransitionEasing::SmoothStep,
delay: 0.0,
}
}
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.tag = tag.into();
self
}
pub fn with_easing(mut self, easing: TransitionEasing) -> Self {
self.easing = easing;
self
}
pub fn with_delay(mut self, delay: f32) -> Self {
self.delay = delay;
self
}
}
pub struct TransitionQueue {
pub manager: TransitionManager,
pending: Vec<TransitionRequest>,
delay_timer: f32,
}
impl TransitionQueue {
pub fn new() -> Self {
Self {
manager: TransitionManager::new(),
pending: Vec::new(),
delay_timer: 0.0,
}
}
pub fn enqueue(&mut self, request: TransitionRequest) {
self.pending.push(request);
}
pub fn enqueue_simple(&mut self, transition: TransitionType) {
self.pending.push(TransitionRequest::new(transition));
}
pub fn tick(&mut self, dt: f32) {
self.manager.tick(dt);
if self.manager.is_done() && !self.pending.is_empty() {
if self.delay_timer > 0.0 {
self.delay_timer -= dt;
return;
}
let request = self.pending.remove(0);
if request.delay > 0.0 && self.delay_timer <= 0.0 {
self.delay_timer = request.delay;
self.pending.insert(0, TransitionRequest {
delay: 0.0,
..request
});
return;
}
self.manager.easing = request.easing;
self.manager.start_tagged(request.transition, request.tag);
}
}
pub fn should_swap_state(&self) -> bool {
self.manager.should_swap_state()
}
pub fn acknowledge_swap(&mut self) {
self.manager.acknowledge_swap();
}
pub fn render_overlay(&self, w: f32, h: f32) -> TransitionOverlay {
self.manager.render_overlay(w, h)
}
pub fn is_busy(&self) -> bool {
self.manager.is_active() || !self.pending.is_empty()
}
pub fn clear(&mut self) {
self.manager.clear();
self.pending.clear();
self.delay_timer = 0.0;
}
pub fn pending_count(&self) -> usize {
self.pending.len()
}
}
impl Default for TransitionQueue {
fn default() -> Self { Self::new() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fade_black_phases() {
let mut tm = TransitionManager::new();
tm.start(TransitionType::FadeBlack {
out_time: 0.2,
hold_time: 0.1,
in_time: 0.2,
});
assert_eq!(tm.state(), TransitionState::FadingOut);
tm.tick(0.15);
assert_eq!(tm.state(), TransitionState::FadingOut);
tm.tick(0.1);
assert_eq!(tm.state(), TransitionState::Hold);
assert!(tm.should_swap_state());
tm.acknowledge_swap();
assert!(!tm.should_swap_state());
tm.tick(0.1);
assert_eq!(tm.state(), TransitionState::FadingIn);
tm.tick(0.2);
assert_eq!(tm.state(), TransitionState::Completed);
assert!(tm.is_done());
}
#[test]
fn dissolve_transition() {
let mut tm = TransitionManager::new();
tm.start(TransitionType::Dissolve { duration: 1.0 });
tm.tick(0.25);
assert!(tm.is_active());
let overlay = tm.render_overlay(800.0, 600.0);
assert_eq!(overlay.effect, TransitionEffect::Dissolve);
assert!(overlay.dissolve_threshold > 0.0);
tm.tick(0.75);
assert!(tm.is_done());
}
#[test]
fn chaos_wipe_particles() {
let mut tm = TransitionManager::new();
tm.start(TransitionType::ChaosWipe { duration: 0.3 });
tm.tick(0.15);
let overlay = tm.render_overlay(800.0, 600.0);
assert_eq!(overlay.effect, TransitionEffect::ChaosWipe);
assert!(overlay.chaos_particle_count > 0);
assert!(overlay.wipe_front > 0.0);
}
#[test]
fn zoom_in_flash() {
let mut tm = TransitionManager::new();
tm.start(TransitionType::ZoomIn { duration: 1.0 });
tm.tick(0.5);
let overlay = tm.render_overlay(800.0, 600.0);
assert_eq!(overlay.effect, TransitionEffect::ZoomIn);
assert!(overlay.zoom_scale > 1.0);
}
#[test]
fn instant_cut() {
let mut tm = TransitionManager::new();
tm.start(TransitionType::Cut);
tm.tick(0.0);
assert!(tm.is_done());
}
#[test]
fn slide_left() {
let mut tm = TransitionManager::new();
tm.start(TransitionType::SlideLeft { duration: 0.5 });
tm.tick(0.25);
let overlay = tm.render_overlay(800.0, 600.0);
assert_eq!(overlay.effect, TransitionEffect::SlideLeft);
assert!(overlay.slide_offset < 0.0);
}
#[test]
fn game_preset_durations() {
assert!(GameTransitions::title_to_character_creation().total_duration() > 0.0);
assert!(GameTransitions::to_death().total_duration() > 1.0);
assert!(GameTransitions::to_boss().total_duration() > 0.0);
assert!(GameTransitions::floor_transition().total_duration() > 0.0);
}
#[test]
fn easing_bounds() {
for easing in &[
TransitionEasing::Linear,
TransitionEasing::EaseIn,
TransitionEasing::EaseOut,
TransitionEasing::EaseInOut,
TransitionEasing::SmoothStep,
] {
assert!((easing.apply(0.0) - 0.0).abs() < 1e-6, "{:?} at 0", easing);
assert!((easing.apply(1.0) - 1.0).abs() < 1e-6, "{:?} at 1", easing);
let mid = easing.apply(0.5);
assert!(mid >= 0.0 && mid <= 1.0, "{:?} mid={}", easing, mid);
}
}
#[test]
fn transition_queue_sequences() {
let mut queue = TransitionQueue::new();
queue.enqueue_simple(TransitionType::FadeBlack {
out_time: 0.1, hold_time: 0.0, in_time: 0.1,
});
queue.enqueue_simple(TransitionType::Dissolve { duration: 0.2 });
queue.tick(0.01);
assert!(queue.is_busy());
assert_eq!(queue.pending_count(), 1);
queue.tick(0.05);
queue.acknowledge_swap();
queue.tick(0.15);
queue.tick(0.01);
assert!(queue.is_busy());
assert_eq!(queue.pending_count(), 0);
}
#[test]
fn overlay_default_when_inactive() {
let tm = TransitionManager::new();
let overlay = tm.render_overlay(800.0, 600.0);
assert_eq!(overlay.effect, TransitionEffect::None);
assert_eq!(overlay.coverage, 0.0);
}
}