konnektoren_bevy/screens/
credits.rs

1use crate::{
2    theme::KonnektorenTheme,
3    ui::{
4        responsive::{ResponsiveFontSize, ResponsiveInfo, ResponsiveSpacing},
5        widgets::{ResponsiveText, ThemedButton},
6    },
7};
8use bevy::prelude::*;
9use bevy_egui::{
10    egui::{self, Widget},
11    EguiContextPass, EguiContexts,
12};
13
14/// Plugin for reusable credits screen functionality
15pub struct CreditsPlugin;
16
17impl Plugin for CreditsPlugin {
18    fn build(&self, app: &mut App) {
19        app.add_event::<CreditsDismissed>()
20            .add_systems(Update, (check_credits_config, handle_credits_completion))
21            .add_systems(EguiContextPass, render_credits_ui);
22    }
23}
24
25/// Configuration for the credits screen
26#[derive(Component, Clone)]
27pub struct CreditsConfig {
28    /// Main title of the application/game
29    pub app_title: String,
30    /// Subtitle describing the credits
31    pub subtitle: String,
32    /// List of team members/contributors with their roles
33    pub team_members: Vec<(String, String)>, // (name, role/description)
34    /// List of assets and their attributions
35    pub assets: Vec<(String, String)>, // (asset name, attribution)
36    /// List of special thanks
37    pub special_thanks: Vec<(String, String)>, // (name, reason)
38    /// List of technologies used
39    pub technologies: Vec<(String, String)>, // (tech name, description)
40    /// Copyright information
41    pub copyright_info: Option<String>,
42    /// Allow manual dismissal (back button/escape)
43    pub manual_dismissal: bool,
44    /// Custom extension widget renderer
45    pub extension_widget: Option<fn(&mut egui::Ui, &KonnektorenTheme, &ResponsiveInfo)>,
46    /// Additional custom sections
47    pub custom_sections: Vec<CustomCreditsSection>,
48    /// Button text for dismissal
49    pub dismiss_button_text: String,
50}
51
52/// Custom section for extending the credits screen
53#[derive(Clone)]
54pub struct CustomCreditsSection {
55    pub title: String,
56    pub renderer: fn(&mut egui::Ui, &KonnektorenTheme, &ResponsiveInfo),
57}
58
59impl Default for CreditsConfig {
60    fn default() -> Self {
61        Self {
62            app_title: "Konnektoren".to_string(),
63            subtitle: "Credits".to_string(),
64            team_members: vec![(
65                "Development Team".to_string(),
66                "Built with passion and dedication".to_string(),
67            )],
68            assets: vec![
69                (
70                    "Icons".to_string(),
71                    "Various sources under Creative Commons".to_string(),
72                ),
73                ("Fonts".to_string(), "Open source typography".to_string()),
74            ],
75            special_thanks: vec![
76                (
77                    "Bevy Community".to_string(),
78                    "For the amazing game engine".to_string(),
79                ),
80                (
81                    "Rust Community".to_string(),
82                    "For the incredible programming language".to_string(),
83                ),
84            ],
85            technologies: vec![
86                (
87                    "Rust".to_string(),
88                    "Safe, fast, and modern programming language".to_string(),
89                ),
90                ("Bevy".to_string(), "Data-driven game engine".to_string()),
91                ("egui".to_string(), "Immediate mode GUI library".to_string()),
92                (
93                    "WebAssembly".to_string(),
94                    "For web browser support".to_string(),
95                ),
96            ],
97            copyright_info: Some("All rights reserved.".to_string()),
98            manual_dismissal: true,
99            extension_widget: None,
100            custom_sections: vec![],
101            dismiss_button_text: "← Back".to_string(),
102        }
103    }
104}
105
106impl CreditsConfig {
107    pub fn new(app_title: impl Into<String>) -> Self {
108        Self {
109            app_title: app_title.into(),
110            ..Default::default()
111        }
112    }
113
114    pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
115        self.subtitle = subtitle.into();
116        self
117    }
118
119    pub fn with_team_members(mut self, team_members: Vec<(String, String)>) -> Self {
120        self.team_members = team_members;
121        self
122    }
123
124    pub fn add_team_member(mut self, name: impl Into<String>, role: impl Into<String>) -> Self {
125        self.team_members.push((name.into(), role.into()));
126        self
127    }
128
129    pub fn with_assets(mut self, assets: Vec<(String, String)>) -> Self {
130        self.assets = assets;
131        self
132    }
133
134    pub fn add_asset(mut self, name: impl Into<String>, attribution: impl Into<String>) -> Self {
135        self.assets.push((name.into(), attribution.into()));
136        self
137    }
138
139    pub fn with_special_thanks(mut self, special_thanks: Vec<(String, String)>) -> Self {
140        self.special_thanks = special_thanks;
141        self
142    }
143
144    pub fn add_special_thanks(
145        mut self,
146        name: impl Into<String>,
147        reason: impl Into<String>,
148    ) -> Self {
149        self.special_thanks.push((name.into(), reason.into()));
150        self
151    }
152
153    pub fn with_technologies(mut self, technologies: Vec<(String, String)>) -> Self {
154        self.technologies = technologies;
155        self
156    }
157
158    pub fn add_technology(
159        mut self,
160        name: impl Into<String>,
161        description: impl Into<String>,
162    ) -> Self {
163        self.technologies.push((name.into(), description.into()));
164        self
165    }
166
167    pub fn with_copyright_info(mut self, info: impl Into<String>) -> Self {
168        self.copyright_info = Some(info.into());
169        self
170    }
171
172    pub fn with_extension_widget(
173        mut self,
174        widget: fn(&mut egui::Ui, &KonnektorenTheme, &ResponsiveInfo),
175    ) -> Self {
176        self.extension_widget = Some(widget);
177        self
178    }
179
180    pub fn add_custom_section(
181        mut self,
182        title: impl Into<String>,
183        renderer: fn(&mut egui::Ui, &KonnektorenTheme, &ResponsiveInfo),
184    ) -> Self {
185        self.custom_sections.push(CustomCreditsSection {
186            title: title.into(),
187            renderer,
188        });
189        self
190    }
191
192    pub fn with_dismiss_button_text(mut self, text: impl Into<String>) -> Self {
193        self.dismiss_button_text = text.into();
194        self
195    }
196
197    /// Create a default game-focused credits config
198    pub fn for_game(title: impl Into<String>) -> Self {
199        Self {
200            app_title: title.into(),
201            subtitle: "Game Credits".to_string(),
202            team_members: vec![
203                (
204                    "Game Designer".to_string(),
205                    "Concept and gameplay design".to_string(),
206                ),
207                (
208                    "Developer".to_string(),
209                    "Programming and implementation".to_string(),
210                ),
211                ("Artist".to_string(), "Visual design and assets".to_string()),
212            ],
213            assets: vec![
214                (
215                    "Game Art".to_string(),
216                    "Original artwork and sprites".to_string(),
217                ),
218                (
219                    "Sound Effects".to_string(),
220                    "Audio design and implementation".to_string(),
221                ),
222                (
223                    "Music".to_string(),
224                    "Background music and themes".to_string(),
225                ),
226            ],
227            ..Default::default()
228        }
229    }
230}
231
232/// Component marking an active credits screen
233#[derive(Component)]
234pub struct ActiveCredits {
235    config: CreditsConfig,
236    navigation_state: CreditsNavigationState,
237}
238
239/// Navigation state for keyboard/gamepad support
240#[derive(Clone)]
241pub struct CreditsNavigationState {
242    pub current_index: usize,
243    pub max_index: usize,
244    pub enabled: bool,
245}
246
247impl Default for CreditsNavigationState {
248    fn default() -> Self {
249        Self {
250            current_index: 0,
251            max_index: 0,
252            enabled: true,
253        }
254    }
255}
256
257/// Event sent when credits screen should be dismissed
258#[derive(Event)]
259pub struct CreditsDismissed {
260    pub entity: Entity,
261}
262
263/// System to check for new credits configurations and set them up
264#[allow(clippy::type_complexity)]
265fn check_credits_config(
266    mut commands: Commands,
267    query: Query<(Entity, &CreditsConfig), (Without<ActiveCredits>, Changed<CreditsConfig>)>,
268    existing_credits: Query<Entity, With<ActiveCredits>>,
269) {
270    for (entity, config) in query.iter() {
271        info!("Setting up credits screen for entity {:?}", entity);
272
273        // Clean up any existing credits screens first
274        for existing_entity in existing_credits.iter() {
275            info!("Cleaning up existing credits screen: {:?}", existing_entity);
276            commands.entity(existing_entity).remove::<ActiveCredits>();
277        }
278
279        // Calculate navigation indices
280        let mut nav_state = CreditsNavigationState {
281            max_index: 0,
282            ..Default::default()
283        };
284        if config.manual_dismissal {
285            nav_state.max_index += 1;
286        }
287
288        commands.entity(entity).insert(ActiveCredits {
289            config: config.clone(),
290            navigation_state: nav_state,
291        });
292    }
293}
294
295/// System to render credits UI
296fn render_credits_ui(
297    mut contexts: EguiContexts,
298    theme: Res<KonnektorenTheme>,
299    responsive: Res<ResponsiveInfo>,
300    mut query: Query<(Entity, &mut ActiveCredits)>,
301    mut dismiss_events: EventWriter<CreditsDismissed>,
302    input: Res<ButtonInput<KeyCode>>,
303) {
304    if query.is_empty() {
305        return;
306    }
307
308    let ctx = contexts.ctx_mut();
309
310    // Only render the first (most recent) credits screen to avoid widget ID conflicts
311    if let Some((entity, mut credits)) = query.iter_mut().next() {
312        // Check dismissal first with separate borrow
313        let should_dismiss = credits.config.manual_dismissal && input.just_pressed(KeyCode::Escape);
314        if should_dismiss {
315            dismiss_events.write(CreditsDismissed { entity });
316            return;
317        }
318
319        // Destructure to get separate borrows
320        let ActiveCredits {
321            config,
322            navigation_state,
323        } = &mut *credits;
324
325        egui::CentralPanel::default()
326            .frame(egui::Frame::NONE.fill(theme.base_100))
327            .show(ctx, |ui| {
328                render_credits_content(
329                    ui,
330                    config,
331                    navigation_state,
332                    &theme,
333                    &responsive,
334                    entity,
335                    &mut dismiss_events,
336                );
337            });
338    }
339}
340
341/// Render credits screen content
342fn render_credits_content(
343    ui: &mut egui::Ui,
344    config: &CreditsConfig,
345    nav_state: &mut CreditsNavigationState,
346    theme: &KonnektorenTheme,
347    responsive: &ResponsiveInfo,
348    entity: Entity,
349    dismiss_events: &mut EventWriter<CreditsDismissed>,
350) {
351    ui.vertical_centered(|ui| {
352        let max_width = if responsive.is_mobile() {
353            ui.available_width() * 0.95
354        } else {
355            800.0_f32.min(ui.available_width() * 0.9)
356        };
357
358        ui.set_max_width(max_width);
359
360        let top_spacing = responsive.spacing(ResponsiveSpacing::Large);
361        ui.add_space(top_spacing);
362
363        // Header section
364        render_credits_header(ui, config, theme, responsive);
365
366        // Main scrollable content
367        let scroll_height = ui.available_height() - 80.0;
368        egui::ScrollArea::vertical()
369            .max_height(scroll_height)
370            .auto_shrink([false; 2])
371            .show(ui, |ui| {
372                render_credits_sections(ui, config, nav_state, theme, responsive);
373            });
374
375        // Back button at bottom
376        if config.manual_dismissal {
377            ui.add_space(responsive.spacing(ResponsiveSpacing::Large));
378            render_credits_dismiss_button(
379                ui,
380                config,
381                theme,
382                responsive,
383                nav_state,
384                entity,
385                dismiss_events,
386            );
387        }
388
389        ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
390    });
391}
392
393fn render_credits_header(
394    ui: &mut egui::Ui,
395    config: &CreditsConfig,
396    theme: &KonnektorenTheme,
397    responsive: &ResponsiveInfo,
398) {
399    ui.vertical_centered(|ui| {
400        // Title
401        ResponsiveText::new(&config.subtitle, ResponsiveFontSize::Title, theme.primary)
402            .responsive(responsive)
403            .strong()
404            .ui(ui);
405
406        ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
407
408        // App title
409        ResponsiveText::new(
410            &config.app_title,
411            ResponsiveFontSize::Large,
412            theme.base_content,
413        )
414        .responsive(responsive)
415        .ui(ui);
416
417        ui.add_space(responsive.spacing(ResponsiveSpacing::Large));
418    });
419}
420
421fn render_credits_sections(
422    ui: &mut egui::Ui,
423    config: &CreditsConfig,
424    _nav_state: &CreditsNavigationState,
425    theme: &KonnektorenTheme,
426    responsive: &ResponsiveInfo,
427) {
428    let section_spacing = responsive.spacing(ResponsiveSpacing::Large);
429
430    // Team members section
431    if !config.team_members.is_empty() {
432        render_credits_section(ui, theme, responsive, "Team", |ui| {
433            for (name, role) in &config.team_members {
434                render_credits_item(ui, theme, responsive, name, role);
435            }
436        });
437        ui.add_space(section_spacing);
438    }
439
440    // Assets section
441    if !config.assets.is_empty() {
442        render_credits_section(ui, theme, responsive, "Assets & Attributions", |ui| {
443            for (asset_name, attribution) in &config.assets {
444                render_credits_item(ui, theme, responsive, asset_name, attribution);
445            }
446        });
447        ui.add_space(section_spacing);
448    }
449
450    // Special thanks section
451    if !config.special_thanks.is_empty() {
452        render_credits_section(ui, theme, responsive, "Special Thanks", |ui| {
453            for (name, reason) in &config.special_thanks {
454                render_credits_item(ui, theme, responsive, name, reason);
455            }
456        });
457        ui.add_space(section_spacing);
458    }
459
460    // Custom extension widget
461    if let Some(extension_widget) = config.extension_widget {
462        extension_widget(ui, theme, responsive);
463        ui.add_space(section_spacing);
464    }
465
466    // Custom sections
467    for custom_section in &config.custom_sections {
468        render_credits_section(ui, theme, responsive, &custom_section.title, |ui| {
469            (custom_section.renderer)(ui, theme, responsive);
470        });
471        ui.add_space(section_spacing);
472    }
473
474    // Technology section
475    if !config.technologies.is_empty() {
476        render_credits_section(ui, theme, responsive, "Built With", |ui| {
477            for (tech_name, description) in &config.technologies {
478                render_tech_item(ui, theme, responsive, tech_name, description);
479            }
480        });
481        ui.add_space(section_spacing);
482    }
483
484    // Copyright information
485    if let Some(copyright_info) = &config.copyright_info {
486        ui.vertical_centered(|ui| {
487            ResponsiveText::new(copyright_info, ResponsiveFontSize::Small, theme.accent)
488                .responsive(responsive)
489                .ui(ui);
490        });
491        ui.add_space(section_spacing);
492    }
493
494    ui.add_space(responsive.spacing(ResponsiveSpacing::XLarge));
495}
496
497fn render_credits_section<F>(
498    ui: &mut egui::Ui,
499    theme: &KonnektorenTheme,
500    responsive: &ResponsiveInfo,
501    title: &str,
502    content: F,
503) where
504    F: FnOnce(&mut egui::Ui),
505{
506    let margin = if responsive.is_mobile() { 12 } else { 16 };
507    let frame = egui::Frame {
508        inner_margin: egui::Margin::same(margin),
509        corner_radius: egui::CornerRadius::same(8),
510        fill: theme.base_200,
511        stroke: egui::Stroke::new(1.0, theme.accent.linear_multiply(0.3)),
512        ..Default::default()
513    };
514
515    frame.show(ui, |ui| {
516        ResponsiveText::new(title, ResponsiveFontSize::Large, theme.primary)
517            .responsive(responsive)
518            .strong()
519            .ui(ui);
520
521        ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
522
523        content(ui);
524    });
525}
526
527fn render_credits_item(
528    ui: &mut egui::Ui,
529    theme: &KonnektorenTheme,
530    responsive: &ResponsiveInfo,
531    name: &str,
532    description: &str,
533) {
534    if responsive.is_mobile() {
535        ui.vertical(|ui| {
536            ResponsiveText::new(name, ResponsiveFontSize::Medium, theme.secondary)
537                .responsive(responsive)
538                .strong()
539                .ui(ui);
540            ResponsiveText::new(description, ResponsiveFontSize::Small, theme.base_content)
541                .responsive(responsive)
542                .ui(ui);
543        });
544    } else {
545        ui.horizontal(|ui| {
546            ResponsiveText::new(name, ResponsiveFontSize::Medium, theme.secondary)
547                .responsive(responsive)
548                .strong()
549                .ui(ui);
550            ui.label(" - ");
551            ResponsiveText::new(description, ResponsiveFontSize::Medium, theme.base_content)
552                .responsive(responsive)
553                .ui(ui);
554        });
555    }
556
557    ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
558}
559
560fn render_tech_item(
561    ui: &mut egui::Ui,
562    theme: &KonnektorenTheme,
563    responsive: &ResponsiveInfo,
564    tech_name: &str,
565    description: &str,
566) {
567    ui.horizontal(|ui| {
568        let tech_badge = egui::Frame {
569            inner_margin: egui::Margin::symmetric(8, 4),
570            corner_radius: egui::CornerRadius::same(4),
571            fill: theme.info,
572            ..Default::default()
573        };
574
575        tech_badge.show(ui, |ui| {
576            ResponsiveText::new(tech_name, ResponsiveFontSize::Small, theme.primary_content)
577                .responsive(responsive)
578                .strong()
579                .ui(ui);
580        });
581
582        ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
583
584        ResponsiveText::new(description, ResponsiveFontSize::Medium, theme.base_content)
585            .responsive(responsive)
586            .ui(ui);
587    });
588
589    ui.add_space(responsive.spacing(ResponsiveSpacing::XSmall));
590}
591
592fn render_credits_dismiss_button(
593    ui: &mut egui::Ui,
594    config: &CreditsConfig,
595    theme: &KonnektorenTheme,
596    responsive: &ResponsiveInfo,
597    _nav_state: &CreditsNavigationState,
598    entity: Entity,
599    dismiss_events: &mut EventWriter<CreditsDismissed>,
600) {
601    ui.vertical_centered(|ui| {
602        let back_button = ThemedButton::new(&config.dismiss_button_text, theme)
603            .responsive(responsive)
604            .width(if responsive.is_mobile() { 200.0 } else { 250.0 });
605
606        if ui.add(back_button).clicked() {
607            dismiss_events.write(CreditsDismissed { entity });
608        }
609    });
610}
611
612/// System to handle credits completion
613fn handle_credits_completion(
614    mut commands: Commands,
615    mut dismiss_events: EventReader<CreditsDismissed>,
616) {
617    for event in dismiss_events.read() {
618        info!("Dismissing credits screen for entity {:?}", event.entity);
619        commands.entity(event.entity).remove::<ActiveCredits>();
620    }
621}
622
623/// Helper trait for easy credits screen setup
624pub trait CreditsScreenExt {
625    /// Add a credits screen with the given configuration
626    fn spawn_credits(&mut self, config: CreditsConfig) -> Entity;
627
628    /// Add a simple credits screen with just a title
629    fn spawn_simple_credits(&mut self, title: impl Into<String>) -> Entity;
630
631    /// Add a game-focused credits screen
632    fn spawn_game_credits(&mut self, title: impl Into<String>) -> Entity;
633}
634
635impl CreditsScreenExt for Commands<'_, '_> {
636    fn spawn_credits(&mut self, config: CreditsConfig) -> Entity {
637        self.spawn((Name::new("Credits Screen"), config)).id()
638    }
639
640    fn spawn_simple_credits(&mut self, title: impl Into<String>) -> Entity {
641        self.spawn_credits(CreditsConfig::new(title))
642    }
643
644    fn spawn_game_credits(&mut self, title: impl Into<String>) -> Entity {
645        self.spawn_credits(CreditsConfig::for_game(title))
646    }
647}