konnektoren_bevy/screens/
splash.rs

1use crate::{
2    theme::KonnektorenTheme,
3    ui::responsive::{ResponsiveFontSize, ResponsiveInfo, ResponsiveSpacing},
4};
5use bevy::prelude::*;
6use bevy_egui::{
7    egui::{self, StrokeKind, TextureId},
8    EguiContexts, EguiUserTextures,
9};
10use std::collections::HashMap;
11
12/// Plugin for reusable splash screen functionality
13pub struct SplashPlugin;
14
15impl Plugin for SplashPlugin {
16    fn build(&self, app: &mut App) {
17        app.add_event::<SplashDismissed>()
18            .add_systems(
19                Update,
20                (
21                    check_splash_config,
22                    update_splash_timer,
23                    handle_splash_completion,
24                    load_splash_images,
25                ),
26            )
27            .add_systems(bevy_egui::EguiContextPass, render_splash_ui);
28    }
29}
30
31/// Component to track loaded textures for splash screens
32#[derive(Component)]
33pub struct LoadedTextures {
34    pub textures: HashMap<String, TextureId>,
35    // Store handles to keep assets alive
36    pub handles: HashMap<String, Handle<Image>>,
37}
38
39/// Component to track loading images (keeps handles alive)
40#[derive(Component)]
41pub struct LoadingImages {
42    pub handles: HashMap<String, Handle<Image>>,
43}
44
45/// Logo display options for the splash screen
46#[derive(Clone, Debug)]
47pub enum LogoDisplay {
48    /// No logo
49    None,
50    /// Show an emoji icon
51    Emoji(String),
52    /// Show text as logo
53    Text(String),
54    /// Load image from asset path (requires asset server)
55    Image(String),
56    /// Custom logo renderer (advanced usage)
57    Custom(fn(&mut egui::Ui, &KonnektorenTheme, &ResponsiveInfo)),
58}
59
60impl Default for LogoDisplay {
61    fn default() -> Self {
62        LogoDisplay::Emoji("🎮".to_string())
63    }
64}
65
66/// Component that configures splash screen behavior
67#[derive(Component, Clone)]
68pub struct SplashConfig {
69    /// Logo display configuration
70    pub logo: LogoDisplay,
71    /// Title text
72    pub title: String,
73    /// Subtitle text (optional)
74    pub subtitle: Option<String>,
75    /// Duration in seconds (0.0 = infinite, requires manual dismissal)
76    pub duration: f32,
77    /// Auto transition to next state when timer finishes
78    pub auto_transition: bool,
79    /// Allow manual dismissal (click/key press)
80    pub manual_dismissal: bool,
81    /// Background color (optional, uses theme default if None)
82    pub background_color: Option<egui::Color32>,
83    /// Custom button text (if manual dismissal enabled)
84    pub button_text: Option<String>,
85    /// Show loading indicator
86    pub show_loading: bool,
87    /// Logo size multiplier (1.0 = default size)
88    pub logo_size_multiplier: f32,
89}
90
91impl Default for SplashConfig {
92    fn default() -> Self {
93        Self {
94            logo: LogoDisplay::default(),
95            title: "Loading...".to_string(),
96            subtitle: None,
97            duration: 2.0,
98            auto_transition: true,
99            manual_dismissal: true,
100            background_color: None,
101            button_text: None,
102            show_loading: true,
103            logo_size_multiplier: 1.0,
104        }
105    }
106}
107
108impl SplashConfig {
109    pub fn new(title: impl Into<String>) -> Self {
110        Self {
111            title: title.into(),
112            ..Default::default()
113        }
114    }
115
116    /// Set logo as emoji
117    pub fn with_emoji_logo(mut self, emoji: impl Into<String>) -> Self {
118        self.logo = LogoDisplay::Emoji(emoji.into());
119        self
120    }
121
122    /// Set logo as text
123    pub fn with_text_logo(mut self, text: impl Into<String>) -> Self {
124        self.logo = LogoDisplay::Text(text.into());
125        self
126    }
127
128    /// Set logo as image from asset path
129    pub fn with_image_logo(mut self, path: impl Into<String>) -> Self {
130        self.logo = LogoDisplay::Image(path.into());
131        self
132    }
133
134    /// Set custom logo renderer
135    pub fn with_custom_logo(
136        mut self,
137        renderer: fn(&mut egui::Ui, &KonnektorenTheme, &ResponsiveInfo),
138    ) -> Self {
139        self.logo = LogoDisplay::Custom(renderer);
140        self
141    }
142
143    /// Remove logo entirely
144    pub fn without_logo(mut self) -> Self {
145        self.logo = LogoDisplay::None;
146        self
147    }
148
149    pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
150        self.subtitle = Some(subtitle.into());
151        self
152    }
153
154    pub fn with_duration(mut self, duration: f32) -> Self {
155        self.duration = duration;
156        self
157    }
158
159    pub fn with_auto_transition(mut self, auto_transition: bool) -> Self {
160        self.auto_transition = auto_transition;
161        self
162    }
163
164    pub fn with_manual_dismissal(mut self, manual_dismissal: bool) -> Self {
165        self.manual_dismissal = manual_dismissal;
166        self
167    }
168
169    pub fn with_background_color(mut self, color: egui::Color32) -> Self {
170        self.background_color = Some(color);
171        self
172    }
173
174    pub fn with_button_text(mut self, text: impl Into<String>) -> Self {
175        self.button_text = Some(text.into());
176        self
177    }
178
179    pub fn with_loading_indicator(mut self, show: bool) -> Self {
180        self.show_loading = show;
181        self
182    }
183
184    pub fn with_logo_size(mut self, multiplier: f32) -> Self {
185        self.logo_size_multiplier = multiplier;
186        self
187    }
188
189    /// Infinite splash that requires manual dismissal
190    pub fn infinite(mut self) -> Self {
191        self.duration = 0.0;
192        self.auto_transition = false;
193        self.manual_dismissal = true;
194        self
195    }
196
197    /// Create a Konnektoren-branded splash screen
198    pub fn konnektoren() -> Self {
199        Self {
200            logo: LogoDisplay::Image("logo.png".to_string()),
201            title: "Konnektoren".to_string(),
202            subtitle: Some("Educational Games Platform".to_string()),
203            duration: 3.0,
204            auto_transition: true,
205            manual_dismissal: true,
206            background_color: None,
207            button_text: Some("Enter".to_string()),
208            show_loading: true,
209            logo_size_multiplier: 1.2,
210        }
211    }
212}
213
214/// Component marking an active splash screen
215#[derive(Component)]
216pub struct ActiveSplash {
217    timer: Timer,
218    config: SplashConfig,
219}
220
221/// Event sent when splash screen should be dismissed
222#[derive(Event)]
223pub struct SplashDismissed {
224    pub entity: Entity,
225}
226
227/// System to check for new splash configurations and set them up
228#[allow(clippy::type_complexity)]
229fn check_splash_config(
230    mut commands: Commands,
231    query: Query<(Entity, &SplashConfig), (Without<ActiveSplash>, Changed<SplashConfig>)>,
232) {
233    for (entity, config) in query.iter() {
234        info!("Setting up splash screen for entity {:?}", entity);
235
236        // Create timer
237        let timer = if config.duration > 0.0 {
238            Timer::from_seconds(config.duration, TimerMode::Once)
239        } else {
240            Timer::from_seconds(f32::MAX, TimerMode::Once) // Infinite timer
241        };
242
243        // Add ActiveSplash component
244        commands.entity(entity).insert(ActiveSplash {
245            timer,
246            config: config.clone(),
247        });
248    }
249}
250
251/// System to load images for splash screens
252#[allow(clippy::type_complexity)]
253fn load_splash_images(
254    mut commands: Commands,
255    asset_server: Res<AssetServer>,
256    images: Res<Assets<Image>>,
257    mut egui_user_textures: ResMut<EguiUserTextures>,
258    // Query for entities that need image loading
259    active_query: Query<(Entity, &ActiveSplash), (Without<LoadedTextures>, Without<LoadingImages>)>,
260    // Query for entities that are currently loading
261    mut loading_query: Query<(Entity, &ActiveSplash, &mut LoadingImages), Without<LoadedTextures>>,
262) {
263    // Start loading for new splash screens
264    for (entity, splash) in active_query.iter() {
265        if let LogoDisplay::Image(path) = &splash.config.logo {
266            info!("Starting to load image: {}", path);
267
268            // Load the image asset and store the handle
269            let image_handle: Handle<Image> = asset_server.load(path);
270
271            let mut handles = HashMap::new();
272            handles.insert(path.clone(), image_handle);
273
274            commands.entity(entity).insert(LoadingImages { handles });
275        }
276    }
277
278    // Check loading progress
279    for (entity, splash, mut loading_images) in loading_query.iter_mut() {
280        if let LogoDisplay::Image(path) = &splash.config.logo {
281            if let Some(image_handle) = loading_images.handles.get(path) {
282                // Check if the image is now loaded
283                if let Some(_image) = images.get(image_handle) {
284                    info!("Image loaded successfully: {}", path);
285
286                    // Convert to egui texture
287                    let texture_id = egui_user_textures.add_image(image_handle.clone());
288
289                    let mut textures = HashMap::new();
290                    textures.insert(path.clone(), texture_id);
291
292                    // Move handles to keep them alive
293                    let handles = std::mem::take(&mut loading_images.handles);
294
295                    commands
296                        .entity(entity)
297                        .remove::<LoadingImages>()
298                        .insert(LoadedTextures { textures, handles });
299                }
300            }
301        }
302    }
303}
304
305/// System to update splash timers
306fn update_splash_timer(
307    time: Res<Time>,
308    mut query: Query<(Entity, &mut ActiveSplash)>,
309    mut dismiss_events: EventWriter<SplashDismissed>,
310) {
311    for (entity, mut splash) in query.iter_mut() {
312        if splash.config.duration > 0.0 {
313            splash.timer.tick(time.delta());
314
315            if splash.timer.just_finished() && splash.config.auto_transition {
316                info!("Splash timer finished for entity {:?}", entity);
317                dismiss_events.write(SplashDismissed { entity });
318            }
319        }
320    }
321}
322
323/// System to render splash UI
324fn render_splash_ui(
325    mut contexts: EguiContexts,
326    theme: Res<KonnektorenTheme>,
327    responsive: Res<ResponsiveInfo>,
328    query: Query<(Entity, &ActiveSplash, Option<&LoadedTextures>)>,
329    mut dismiss_events: EventWriter<SplashDismissed>,
330    input: Res<ButtonInput<KeyCode>>,
331) {
332    // Early return if no active splash screens
333    if query.is_empty() {
334        return;
335    }
336
337    let ctx = contexts.ctx_mut();
338
339    for (entity, splash, loaded_textures) in query.iter() {
340        let config = &splash.config;
341
342        // Handle keyboard dismissal
343        if config.manual_dismissal
344            && (input.just_pressed(KeyCode::Space)
345                || input.just_pressed(KeyCode::Enter)
346                || input.just_pressed(KeyCode::Escape))
347        {
348            dismiss_events.write(SplashDismissed { entity });
349            continue;
350        }
351
352        // Determine background color
353        let bg_color = config.background_color.unwrap_or(theme.base_100);
354
355        egui::CentralPanel::default()
356            .frame(egui::Frame::NONE.fill(bg_color))
357            .show(ctx, |ui| {
358                render_splash_content(
359                    ui,
360                    config,
361                    splash,
362                    &theme,
363                    &responsive,
364                    entity,
365                    &mut dismiss_events,
366                    loaded_textures,
367                );
368            });
369    }
370}
371
372/// Render splash screen content
373#[allow(clippy::too_many_arguments)]
374fn render_splash_content(
375    ui: &mut egui::Ui,
376    config: &SplashConfig,
377    splash: &ActiveSplash,
378    theme: &KonnektorenTheme,
379    responsive: &ResponsiveInfo,
380    entity: Entity,
381    dismiss_events: &mut EventWriter<SplashDismissed>,
382    loaded_textures: Option<&LoadedTextures>,
383) {
384    ui.vertical_centered(|ui| {
385        let top_spacing = if responsive.is_mobile() { 50.0 } else { 80.0 };
386        ui.add_space(top_spacing);
387
388        // Render logo with image support
389        render_logo_enhanced(
390            ui,
391            &config.logo,
392            theme,
393            responsive,
394            config.logo_size_multiplier,
395            loaded_textures,
396        );
397
398        // Title
399        ui.heading(
400            egui::RichText::new(&config.title)
401                .color(theme.primary)
402                .size(responsive.font_size(ResponsiveFontSize::Title))
403                .strong(),
404        );
405
406        // Subtitle
407        if let Some(subtitle) = &config.subtitle {
408            ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
409            ui.label(
410                egui::RichText::new(subtitle)
411                    .color(theme.base_content)
412                    .size(responsive.font_size(ResponsiveFontSize::Large)),
413            );
414        }
415
416        // Loading indicator for timed splashes
417        if config.show_loading && config.duration > 0.0 {
418            ui.add_space(responsive.spacing(ResponsiveSpacing::Large));
419
420            let progress = splash.timer.elapsed_secs() / splash.timer.duration().as_secs_f32();
421            let progress = progress.clamp(0.0, 1.0);
422
423            let time = ui.input(|i| i.time);
424            let dots = match ((time * 2.0) as usize) % 4 {
425                0 => "",
426                1 => ".",
427                2 => "..",
428                3 => "...",
429                _ => "",
430            };
431
432            ui.label(
433                egui::RichText::new(format!("Loading{}", dots))
434                    .color(theme.accent)
435                    .size(responsive.font_size(ResponsiveFontSize::Medium)),
436            );
437
438            ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
439            let progress_bar = egui::ProgressBar::new(progress)
440                .desired_width(200.0)
441                .animate(true);
442            ui.add(progress_bar);
443        }
444
445        // Manual dismissal button
446        if config.manual_dismissal {
447            ui.add_space(responsive.spacing(ResponsiveSpacing::XLarge));
448
449            let button_text = config.button_text.as_deref().unwrap_or("Continue");
450            let button = egui::Button::new(
451                egui::RichText::new(button_text)
452                    .size(responsive.font_size(ResponsiveFontSize::Large)),
453            )
454            .min_size(egui::vec2(120.0, 40.0));
455
456            if ui.add(button).clicked() {
457                dismiss_events.write(SplashDismissed { entity });
458            }
459
460            ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
461            ui.label(
462                egui::RichText::new("Press Space, Enter, or Escape to continue")
463                    .color(theme.base_content.gamma_multiply(0.7))
464                    .size(responsive.font_size(ResponsiveFontSize::Small)),
465            );
466        }
467    });
468}
469
470/// Enhanced logo rendering with actual image support
471fn render_logo_enhanced(
472    ui: &mut egui::Ui,
473    logo: &LogoDisplay,
474    theme: &KonnektorenTheme,
475    responsive: &ResponsiveInfo,
476    size_multiplier: f32,
477    loaded_textures: Option<&LoadedTextures>,
478) {
479    let base_size = if responsive.is_mobile() { 80.0 } else { 100.0 };
480    let logo_size = base_size * size_multiplier;
481
482    match logo {
483        LogoDisplay::None => {
484            // No logo, no space
485        }
486        LogoDisplay::Emoji(emoji) => {
487            ui.label(egui::RichText::new(emoji).size(logo_size));
488            ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
489        }
490        LogoDisplay::Text(text) => {
491            // Create a circular background for text logos
492            let (rect, _) =
493                ui.allocate_exact_size(egui::vec2(logo_size, logo_size), egui::Sense::hover());
494
495            // Draw circular background
496            ui.painter()
497                .circle_filled(rect.center(), logo_size / 2.0, theme.primary);
498
499            // Draw border
500            ui.painter().circle_stroke(
501                rect.center(),
502                logo_size / 2.0,
503                egui::Stroke::new(3.0, theme.primary_content),
504            );
505
506            // Draw text in center
507            ui.painter().text(
508                rect.center(),
509                egui::Align2::CENTER_CENTER,
510                text,
511                egui::FontId::proportional(logo_size * 0.5),
512                theme.primary_content,
513            );
514
515            ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
516        }
517        LogoDisplay::Image(path) => {
518            // Try to render actual image if loaded
519            if let Some(textures) = loaded_textures {
520                if let Some(texture_id) = textures.textures.get(path) {
521                    render_actual_image(ui, texture_id, logo_size, responsive);
522                } else {
523                    render_image_loading(ui, path, theme, responsive, logo_size);
524                }
525            } else {
526                render_image_loading(ui, path, theme, responsive, logo_size);
527            }
528        }
529        LogoDisplay::Custom(renderer) => {
530            renderer(ui, theme, responsive);
531            ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
532        }
533    }
534}
535
536/// Render actual loaded image
537fn render_actual_image(
538    ui: &mut egui::Ui,
539    texture_id: &TextureId,
540    size: f32,
541    responsive: &ResponsiveInfo,
542) {
543    // Create a square image with proper sizing
544    let image_widget = egui::Image::from_texture((*texture_id, egui::vec2(size, size)))
545        .fit_to_exact_size(egui::vec2(size, size))
546        .corner_radius(egui::CornerRadius::same(8));
547
548    ui.add(image_widget);
549    ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
550}
551
552/// Render loading state for image
553fn render_image_loading(
554    ui: &mut egui::Ui,
555    path: &str,
556    theme: &KonnektorenTheme,
557    responsive: &ResponsiveInfo,
558    size: f32,
559) {
560    let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover());
561
562    // Draw placeholder background
563    ui.painter()
564        .rect_filled(rect, egui::CornerRadius::same(8), theme.base_200);
565
566    ui.painter().rect_stroke(
567        rect,
568        egui::CornerRadius::same(8),
569        egui::Stroke::new(2.0, theme.accent),
570        StrokeKind::Outside,
571    );
572
573    // Show loading spinner
574    let time = ui.input(|i| i.time);
575    let angle = time % 2.0 * std::f64::consts::PI;
576    let spinner_center = rect.center();
577    let spinner_radius = size * 0.15;
578
579    for i in 0..8 {
580        let i_angle = angle + (i as f64 * std::f64::consts::PI / 4.0);
581        let alpha = ((8 - i) as f32 / 8.0) * 0.8 + 0.2;
582        let pos = egui::pos2(
583            spinner_center.x + (spinner_radius * i_angle.cos() as f32),
584            spinner_center.y + (spinner_radius * i_angle.sin() as f32),
585        );
586
587        ui.painter()
588            .circle_filled(pos, 3.0, theme.primary.linear_multiply(alpha));
589    }
590
591    // Show filename - Fixed clippy warning by using next_back() instead of last()
592    ui.painter().text(
593        rect.center() + egui::vec2(0.0, size * 0.35),
594        egui::Align2::CENTER_CENTER,
595        format!("Loading {}", path.split('/').next_back().unwrap_or(path)),
596        egui::FontId::proportional(size * 0.08),
597        theme.base_content,
598    );
599
600    ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
601}
602
603/// System to handle splash completion
604fn handle_splash_completion(
605    mut commands: Commands,
606    mut dismiss_events: EventReader<SplashDismissed>,
607) {
608    for event in dismiss_events.read() {
609        info!("Dismissing splash screen for entity {:?}", event.entity);
610
611        // Remove all splash-related components
612        commands
613            .entity(event.entity)
614            .remove::<ActiveSplash>()
615            .remove::<LoadedTextures>()
616            .remove::<LoadingImages>();
617    }
618}
619
620/// Helper trait for easy splash screen setup
621pub trait SplashScreenExt {
622    /// Add a splash screen with the given configuration
623    fn spawn_splash(&mut self, config: SplashConfig) -> Entity;
624
625    /// Add a simple splash screen with just a title
626    fn spawn_simple_splash(&mut self, title: impl Into<String>) -> Entity;
627
628    /// Add a Konnektoren-branded splash screen
629    fn spawn_konnektoren_splash(&mut self) -> Entity;
630}
631
632impl SplashScreenExt for Commands<'_, '_> {
633    fn spawn_splash(&mut self, config: SplashConfig) -> Entity {
634        self.spawn((Name::new("Splash Screen"), config)).id()
635    }
636
637    fn spawn_simple_splash(&mut self, title: impl Into<String>) -> Entity {
638        self.spawn_splash(SplashConfig::new(title))
639    }
640
641    fn spawn_konnektoren_splash(&mut self) -> Entity {
642        self.spawn_splash(SplashConfig::konnektoren())
643    }
644}