1use bevy::prelude::*;
6
7const TEXT_COLOR: Color = Color::srgb(0.9, 0.9, 0.9);
8
9#[derive(Clone, Copy, Default, Eq, PartialEq, Debug, Hash, States)]
11enum GameState {
12 #[default]
13 Splash,
14 Menu,
15 Game,
16}
17
18#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)]
20enum DisplayQuality {
21 Low,
22 Medium,
23 High,
24}
25
26#[derive(Component)]
27struct Setting<T>(T);
28
29#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)]
31struct Volume(u32);
32
33fn main() {
34 App::new()
35 .add_plugins(DefaultPlugins)
36 .insert_resource(DisplayQuality::Medium)
38 .insert_resource(Volume(7))
39 .init_state::<GameState>()
41 .add_systems(Startup, setup)
42 .add_plugins((splash::splash_plugin, menu::menu_plugin, game::game_plugin))
44 .run();
45}
46
47fn setup(mut commands: Commands) {
48 commands.spawn(Camera2d);
49}
50
51mod splash {
52 use bevy::prelude::*;
53
54 use super::GameState;
55
56 pub fn splash_plugin(app: &mut App) {
58 app
60 .add_systems(OnEnter(GameState::Splash), splash_setup)
62 .add_systems(Update, countdown.run_if(in_state(GameState::Splash)));
64 }
65
66 #[derive(Component)]
68 struct OnSplashScreen;
69
70 #[derive(Resource, Deref, DerefMut)]
72 struct SplashTimer(Timer);
73
74 fn splash_setup(mut commands: Commands, asset_server: Res<AssetServer>) {
75 let icon = asset_server.load("branding/icon.png");
76 commands.spawn((
78 DespawnOnExit(GameState::Splash),
80 Node {
81 align_items: AlignItems::Center,
82 justify_content: JustifyContent::Center,
83 width: percent(100),
84 height: percent(100),
85 ..default()
86 },
87 OnSplashScreen,
88 children![(
89 ImageNode::new(icon),
90 Node {
91 width: px(200),
93 ..default()
94 },
95 )],
96 ));
97 commands.insert_resource(SplashTimer(Timer::from_seconds(1.0, TimerMode::Once)));
99 }
100
101 fn countdown(
103 mut game_state: ResMut<NextState<GameState>>,
104 time: Res<Time>,
105 mut timer: ResMut<SplashTimer>,
106 ) {
107 if timer.tick(time.delta()).is_finished() {
108 game_state.set(GameState::Menu);
109 }
110 }
111}
112
113mod game {
114 use bevy::{
115 color::palettes::basic::{BLUE, LIME},
116 prelude::*,
117 };
118
119 use super::{DisplayQuality, GameState, Volume, TEXT_COLOR};
120
121 pub fn game_plugin(app: &mut App) {
124 app.add_systems(OnEnter(GameState::Game), game_setup)
125 .add_systems(Update, game.run_if(in_state(GameState::Game)));
126 }
127
128 #[derive(Component)]
130 struct OnGameScreen;
131
132 #[derive(Resource, Deref, DerefMut)]
133 struct GameTimer(Timer);
134
135 fn game_setup(
136 mut commands: Commands,
137 display_quality: Res<DisplayQuality>,
138 volume: Res<Volume>,
139 ) {
140 commands.spawn((
141 DespawnOnExit(GameState::Game),
142 Node {
143 width: percent(100),
144 height: percent(100),
145 align_items: AlignItems::Center,
147 justify_content: JustifyContent::Center,
148 ..default()
149 },
150 OnGameScreen,
151 children![(
152 Node {
153 flex_direction: FlexDirection::Column,
155 align_items: AlignItems::Center,
159 ..default()
160 },
161 BackgroundColor(Color::BLACK),
162 children![
163 (
164 Text::new("Will be back to the menu shortly..."),
165 TextFont {
166 font_size: FontSize::Px(67.0),
167 ..default()
168 },
169 TextColor(TEXT_COLOR),
170 Node {
171 margin: UiRect::all(px(50)),
172 ..default()
173 },
174 ),
175 (
176 Text::default(),
177 Node {
178 margin: UiRect::all(px(50)),
179 ..default()
180 },
181 children![
182 (
183 TextSpan(format!("quality: {:?}", *display_quality)),
184 TextFont {
185 font_size: FontSize::Px(50.0),
186 ..default()
187 },
188 TextColor(BLUE.into()),
189 ),
190 (
191 TextSpan::new(" - "),
192 TextFont {
193 font_size: FontSize::Px(50.0),
194 ..default()
195 },
196 TextColor(TEXT_COLOR),
197 ),
198 (
199 TextSpan(format!("volume: {:?}", *volume)),
200 TextFont {
201 font_size: FontSize::Px(50.0),
202 ..default()
203 },
204 TextColor(LIME.into()),
205 ),
206 ]
207 ),
208 ]
209 )],
210 ));
211 commands.insert_resource(GameTimer(Timer::from_seconds(5.0, TimerMode::Once)));
213 }
214
215 fn game(
217 time: Res<Time>,
218 mut game_state: ResMut<NextState<GameState>>,
219 mut timer: ResMut<GameTimer>,
220 ) {
221 if timer.tick(time.delta()).is_finished() {
222 game_state.set(GameState::Menu);
223 }
224 }
225}
226
227mod menu {
228 use bevy::{
229 app::AppExit,
230 color::palettes::css::CRIMSON,
231 ecs::component::Mutable,
232 ecs::spawn::{SpawnIter, SpawnWith},
233 picking::prelude::*,
234 prelude::*,
235 ui::Checked,
236 ui_widgets::{Activate, ActivateOnPress, Button, RadioButton, RadioGroup, ValueChange},
237 };
238
239 use super::{DisplayQuality, GameState, Setting, Volume, TEXT_COLOR};
240
241 pub fn menu_plugin(app: &mut App) {
246 app
247 .init_state::<MenuState>()
251 .add_systems(OnEnter(GameState::Menu), menu_setup)
252 .add_systems(OnEnter(MenuState::Main), main_menu_setup)
254 .add_systems(OnEnter(MenuState::Settings), settings_menu_setup)
256 .add_systems(
258 OnEnter(MenuState::SettingsDisplay),
259 display_settings_menu_setup,
260 )
261 .add_observer(
262 on_value_change_setting_radio::<DisplayQuality>
263 .run_if(in_state(MenuState::SettingsDisplay)),
264 )
265 .add_systems(OnEnter(MenuState::SettingsSound), sound_settings_menu_setup)
267 .add_observer(
268 on_value_change_setting_radio::<Volume>.run_if(in_state(MenuState::SettingsSound)),
269 )
270 .add_observer(on_button_activate_update_states)
272 .add_observer(on_button_over_style)
274 .add_observer(on_button_out_style)
275 .add_observer(on_radio_button_press_style)
276 .add_observer(on_radio_button_release_style);
277 }
278
279 #[derive(Clone, Copy, Default, Eq, PartialEq, Debug, Hash, States)]
281 enum MenuState {
282 Main,
283 Settings,
284 SettingsDisplay,
285 SettingsSound,
286 #[default]
287 Disabled,
288 }
289
290 #[derive(Component)]
292 struct OnMainMenuScreen;
293
294 #[derive(Component)]
296 struct OnSettingsMenuScreen;
297
298 #[derive(Component)]
300 struct OnDisplaySettingsMenuScreen;
301
302 #[derive(Component)]
304 struct OnSoundSettingsMenuScreen;
305
306 const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
307 const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
308 const HOVERED_PRESSED_BUTTON: Color = Color::srgb(0.25, 0.65, 0.25);
309 const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
310
311 #[derive(Component)]
313 enum MenuButtonAction {
314 Play,
315 Settings,
316 SettingsDisplay,
317 SettingsSound,
318 BackToMainMenu,
319 BackToSettings,
320 Quit,
321 }
322
323 fn on_button_over_style(
325 event: On<PointerOver>,
326 mut button_q: Query<&mut BackgroundColor, (With<Button>, Without<RadioButton>)>,
327 mut radio_button_q: Query<
328 (&mut BackgroundColor, Has<Checked>),
329 (With<RadioButton>, Without<Button>),
330 >,
331 ) {
332 if let Ok(mut background_color) = button_q.get_mut(event.entity) {
333 *background_color = HOVERED_BUTTON.into();
334 }
335 if let Ok((mut background_color, is_checked)) = radio_button_q.get_mut(event.entity) {
336 if is_checked {
337 *background_color = HOVERED_PRESSED_BUTTON.into();
338 } else {
339 *background_color = HOVERED_BUTTON.into();
340 }
341 }
342 }
343
344 fn on_button_out_style(
346 event: On<PointerOut>,
347 mut bg_q: Query<&mut BackgroundColor, (With<Button>, Without<RadioButton>)>,
348 mut radio_button_q: Query<
349 (&mut BackgroundColor, Has<Checked>),
350 (With<RadioButton>, Without<Button>),
351 >,
352 ) {
353 if let Ok(mut background_color) = bg_q.get_mut(event.entity) {
354 *background_color = NORMAL_BUTTON.into();
355 }
356 if let Ok((mut background_color, is_checked)) = radio_button_q.get_mut(event.entity) {
357 if is_checked {
358 *background_color = PRESSED_BUTTON.into();
359 } else {
360 *background_color = NORMAL_BUTTON.into();
361 }
362 }
363 }
364
365 fn on_radio_button_press_style(
367 event: On<PointerPress>,
368 mut radio_button_q: Query<&mut BackgroundColor, With<RadioButton>>,
369 ) {
370 if let Ok(mut background_color) = radio_button_q.get_mut(event.entity) {
371 *background_color = PRESSED_BUTTON.into();
372 }
373 }
374
375 fn on_radio_button_release_style(
377 event: On<PointerRelease>,
378 mut radio_button_q: Query<(&mut BackgroundColor, Has<Checked>), With<RadioButton>>,
379 ) {
380 if let Ok((mut background_color, is_checked)) = radio_button_q.get_mut(event.entity) {
381 if is_checked {
382 *background_color = HOVERED_PRESSED_BUTTON.into();
383 } else {
384 *background_color = HOVERED_BUTTON.into();
385 }
386 }
387 }
388
389 fn on_value_change_setting_radio<
392 T: Resource<Mutability = Mutable> + Component + PartialEq + Copy,
393 >(
394 event: On<ValueChange<Entity>>,
395 new_setting_query: Query<(&Setting<T>, Entity), (With<RadioButton>, Without<Checked>)>,
396 previous_query: Single<(Entity, &mut BackgroundColor), (With<RadioButton>, With<Checked>)>,
397 mut commands: Commands,
398 mut setting: ResMut<T>,
399 ) {
400 if let Ok((radio_setting, entity)) = new_setting_query.get(event.value)
401 && *setting != radio_setting.0
402 {
403 let (previous, mut previous_button_color) = previous_query.into_inner();
404 *previous_button_color = NORMAL_BUTTON.into();
405 commands.entity(previous).remove::<Checked>();
406 commands.entity(entity).insert(Checked);
407 *setting = radio_setting.0;
408 }
409 }
410
411 fn menu_setup(mut menu_state: ResMut<NextState<MenuState>>) {
412 menu_state.set(MenuState::Main);
413 }
414
415 fn main_menu_setup(mut commands: Commands, asset_server: Res<AssetServer>) {
416 let button_node = Node {
418 width: px(300),
419 height: px(65),
420 margin: UiRect::all(px(20)),
421 justify_content: JustifyContent::Center,
422 align_items: AlignItems::Center,
423 ..default()
424 };
425 let button_icon_node = Node {
426 width: px(30),
427 position_type: PositionType::Absolute,
429 left: px(10),
431 ..default()
432 };
433 let button_text_font = TextFont {
434 font_size: FontSize::Px(33.0),
435 ..default()
436 };
437
438 let right_icon = asset_server.load("textures/Game Icons/right.png");
439 let wrench_icon = asset_server.load("textures/Game Icons/wrench.png");
440 let exit_icon = asset_server.load("textures/Game Icons/exitRight.png");
441
442 commands.spawn((
443 DespawnOnExit(MenuState::Main),
444 Node {
445 width: percent(100),
446 height: percent(100),
447 align_items: AlignItems::Center,
448 justify_content: JustifyContent::Center,
449 ..default()
450 },
451 OnMainMenuScreen,
452 children![(
453 Node {
454 flex_direction: FlexDirection::Column,
455 align_items: AlignItems::Center,
456 ..default()
457 },
458 BackgroundColor(CRIMSON.into()),
459 children![
460 (
462 Text::new("Bevy Game Menu UI"),
463 TextFont {
464 font_size: FontSize::Px(67.0),
465 ..default()
466 },
467 TextColor(TEXT_COLOR),
468 Node {
469 margin: UiRect::all(px(50)),
470 ..default()
471 },
472 ),
473 (
478 Button,
479 ActivateOnPress,
480 button_node.clone(),
481 BackgroundColor(NORMAL_BUTTON),
482 MenuButtonAction::Play,
483 children![
484 (ImageNode::new(right_icon), button_icon_node.clone()),
485 (
486 Text::new("New Game"),
487 button_text_font.clone(),
488 TextColor(TEXT_COLOR),
489 ),
490 ]
491 ),
492 (
493 Button,
494 ActivateOnPress,
495 button_node.clone(),
496 BackgroundColor(NORMAL_BUTTON),
497 MenuButtonAction::Settings,
498 children![
499 (ImageNode::new(wrench_icon), button_icon_node.clone()),
500 (
501 Text::new("Settings"),
502 button_text_font.clone(),
503 TextColor(TEXT_COLOR),
504 ),
505 ]
506 ),
507 (
508 Button,
509 ActivateOnPress,
510 button_node,
511 BackgroundColor(NORMAL_BUTTON),
512 MenuButtonAction::Quit,
513 children![
514 (ImageNode::new(exit_icon), button_icon_node),
515 (Text::new("Quit"), button_text_font, TextColor(TEXT_COLOR),),
516 ]
517 ),
518 ]
519 )],
520 ));
521 }
522
523 fn settings_menu_setup(mut commands: Commands) {
524 let button_node = Node {
525 width: px(200),
526 height: px(65),
527 margin: UiRect::all(px(20)),
528 justify_content: JustifyContent::Center,
529 align_items: AlignItems::Center,
530 ..default()
531 };
532
533 let button_text_style = (
534 TextFont {
535 font_size: FontSize::Px(33.0),
536 ..default()
537 },
538 TextColor(TEXT_COLOR),
539 );
540
541 commands.spawn((
542 DespawnOnExit(MenuState::Settings),
543 Node {
544 width: percent(100),
545 height: percent(100),
546 align_items: AlignItems::Center,
547 justify_content: JustifyContent::Center,
548 ..default()
549 },
550 OnSettingsMenuScreen,
551 children![(
552 Node {
553 flex_direction: FlexDirection::Column,
554 align_items: AlignItems::Center,
555 ..default()
556 },
557 BackgroundColor(CRIMSON.into()),
558 Children::spawn(SpawnIter(
559 [
560 (MenuButtonAction::SettingsDisplay, "Display"),
561 (MenuButtonAction::SettingsSound, "Sound"),
562 (MenuButtonAction::BackToMainMenu, "Back"),
563 ]
564 .into_iter()
565 .map(move |(action, text)| {
566 (
567 Button,
568 ActivateOnPress,
569 button_node.clone(),
570 BackgroundColor(NORMAL_BUTTON),
571 action,
572 children![(Text::new(text), button_text_style.clone())],
573 )
574 })
575 ))
576 )],
577 ));
578 }
579
580 fn display_settings_menu_setup(mut commands: Commands, display_quality: Res<DisplayQuality>) {
581 fn button_node() -> Node {
582 Node {
583 width: px(200),
584 height: px(65),
585 margin: UiRect::all(px(20)),
586 justify_content: JustifyContent::Center,
587 align_items: AlignItems::Center,
588 ..default()
589 }
590 }
591 fn button_text_style() -> impl Bundle {
592 (
593 TextFont {
594 font_size: FontSize::Px(33.0),
595 ..default()
596 },
597 TextColor(TEXT_COLOR),
598 )
599 }
600
601 let display_quality = *display_quality;
602 commands.spawn((
603 DespawnOnExit(MenuState::SettingsDisplay),
604 Node {
605 width: percent(100),
606 height: percent(100),
607 align_items: AlignItems::Center,
608 justify_content: JustifyContent::Center,
609 ..default()
610 },
611 OnDisplaySettingsMenuScreen,
612 children![(
613 Node {
614 flex_direction: FlexDirection::Column,
615 align_items: AlignItems::Center,
616 ..default()
617 },
618 BackgroundColor(CRIMSON.into()),
619 children![
620 (
625 RadioGroup,
626 Node {
627 align_items: AlignItems::Center,
628 ..default()
629 },
630 BackgroundColor(CRIMSON.into()),
631 Children::spawn((
632 Spawn((Text::new("Display Quality"), button_text_style())),
634 SpawnWith(move |parent: &mut ChildSpawner| {
635 for quality_setting in [
636 DisplayQuality::Low,
637 DisplayQuality::Medium,
638 DisplayQuality::High,
639 ] {
640 let mut entity = parent.spawn((
641 RadioButton,
642 Node {
643 width: px(150),
644 height: px(65),
645 ..button_node()
646 },
647 BackgroundColor(NORMAL_BUTTON),
648 Setting(quality_setting),
649 children![(
650 Text::new(format!("{quality_setting:?}")),
651 button_text_style(),
652 )],
653 ));
654 if display_quality == quality_setting {
655 entity.insert((Checked, BackgroundColor(PRESSED_BUTTON)));
656 }
657 }
658 })
659 ))
660 ),
661 (
663 Button,
664 ActivateOnPress,
665 button_node(),
666 BackgroundColor(NORMAL_BUTTON),
667 MenuButtonAction::BackToSettings,
668 children![(Text::new("Back"), button_text_style())]
669 )
670 ]
671 )],
672 ));
673 }
674
675 fn sound_settings_menu_setup(mut commands: Commands, volume: Res<Volume>) {
676 let button_node = Node {
677 width: px(200),
678 height: px(65),
679 margin: UiRect::all(px(20)),
680 justify_content: JustifyContent::Center,
681 align_items: AlignItems::Center,
682 ..default()
683 };
684 let button_text_style = (
685 TextFont {
686 font_size: FontSize::Px(33.0),
687 ..default()
688 },
689 TextColor(TEXT_COLOR),
690 );
691
692 let volume = *volume;
693 let button_node_clone = button_node.clone();
694 commands.spawn((
695 DespawnOnExit(MenuState::SettingsSound),
696 Node {
697 width: percent(100),
698 height: percent(100),
699 align_items: AlignItems::Center,
700 justify_content: JustifyContent::Center,
701 ..default()
702 },
703 OnSoundSettingsMenuScreen,
704 children![(
705 Node {
706 flex_direction: FlexDirection::Column,
707 align_items: AlignItems::Center,
708 ..default()
709 },
710 BackgroundColor(CRIMSON.into()),
711 children![
712 (
713 RadioGroup,
714 Node {
715 align_items: AlignItems::Center,
716 ..default()
717 },
718 BackgroundColor(CRIMSON.into()),
719 Children::spawn((
720 Spawn((Text::new("Volume"), button_text_style.clone())),
721 SpawnWith(move |parent: &mut ChildSpawner| {
722 for volume_setting in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] {
723 let mut entity = parent.spawn((
724 RadioButton,
725 Node {
726 width: px(30),
727 height: px(65),
728 ..button_node_clone.clone()
729 },
730 BackgroundColor(NORMAL_BUTTON),
731 Setting(Volume(volume_setting)),
732 ));
733 if volume == Volume(volume_setting) {
734 entity.insert((Checked, BackgroundColor(PRESSED_BUTTON)));
735 }
736 }
737 })
738 ))
739 ),
740 (
741 Button,
742 ActivateOnPress,
743 button_node,
744 BackgroundColor(NORMAL_BUTTON),
745 MenuButtonAction::BackToSettings,
746 children![(Text::new("Back"), button_text_style)]
747 )
748 ]
749 )],
750 ));
751 }
752
753 fn on_button_activate_update_states(
756 event: On<Activate>,
757 action_q: Query<&MenuButtonAction, With<Button>>,
758 mut app_exit_writer: MessageWriter<AppExit>,
759 mut menu_state: ResMut<NextState<MenuState>>,
760 mut game_state: ResMut<NextState<GameState>>,
761 ) {
762 if let Ok(menu_button_action) = action_q.get(event.entity) {
763 match menu_button_action {
764 MenuButtonAction::Quit => {
765 app_exit_writer.write(AppExit::Success);
766 }
767 MenuButtonAction::Play => {
768 game_state.set(GameState::Game);
769 menu_state.set(MenuState::Disabled);
770 }
771 MenuButtonAction::Settings => menu_state.set(MenuState::Settings),
772 MenuButtonAction::SettingsDisplay => {
773 menu_state.set(MenuState::SettingsDisplay);
774 }
775 MenuButtonAction::SettingsSound => {
776 menu_state.set(MenuState::SettingsSound);
777 }
778 MenuButtonAction::BackToMainMenu => menu_state.set(MenuState::Main),
779 MenuButtonAction::BackToSettings => {
780 menu_state.set(MenuState::Settings);
781 }
782 }
783 }
784 }
785}