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
12pub 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#[derive(Component)]
33pub struct LoadedTextures {
34 pub textures: HashMap<String, TextureId>,
35 pub handles: HashMap<String, Handle<Image>>,
37}
38
39#[derive(Component)]
41pub struct LoadingImages {
42 pub handles: HashMap<String, Handle<Image>>,
43}
44
45#[derive(Clone, Debug)]
47pub enum LogoDisplay {
48 None,
50 Emoji(String),
52 Text(String),
54 Image(String),
56 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#[derive(Component, Clone)]
68pub struct SplashConfig {
69 pub logo: LogoDisplay,
71 pub title: String,
73 pub subtitle: Option<String>,
75 pub duration: f32,
77 pub auto_transition: bool,
79 pub manual_dismissal: bool,
81 pub background_color: Option<egui::Color32>,
83 pub button_text: Option<String>,
85 pub show_loading: bool,
87 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 pub fn with_emoji_logo(mut self, emoji: impl Into<String>) -> Self {
118 self.logo = LogoDisplay::Emoji(emoji.into());
119 self
120 }
121
122 pub fn with_text_logo(mut self, text: impl Into<String>) -> Self {
124 self.logo = LogoDisplay::Text(text.into());
125 self
126 }
127
128 pub fn with_image_logo(mut self, path: impl Into<String>) -> Self {
130 self.logo = LogoDisplay::Image(path.into());
131 self
132 }
133
134 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 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 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 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#[derive(Component)]
216pub struct ActiveSplash {
217 timer: Timer,
218 config: SplashConfig,
219}
220
221#[derive(Event)]
223pub struct SplashDismissed {
224 pub entity: Entity,
225}
226
227#[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 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) };
242
243 commands.entity(entity).insert(ActiveSplash {
245 timer,
246 config: config.clone(),
247 });
248 }
249}
250
251#[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 active_query: Query<(Entity, &ActiveSplash), (Without<LoadedTextures>, Without<LoadingImages>)>,
260 mut loading_query: Query<(Entity, &ActiveSplash, &mut LoadingImages), Without<LoadedTextures>>,
262) {
263 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 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 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 if let Some(_image) = images.get(image_handle) {
284 info!("Image loaded successfully: {}", path);
285
286 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 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
305fn 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
323fn 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 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 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 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#[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_enhanced(
390 ui,
391 &config.logo,
392 theme,
393 responsive,
394 config.logo_size_multiplier,
395 loaded_textures,
396 );
397
398 ui.heading(
400 egui::RichText::new(&config.title)
401 .color(theme.primary)
402 .size(responsive.font_size(ResponsiveFontSize::Title))
403 .strong(),
404 );
405
406 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 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 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
470fn 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 }
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 let (rect, _) =
493 ui.allocate_exact_size(egui::vec2(logo_size, logo_size), egui::Sense::hover());
494
495 ui.painter()
497 .circle_filled(rect.center(), logo_size / 2.0, theme.primary);
498
499 ui.painter().circle_stroke(
501 rect.center(),
502 logo_size / 2.0,
503 egui::Stroke::new(3.0, theme.primary_content),
504 );
505
506 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 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
536fn render_actual_image(
538 ui: &mut egui::Ui,
539 texture_id: &TextureId,
540 size: f32,
541 responsive: &ResponsiveInfo,
542) {
543 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
552fn 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 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 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 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
603fn 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 commands
613 .entity(event.entity)
614 .remove::<ActiveSplash>()
615 .remove::<LoadedTextures>()
616 .remove::<LoadingImages>();
617 }
618}
619
620pub trait SplashScreenExt {
622 fn spawn_splash(&mut self, config: SplashConfig) -> Entity;
624
625 fn spawn_simple_splash(&mut self, title: impl Into<String>) -> Entity;
627
628 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}