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
14pub 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#[derive(Component, Clone)]
27pub struct CreditsConfig {
28 pub app_title: String,
30 pub subtitle: String,
32 pub team_members: Vec<(String, String)>, pub assets: Vec<(String, String)>, pub special_thanks: Vec<(String, String)>, pub technologies: Vec<(String, String)>, pub copyright_info: Option<String>,
42 pub manual_dismissal: bool,
44 pub extension_widget: Option<fn(&mut egui::Ui, &KonnektorenTheme, &ResponsiveInfo)>,
46 pub custom_sections: Vec<CustomCreditsSection>,
48 pub dismiss_button_text: String,
50}
51
52#[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 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#[derive(Component)]
234pub struct ActiveCredits {
235 config: CreditsConfig,
236 navigation_state: CreditsNavigationState,
237}
238
239#[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#[derive(Event)]
259pub struct CreditsDismissed {
260 pub entity: Entity,
261}
262
263#[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 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 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
295fn 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 if let Some((entity, mut credits)) = query.iter_mut().next() {
312 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 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
341fn 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 render_credits_header(ui, config, theme, responsive);
365
366 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 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 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 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 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 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 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 if let Some(extension_widget) = config.extension_widget {
462 extension_widget(ui, theme, responsive);
463 ui.add_space(section_spacing);
464 }
465
466 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 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 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
612fn 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
623pub trait CreditsScreenExt {
625 fn spawn_credits(&mut self, config: CreditsConfig) -> Entity;
627
628 fn spawn_simple_credits(&mut self, title: impl Into<String>) -> Entity;
630
631 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}