Skip to main content

logicaffeine_web/ui/components/
xp_popup.rs

1//! XP reward popup notification.
2//!
3//! Displays a centered popup showing XP earned with breakdown of bonuses.
4//! Auto-dismisses after 2 seconds. Plays sound effect on mount.
5//!
6//! # Props
7//!
8//! - `reward` - XP breakdown with base, bonuses, and total
9//! - `on_dismiss` - Callback when popup closes (auto or click)
10//!
11//! # Special Effects
12//!
13//! - Critical hits (random chance) show gold styling and pulsing glow
14//! - Different sound for critical vs normal XP gain
15
16use dioxus::prelude::*;
17use crate::game::XpReward;
18use crate::audio::{SoundEffect, play_sound};
19
20const XP_POPUP_STYLE: &str = r#"
21.xp-popup {
22    position: fixed;
23    top: 50%;
24    left: 50%;
25    transform: translate(-50%, -50%);
26    z-index: 1000;
27    cursor: pointer;
28    animation: xp-appear 2s ease-out forwards;
29}
30
31@keyframes xp-appear {
32    0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5); }
33    10% { opacity: 1; transform: translate(-50%, -50%) scale(1.1); }
34    20% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
35    80% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
36    100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9) translateY(-20px); }
37}
38
39.xp-popup-content {
40    background: rgba(0, 0, 0, 0.9);
41    border: 2px solid #667eea;
42    border-radius: 16px;
43    padding: 24px 40px;
44    text-align: center;
45    box-shadow: 0 0 40px rgba(102, 126, 234, 0.4);
46}
47
48.xp-total {
49    font-size: 48px;
50    font-weight: 700;
51    color: #4ade80;
52    margin-bottom: 8px;
53}
54
55.xp-total.critical {
56    color: #fbbf24;
57    text-shadow: 0 0 20px #fbbf24;
58    animation: critical-pulse 0.5s ease-in-out infinite alternate;
59}
60
61@keyframes critical-pulse {
62    from { text-shadow: 0 0 20px #fbbf24; }
63    to { text-shadow: 0 0 40px #fbbf24, 0 0 60px #f59e0b; }
64}
65
66.xp-breakdown {
67    display: flex;
68    flex-direction: column;
69    gap: 4px;
70    font-size: 14px;
71    color: #888;
72}
73
74.xp-line {
75    display: flex;
76    justify-content: space-between;
77    gap: 16px;
78}
79
80.xp-line.combo { color: #f97316; }
81.xp-line.streak { color: #06b6d4; }
82.xp-line.critical { color: #fbbf24; font-weight: 600; }
83.xp-line.first-try { color: #a78bfa; }
84"#;
85
86#[component]
87pub fn XpPopup(reward: XpReward, on_dismiss: EventHandler<()>) -> Element {
88    use_effect(move || {
89        if reward.is_critical {
90            play_sound(SoundEffect::CriticalHit);
91        } else {
92            play_sound(SoundEffect::XpGain);
93        }
94    });
95
96    use_effect(move || {
97        let handler = on_dismiss.clone();
98        spawn(async move {
99            gloo_timers::future::TimeoutFuture::new(2000).await;
100            handler.call(());
101        });
102    });
103
104    let total_class = if reward.is_critical { "xp-total critical" } else { "xp-total" };
105
106    rsx! {
107        style { "{XP_POPUP_STYLE}" }
108        div {
109            class: "xp-popup",
110            onclick: move |_| on_dismiss.call(()),
111            div { class: "xp-popup-content",
112                div { class: "{total_class}", "+{reward.total} XP" }
113                div { class: "xp-breakdown",
114                    div { class: "xp-line",
115                        span { "Base" }
116                        span { "+{reward.base}" }
117                    }
118                    if reward.combo_bonus > 0 {
119                        div { class: "xp-line combo",
120                            span { "Combo Bonus" }
121                            span { "+{reward.combo_bonus}" }
122                        }
123                    }
124                    if reward.streak_bonus > 0 {
125                        div { class: "xp-line streak",
126                            span { "Streak Bonus" }
127                            span { "+{reward.streak_bonus}" }
128                        }
129                    }
130                    if reward.first_try_bonus > 0 {
131                        div { class: "xp-line first-try",
132                            span { "First Try" }
133                            span { "+{reward.first_try_bonus}" }
134                        }
135                    }
136                    if reward.critical_bonus > 0 {
137                        div { class: "xp-line critical",
138                            span { "CRITICAL!" }
139                            span { "+{reward.critical_bonus}" }
140                        }
141                    }
142                }
143            }
144        }
145    }
146}