use crate::focus_navigation::{NavigationInputState, NavigationSystems}; use crate::save::{ActiveGameSave, create_game_save, delete_game_save, get_saves}; use crate::{AppState, WINDOW_TARGET_HEIGHT, WINDOW_TARGET_WIDTH}; 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::Pressed; 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; /// A UI object selected by activation, distinct from keyboard/pointer focus. #[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)] struct Selected; /// Defines colors that a UI object should turn when interactions occur. #[derive(Component, Clone, PartialEq, Default, Debug)] pub struct InteractionColors { pub default: Color, pub hovered: Color, pub pressed: Color, pub selected: Color, pub selected_hovered: Color, pub selected_pressed: Color, pub default_outline: Color, pub focused_outline: Color, } impl InteractionColors { pub const DEFAULT: InteractionColors = InteractionColors { default: Color::srgb(0.25, 0.63, 0.84), hovered: Color::srgb(0.31, 0.77, 0.93), pressed: Color::srgb(0.16, 0.55, 0.89), selected: Color::srgb(0.93, 0.66, 0.06), selected_hovered: Color::srgb(1., 0.74, 0.16), selected_pressed: Color::srgb(0.86, 0.56, 0.), default_outline: Color::BLACK, focused_outline: Color::WHITE, }; pub const GOOD: InteractionColors = InteractionColors { default: Color::srgb(0.25, 0.75, 0.25), hovered: Color::srgb(0.35, 0.85, 0.35), pressed: Color::srgb(0.15, 0.65, 0.15), selected: Color::srgb(0.35, 0.8, 0.35), selected_hovered: Color::srgb(0.45, 0.9, 0.45), selected_pressed: Color::srgb(0.25, 0.7, 0.25), default_outline: Color::BLACK, focused_outline: Color::WHITE, }; pub const DANGER: InteractionColors = InteractionColors { default: Color::srgb(0.75, 0.25, 0.25), hovered: Color::srgb(0.85, 0.35, 0.35), pressed: Color::srgb(0.65, 0.15, 0.15), selected: Color::srgb(0.8, 0.3, 0.3), selected_hovered: Color::srgb(0.9, 0.4, 0.4), selected_pressed: Color::srgb(0.7, 0.2, 0.2), default_outline: Color::BLACK, focused_outline: Color::WHITE, }; } /// 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()); 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::(); } } } } /// Styles the UI elements according to their [`InteractionColors`]. fn style_ui_elements( input_focus: Res, mut q_elements: Query< ( Entity, Option<&Hovered>, Has, Option<&mut BackgroundColor>, Option<&mut BorderColor>, &InteractionColors, Has, ), Or<(With, With)>, >, ) { let focused_entity = input_focus.get(); for (entity, hovered, pressed, bg, border, colors, selected) in &mut q_elements { let is_focused = focused_entity == Some(entity); // if the entity is focused, its similar to it being hovered. let hovered = hovered.is_some_and(|h| h.get()) || is_focused; let new_color = match (selected, pressed, hovered) { (true, true, _) => colors.selected_pressed, (true, false, true) => colors.selected_hovered, (true, false, false) => colors.selected, (false, true, _) => colors.pressed, (false, false, true) => colors.hovered, (false, false, false) => colors.default, }; if let Some(mut border) = border { if is_focused { if border.top != colors.focused_outline { border.set_all(colors.focused_outline); } } else { if border.top != colors.default_outline { border.set_all(colors.default_outline); } } } if let Some(mut bg) = bg { if bg.0 != new_color { bg.0 = new_color; } } } } fn scale_ui(mut ui_scale: ResMut, window: Single<&Window>) { let size = window.size(); let scale_factor = f32::min(size.x / WINDOW_TARGET_WIDTH, size.y / WINDOW_TARGET_HEIGHT); ui_scale.0 = scale_factor; } // 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)) }