use crate::AppState; use crate::focus_navigation::{NavigationInputState, NavigationSystems}; use crate::save::{ActiveGameSave, create_game_save, delete_game_save, get_saves}; use crate::ui_logic::{InteractionColors, Selected, scale_ui, style_ui_elements}; use bevy::color::palettes::tailwind::SLATE_900; use bevy::input::gamepad::GamepadButton; use bevy::input_focus::{ AutoFocus, FocusCause, InputFocus, InputFocusVisible, tab_navigation::{TabGroup, TabIndex}, }; use bevy::picking::hover::Hovered; use bevy::text::{EditableText, EditableTextFilter, TextCursorStyle}; use bevy::ui::auto_directional_navigation::AutoDirectionalNavigation; use bevy::ui_widgets::{Button, TextInput}; use bevy::{prelude::*, ui_widgets::Activate}; use derive_more::{Deref, DerefMut}; pub struct MainMenuPlugin; impl Plugin for MainMenuPlugin { fn build(&self, app: &mut App) { app.add_systems(OnEnter(AppState::Menu), setup_menu) .add_systems(OnExit(AppState::Menu), cleanup_menu) .add_systems(Update, (menu_redraw).run_if(in_state(AppState::Menu))) .add_observer(button_activate_observer) .add_systems(Update, (scale_ui, style_ui_elements)) .add_systems( PreUpdate, focus_rightmost_layer_on_navigation .in_set(NavigationSystems::Initialize) .run_if(in_state(AppState::Menu)), ); } } /// The number of layers that the main menu is sized for. const N_LAYERS: usize = 5; /// The text field for the name of the game. #[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)] struct GameNameTextField; /// The root of the menu #[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)] struct MenuRoot; /// The layer that the page is on. #[derive( Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug, Deref, DerefMut, )] struct Layer(usize); /// A component that indicates the layer that this button points to. #[derive(Component, Clone, PartialEq, Eq, Debug, Default)] struct NextLayer(MenuLayer); #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] pub enum MenuLayer { #[default] Root, Play, LoadSave, NewGame, // TODO: replace with some better game identifier, probably the game metadata GameInfo(String), Browse, DirectConnect, Settings, Controls, Graphics, } impl MenuLayer { fn render(&self, layer: usize, menu_root: Entity) -> Box { match self { MenuLayer::Root => Box::new(bsn! { @menu_layer(layer) ChildOf(menu_root) Children [ @menu_button("Play", Some(MenuLayer::Play)) -- @menu_button("Settings", Some(MenuLayer::Settings)) -- @menu_button("Quit", None) on(|_event: On, mut exit: MessageWriter| { exit.write(AppExit::Success); } ) InteractionColors::DANGER ] }), MenuLayer::Play => Box::new(bsn! { @menu_layer(layer) ChildOf(menu_root) Children [ @menu_button("Load Save", Some(MenuLayer::LoadSave)) -- @menu_button("Browse Games", Some(MenuLayer::Browse)) -- @menu_button("Direct Connect", Some(MenuLayer::DirectConnect)) ] }), MenuLayer::LoadSave => { // TODO: this needs to be on an IO thread let saves = get_saves().unwrap_or_default(); let entries: Vec<_> = saves .into_iter() .map(|m| { menu_button(&m.save_name, Some(MenuLayer::GameInfo(m.save_name.clone()))) }) .collect(); // TODO: get rid of row gap when many saves are present. Box::new(bsn! { @menu_layer(layer) ChildOf(menu_root) Children [ {entries} -- @menu_button("New Game", Some(MenuLayer::NewGame)) InteractionColors::GOOD ] }) } MenuLayer::NewGame => Box::new(bsn! { @full_menu_layer(layer) ChildOf(menu_root) Children [ {new_game_menu()} ] }), MenuLayer::GameInfo(game) => Box::new(bsn! { @full_menu_layer(layer) ChildOf(menu_root) Children [ {game_info_menu(game)} ] }), MenuLayer::Browse => Box::new(bsn! { @full_menu_layer(layer) ChildOf(menu_root) Children [ @menu_button("Game 1", None) ] }), MenuLayer::DirectConnect => Box::new(bsn! { @full_menu_layer(layer) ChildOf(menu_root) Children [ // TODO: have IP addr text box here Text("Enter IP") ] }), MenuLayer::Settings => Box::new(bsn! { @menu_layer(layer) ChildOf(menu_root) Children [ @menu_button("Controls", Some(MenuLayer::Controls)) -- @menu_button("Graphics", Some(MenuLayer::Graphics)) ] }), MenuLayer::Controls => Box::new(bsn! { @full_menu_layer(layer) ChildOf(menu_root) Children [ Text("Controls..") ] }), MenuLayer::Graphics => Box::new(bsn! { @full_menu_layer(layer) ChildOf(menu_root) Children [ Text("Graphics..") ] }), } } } /// The state of the menu last frame. #[derive(Resource, Debug, Clone, PartialEq, Eq, Hash, Deref, DerefMut)] struct LastMenu(Vec); /// The current state of the menu. #[derive(Resource, Debug, Clone, PartialEq, Eq, Hash, Deref, DerefMut)] struct CurrentMenu(Vec); fn setup_menu(mut commands: Commands) { commands.insert_resource(CurrentMenu(vec![MenuLayer::Root])); commands.insert_resource(LastMenu(vec![])); commands.spawn_scene(bsn! { #MenuRoot MenuRoot DespawnOnExit::(AppState::Menu) Node { width: percent(90), height: percent(70), margin: UiRect::AUTO, column_gap: px(10), } }); } fn cleanup_menu(mut commands: Commands) { commands.remove_resource::(); commands.remove_resource::(); } fn menu_redraw( mut commands: Commands, mut last_menu: ResMut, current_menu: Res, q_layers: Query<(Entity, &Layer), Without>, s_menu_root: Single, Without)>, ) { if current_menu.is_changed() { let last_equal_layer = last_menu .iter() .zip(current_menu.iter()) .enumerate() .filter_map(|(idx, (last, current))| (last == current).then_some(idx as i32)) .last() .unwrap_or(-1); // delete any layers that have been deleted/changed if last_equal_layer < last_menu.len() as i32 { for (e, layer) in q_layers.iter() { if layer.0 as i32 > last_equal_layer { commands.entity(e).despawn(); } } } // render new/changed layers for (idx, layer) in current_menu .iter() .enumerate() .skip((last_equal_layer + 1) as usize) { commands.spawn_scene(layer.render(idx, *s_menu_root)); } // update last menu to match current menu last_menu.0 = current_menu.0.clone(); } } fn new_game_menu() -> impl SceneList { bsn_list! { Text("New Game") Node { align_self: AlignSelf::Center, } TextFont { // font: FontSourceTemplate::Handle("fonts/FiraSans-Bold.ttf"), font_size: px(100.0), } TextColor(Color::srgb(0.9, 0.9, 0.9)) TextShadow -- Text("Save Name") Node { margin: UiRect::axes(px(12), px(0)), } TextFont { // font: FontSourceTemplate::Handle("fonts/FiraSans-Bold.ttf"), font_size: px(66.0), } TextColor(Color::srgb(0.9, 0.9, 0.9)) TextShadow -- #NewGameTextField GameNameTextField Node { border: Val::all(px(6)), padding: Val::all(px(12)), border_radius: BorderRadius::all(px(12)), } BorderColor::from(SLATE_900) InteractionColors::DEFAULT TextInput EditableText { cursor_width: 0.05, max_characters: Option::Some(14), } EditableTextFilter::new(|c| c == ' ' || c.is_ascii_alphanumeric()) TextLayout::no_wrap() TextFont { font_size: FontSize::Px(66.), } TextCursorStyle { color: Color::BLACK, } AutoDirectionalNavigation::default() AutoFocus TabIndex(0) BackgroundColor(Color::srgb(0.48, 0.84, 0.87)) -- Node { margin: UiRect::top(px(48)), } Children [ @menu_button("Create", None) InteractionColors::GOOD Node { margin: UiRect::left(Val::Auto), } on(|_event: On, text_field: Single<&EditableText, With>| { let value = text_field.value(); info!("Create triggered for: {}", value); create_game_save(value.to_string()).unwrap(); // TODO: refresh listing }) ] } } fn game_info_menu(name: impl Into) -> impl SceneList { let name = name.into(); bsn_list! { #GameInfoTitle Text("Load Save") Node { align_self: AlignSelf::Center, } TextFont { // font: FontSourceTemplate::Handle("fonts/FiraSans-Bold.ttf"), font_size: px(100.0), } TextColor(Color::srgb(0.9, 0.9, 0.9)) TextShadow -- Text("Save Name") Node { margin: UiRect::axes(px(12), px(0)), } TextFont { // font: FontSourceTemplate::Handle("fonts/FiraSans-Bold.ttf"), font_size: px(66.0), } TextColor(Color::srgb(0.9, 0.9, 0.9)) TextShadow -- #SaveNameTextField GameNameTextField Node { border: Val::all(px(6)), padding: Val::all(px(12)), border_radius: BorderRadius::all(px(12)), } BorderColor::from(SLATE_900) InteractionColors::DEFAULT TextInput EditableText::new(name) EditableText { cursor_width: 0.05, max_characters: Option::Some(14), } EditableTextFilter::new(|c| c == ' ' || c.is_ascii_alphanumeric()) TextLayout::no_wrap() TextFont { font_size: FontSize::Px(66.), } TextCursorStyle { color: Color::BLACK, } AutoDirectionalNavigation::default() AutoFocus TabIndex(0) BackgroundColor(Color::srgb(0.48, 0.84, 0.87)) -- Node { margin: UiRect::top(px(48)), } Children [ @menu_button("Delete", None) InteractionColors::DANGER Node { margin: UiRect::right(Val::Auto), } on(|_event: On, text_field: Single<&EditableText, With>| { let value = text_field.value(); info!("Delete triggered for: {}", value); delete_game_save(value.to_string()).unwrap(); // TODO: refresh listing }) -- // TODO: when the user changed the name of the game, change this to a "rename" button @menu_button("Play", None) InteractionColors::GOOD Node { margin: UiRect::left(Val::Auto), } on(|_event: On, text_field: Single<&EditableText, With>, mut commands: Commands, mut app_state: ResMut>| { // TODO: we should definately get this value from an added // resource. Call it something like `SelectedGameSave` let value = text_field.value().to_string(); info!("Play triggered for: {}", value); commands.insert_resource(ActiveGameSave::new(value)); app_state.set(AppState::Lobby); // TODO: refresh listing }) ] } } fn menu_layer(layer: usize) -> impl Scene { let name = format!("MenuLayer{}", layer); bsn! { Name(name) Layer(layer) TabGroup::new(layer as i32) Node { width: percent(100.0 / N_LAYERS as f32), height: percent(100), flex_direction: FlexDirection::Column, justify_content: JustifyContent::FlexEnd, row_gap: px(100), } } } fn full_menu_layer(layer: usize) -> impl Scene { let name = format!("FullMenuLayer{}", layer); bsn! { Name(name) Layer(layer) TabGroup::new(-(layer as i32)) Node { flex_direction: FlexDirection::Column, align_self: AlignSelf::FlexEnd, flex_grow: 1., border_radius: BorderRadius::all(px(24)), padding: Val::all(px(24)), } BackgroundColor(Color::srgb(0.4, 0.4, 0.4)) } } fn menu_button(label: impl Into, next_layer: Option) -> Box { let label = label.into(); let next_layer: Box = if let Some(next_layer) = next_layer { Box::new(bsn! { NextLayer(next_layer) }) } else { Box::new(bsn! {}) }; Box::new(bsn! { Button AutoDirectionalNavigation::default() TabIndex(0) Node { width: px(600), height: px(130), border: px(6), // border_radius: BorderRadius::MAX, justify_content: JustifyContent::Center, align_items: AlignItems::Center, margin: UiRect::all(px(4)), } @next_layer BorderColor::from(Color::BLACK) InteractionColors::DEFAULT // TODO: add interaction component here if neccesary Hovered Children [ Text(label) TextFont { // font: FontSourceTemplate::Handle("fonts/FiraSans-Bold.ttf"), font_size: px(66.0), } TextColor(Color::srgb(0.9, 0.9, 0.9)) TextShadow ] }) } fn button_activate_observer( event: On, mut commands: Commands, menu: Option>, q_next_layer: Query<(&NextLayer, &ChildOf)>, q_layer: Query<(&Layer, &Children)>, q_selected: Query<(Entity, Has)>, ) { let Some(mut menu) = menu else { return; }; if let Ok((next_layer, parent)) = q_next_layer.get(event.entity) { let Ok((layer, layer_entries)) = q_layer.get(parent.parent()) else { warn!( "\"Activate\" triggered on entity with `NextLayer`, but it's parent doesn't have `Layer`!" ); return; }; // remove all the layers after if (layer.0 as usize) < menu.len() { menu.0.drain((layer.0 + 1)..); } // push next layer menu.push(next_layer.0.clone()); // TODO: This custom "selected" logic, where there is one selected button // on the list of options is a lot like a radio button. Probably should replace // this custom impl with a modified radio button impl for (entity, selected) in q_selected.iter_many(layer_entries).matched() { if entity == event.entity { if !selected { commands.entity(entity).insert(Selected); } } else if selected { commands.entity(entity).remove::(); } } } } // TODO: generify the inputing of the nav inputs that are in here fn focus_rightmost_layer_on_navigation( keyboard: Res>, gamepads: Query<&Gamepad>, mut input_focus: ResMut, mut input_focus_visible: ResMut, mut navigation_input: ResMut, layers: Query<(Entity, &Layer)>, children: Query<&Children>, focusable_entities: Query<(), With>, ) { let navigation_pressed = [ KeyCode::Tab, KeyCode::ArrowUp, KeyCode::ArrowDown, KeyCode::ArrowLeft, KeyCode::ArrowRight, ] .into_iter() .any(|key| keyboard.just_pressed(key)) || gamepads.iter().any(|gamepad| { [ GamepadButton::DPadUp, GamepadButton::DPadDown, GamepadButton::DPadLeft, GamepadButton::DPadRight, ] .into_iter() .any(|button| gamepad.just_pressed(button)) }); if !navigation_pressed || input_focus .get() .is_some_and(|focused| focusable_entities.contains(focused)) { return; } let mut rightmost_target = None; for (entity, layer) in &layers { if let Some(target) = first_focusable_in_layer(entity, &children, &focusable_entities) && rightmost_target.is_none_or(|(rightmost_layer, _)| layer.0 > rightmost_layer) { rightmost_target = Some((layer.0, target)); } } if let Some((_, target)) = rightmost_target { input_focus.set(target, FocusCause::Navigated); input_focus_visible.0 = true; navigation_input.focus_initialized = true; } } fn first_focusable_in_layer( entity: Entity, children: &Query<&Children>, focusable_entities: &Query<(), With>, ) -> Option { if focusable_entities.contains(entity) { return Some(entity); } children .get(entity) .ok()? .iter() .find_map(|child| first_focusable_in_layer(child, children, focusable_entities)) }