use bevy::{ color::palettes::tailwind::SLATE_900, input_focus::{ AutoFocus, InputFocus, tab_navigation::{TabGroup, TabIndex}, }, prelude::*, text::{EditableText, EditableTextFilter, TextCursorStyle}, ui::{Checked, auto_directional_navigation::AutoDirectionalNavigation}, ui_widgets::{Activate, Button, RadioButton, RadioGroup, ValueChange}, }; use crate::AppState; use crate::main_menu::InteractionColors; const PANEL_BACKGROUND: Color = Color::srgb(0.4, 0.4, 0.4); const TEXT_COLOR: Color = Color::srgb(0.9, 0.9, 0.9); const TEXT_INPUT_BACKGROUND: Color = Color::srgb(0.48, 0.84, 0.87); const OPTION_BORDER_COLOR: Color = Color::srgb(0.12, 0.16, 0.22); const OPTION_TEXT_COLOR: Color = Color::srgb(0.92, 0.94, 0.98); const OPTION_BACKGROUND: Color = Color::srgb(0.18, 0.22, 0.29); const OPTION_HOVERED_BACKGROUND: Color = Color::srgb(0.26, 0.33, 0.42); const OPTION_SELECTED_BACKGROUND: Color = Color::srgb(0.08, 0.42, 0.52); const OPTION_SELECTED_PRESSED_BACKGROUND: Color = Color::srgb(0.07, 0.36, 0.44); pub struct LobbyPlugin; #[derive(Resource, Debug, Clone, PartialEq, Eq, Reflect)] #[reflect(Resource)] pub struct LobbyUiState { pub privacy: LobbyPrivacy, pub port: u16, pub password: String, // TODO: replace with actual connection logic pub session_active: bool, } impl Default for LobbyUiState { fn default() -> Self { Self { privacy: LobbyPrivacy::Public, port: 5599, password: String::new(), session_active: false, } } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Reflect)] pub enum LobbyPrivacy { #[default] InviteOnly, FriendsOnly, Public, } #[derive(Component, Debug, Clone, Copy, Default, PartialEq, Eq)] struct LobbyPrivacyOption(LobbyPrivacy); #[derive(Component, Clone, Default)] struct PortField; #[derive(Component, Clone, Default)] struct PasswordField; #[derive(Component, Clone, Default)] struct ResetPortButton; #[derive(Component, Clone, Default)] struct SessionStatusLabel; impl Plugin for LobbyPlugin { fn build(&self, app: &mut App) { app.init_resource::(); app.register_type::(); app.add_systems( OnEnter(AppState::Lobby), (setup_lobby, build_lobby_ui).chain(), ) .add_systems( Update, ( update_privacy_controls, session_visuals, sync_lobby_fields, style_privacy_options, style_lobby_focus, ) .chain(), ); app.add_observer(on_privacy_changed); } } /// The root of the lobby ui. #[derive(Component, Debug, Copy, Clone, Default, PartialEq, Eq, Hash)] struct LobbyUiRoot; fn setup_lobby(mut commands: Commands) { // TODO: build from whatever info we have from the loaded save commands.insert_resource(LobbyUiState::default()); commands.spawn_scene(bsn! { #LobbyUiRoot LobbyUiRoot TabGroup::default() DespawnOnExit::(AppState::Lobby) Node { width: percent(100), height: percent(100), flex_direction: FlexDirection::Row, column_gap: px(24), padding: Val::all(px(24)), } }); } fn build_lobby_ui(mut commands: Commands, root_ui: Single>) { let root_ui = *root_ui; commands.spawn_scene(bsn! { ChildOf(root_ui) Node { width: percent(50), flex_direction: FlexDirection::Column, align_self: AlignSelf::Stretch, border_radius: BorderRadius::all(px(24)), padding: Val::all(px(24)), } BackgroundColor(PANEL_BACKGROUND) Children [ ( Text("Server Settings") Node { align_self: AlignSelf::Center, } TextFont { font_size: px(76.), } TextColor(TEXT_COLOR) TextShadow ), ( Text("Session Visibility") Node { margin: UiRect::top(px(24)), } TextFont { font_size: px(38.), } TextColor(TEXT_COLOR) ), ( RadioGroup AutoFocus AutoDirectionalNavigation::default() Node { display: Display::Flex, flex_direction: FlexDirection::Row, width: percent(100), height: px(90), } TabIndex(0) Children [ privacy_option( "Public", LobbyPrivacy::Public, BorderRadius::px(16., 0., 0., 16.), ), privacy_option("Friends Only", LobbyPrivacy::FriendsOnly, BorderRadius::ZERO), privacy_option( "Invite Only", LobbyPrivacy::InviteOnly, BorderRadius::px(0., 16., 16., 0.), ), ] ), ( field_label("Port") ), ( Node { flex_direction: FlexDirection::Row, column_gap: px(12), align_items: AlignItems::Center, } Children [ port_field(), ( Button ResetPortButton AutoDirectionalNavigation::default() TabIndex(2) Node { width: px(600), height: px(130), border: px(6), justify_content: JustifyContent::Center, align_items: AlignItems::Center, margin: UiRect::all(px(4)), } BorderColor::from(Color::BLACK) InteractionColors::DEFAULT Interaction Children [( Text("Reset") TextFont { font_size: px(66.), } TextColor(TEXT_COLOR) TextShadow )] on(|_event: On, mut fields: Query<&mut EditableText, With>| { if let Ok(mut field) = fields.single_mut() { field.editor_mut().set_text("5599"); } }) ), ] ), (field_label("Password")), (password_field()), ( Node { flex_grow: 1., } ), ( Button Node { width: px(600), height: px(130), border: px(6), justify_content: JustifyContent::Center, align_items: AlignItems::Center, margin: UiRect::all(px(4)), } BorderColor::from(Color::BLACK) InteractionColors::GOOD Interaction AutoDirectionalNavigation::default() TabIndex(4) on(|_event: On, mut lobby: ResMut| { info!("Session clicked!"); lobby.session_active = !lobby.session_active; }) Children [( Text("Start Session") SessionStatusLabel TextFont { font_size: px(66.), } TextColor(TEXT_COLOR) TextShadow )] ), ] }); commands.spawn_scene(bsn! { ChildOf(root_ui) Node { width: percent(50), flex_direction: FlexDirection::Column, align_self: AlignSelf::Stretch, border_radius: BorderRadius::all(px(24)), padding: Val::all(px(24)), } BackgroundColor(PANEL_BACKGROUND) Children [ ( Text("Connected Players") Node { align_self: AlignSelf::Center, } TextFont { font_size: px(76.), } TextColor(TEXT_COLOR) TextShadow ), player_row("Joe (you)"), player_row("Alex"), player_row("Sam"), player_row("Taylor"), ] }); } fn field_label(label: &'static str) -> impl Scene { bsn! { Text(label) Node { margin: UiRect::top(px(18)), } TextFont { font_size: px(38.), } TextColor(TEXT_COLOR) } } fn port_field() -> impl Scene { bsn! { PortField Node { width: percent(100), border: Val::all(px(6)), padding: Val::all(px(12)), border_radius: BorderRadius::all(px(12)), } BorderColor::from(SLATE_900) EditableText::new("5599") EditableText { cursor_width: 0.05, max_characters: Option::Some(5), } EditableTextFilter::new(|c| c.is_ascii_digit()) TextLayout::no_wrap() TextFont { font_size: FontSize::Px(66.), } TextCursorStyle { color: Color::BLACK, } AutoDirectionalNavigation::default() TabIndex(1) BackgroundColor(TEXT_INPUT_BACKGROUND) } } fn password_field() -> impl Scene { bsn! { PasswordField Node { width: percent(100), border: Val::all(px(6)), padding: Val::all(px(12)), border_radius: BorderRadius::all(px(12)), } BorderColor::from(SLATE_900) EditableText { cursor_width: 0.05, max_characters: Option::Some(32), } EditableTextFilter::new(|c| c == ' ' || c.is_ascii_alphanumeric() || c == '_' || c == '-') TextLayout::no_wrap() TextFont { font_size: FontSize::Px(66.), } TextCursorStyle { color: Color::BLACK, } AutoDirectionalNavigation::default() TabIndex(3) BackgroundColor(TEXT_INPUT_BACKGROUND) } } fn player_row(name: &'static str) -> impl Scene { bsn! { Node { width: percent(100), height: px(130), margin: UiRect::top(px(14)), padding: Val::all(px(16)), align_items: AlignItems::Center, column_gap: px(24), border_radius: BorderRadius::all(px(12)), } BackgroundColor(OPTION_BACKGROUND) Children [ ( Node { width: px(96), height: px(96), border_radius: BorderRadius::MAX, } BackgroundColor(OPTION_SELECTED_BACKGROUND) ), ( Text(name) TextFont { font_size: px(50.), } TextColor(TEXT_COLOR) TextShadow ), ] } } fn sync_lobby_fields( port_field: Single<&EditableText, With>, password_field: Single<&EditableText, With>, mut lobby: ResMut, ) { if let Ok(port) = port_field.value().to_string().parse::() { lobby.port = port; } lobby.password = password_field.value().to_string(); } fn session_visuals( lobby_ui: Res, q_session_control_text: Option>>, ) { if !lobby_ui.is_changed() { return; } let Some(mut text) = q_session_control_text else { warn!("Session Control Text not found!"); return; }; let new_text = match lobby_ui.session_active { true => "Stop Session", false => "Start Session", }; **text = new_text.into(); } fn privacy_option( label: &'static str, privacy: LobbyPrivacy, border_radius: BorderRadius, ) -> impl Scene { bsn! { RadioButton LobbyPrivacyOption(privacy) Interaction Node { display: Display::Flex, flex_grow: 1., justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(px(4)), border_radius, } BorderColor::all(OPTION_BORDER_COLOR) BackgroundColor(OPTION_BACKGROUND) Children [( Text(label) TextFont { font_size: px(38.0), } TextColor(OPTION_TEXT_COLOR) )] } } /// Updates the privacy control items from the [`LobbyUiState`] resource. fn update_privacy_controls( lobby_ui: Res, mut commands: Commands, options: Query<(Entity, &LobbyPrivacyOption, &Interaction, Has), With>, ) { if !lobby_ui.is_changed() { return; } for (entity, option, interaction, checked) in &options { let should_be_checked = option.0 == lobby_ui.privacy; if should_be_checked && !checked { commands.entity(entity).insert(Checked); } else if !should_be_checked && checked { commands .entity(entity) .remove::() // Reinserting the interaction will trigger the visual redraw .insert(*interaction); } } } /// Updates the lobby privacy in response to the RadioGroup change. fn on_privacy_changed( value_change: On>, options: Query<&LobbyPrivacyOption>, mut lobby_ui: ResMut, ) { if let Ok(option) = options.get(value_change.value) { lobby_ui.privacy = option.0; } } /// Styles the UI options according to their interactions. fn style_privacy_options( mut options: Query< (&Interaction, Has, &mut BackgroundColor), ( With, Or<(Changed, Changed)>, ), >, ) { for (interaction, checked, mut bg) in options.iter_mut() { bg.0 = match (checked, *interaction) { // Hovering a selected radio is a no-op (pressing it changes // nothing), so it keeps its selected color; pressing it darkens. (true, Interaction::Pressed) => OPTION_SELECTED_PRESSED_BACKGROUND, (true, _) => OPTION_SELECTED_BACKGROUND, (false, Interaction::Hovered) => OPTION_HOVERED_BACKGROUND, (false, _) => OPTION_BACKGROUND, }; } } fn style_lobby_focus( input_focus: Res, mut options: Query< (&ChildOf, &mut BorderColor), (With, Without), >, ) { let focused_entity = input_focus.get(); for (parent, mut border_color) in &mut options { let color = if focused_entity == Some(parent.parent()) { OPTION_TEXT_COLOR } else { OPTION_BORDER_COLOR }; let new_border_color = BorderColor::all(color); if *border_color != new_border_color { *border_color = new_border_color; } } } #[cfg(test)] mod tests { use super::*; fn option(privacy: LobbyPrivacy) -> impl Bundle { ( RadioButton, LobbyPrivacyOption(privacy), BackgroundColor(OPTION_BACKGROUND), ) } #[test] fn autofocus_claims_initial_lobby_focus() { let mut world = World::new(); world.insert_resource(InputFocus::default()); let radio_group = world.spawn((RadioGroup, AutoFocus)).id(); assert_eq!(world.resource::().get(), Some(radio_group)); } #[test] fn checked_option_is_styled() { let mut world = World::new(); world.insert_resource(LobbyUiState::default()); let checked = world .spawn(( option(LobbyPrivacy::Public), Interaction::default(), Checked, )) .id(); let unchecked = world .spawn((option(LobbyPrivacy::FriendsOnly), Interaction::default())) .id(); // Regression: without `Interaction` the style system can never match the // option, so the background stays at the spawn-time color. let missing_interaction = world.spawn(option(LobbyPrivacy::Public)).id(); let mut schedule = Schedule::default(); schedule.add_systems((update_privacy_controls, style_privacy_options).chain()); schedule.run(&mut world); let checked_bg = *world.get::(checked).unwrap(); assert_eq!( checked_bg.0, OPTION_SELECTED_BACKGROUND, "checked option must be restyled" ); let unchecked_bg = *world.get::(unchecked).unwrap(); assert_eq!( unchecked_bg.0, OPTION_BACKGROUND, "unchecked option must keep its base color" ); let untouched_bg = *world.get::(missing_interaction).unwrap(); assert_eq!( untouched_bg.0, OPTION_BACKGROUND, "option without Interaction is never styled" ); } #[test] fn selected_option_interactions() { let mut world = World::new(); world.insert_resource(LobbyUiState::default()); let hovered = world .spawn((option(LobbyPrivacy::Public), Interaction::Hovered, Checked)) .id(); let pressed = world .spawn((option(LobbyPrivacy::Public), Interaction::Pressed, Checked)) .id(); let mut schedule = Schedule::default(); schedule.add_systems(style_privacy_options); schedule.run(&mut world); assert_eq!( world.get::(hovered).unwrap().0, OPTION_SELECTED_BACKGROUND, "selected option must not change on hover" ); assert_eq!( world.get::(pressed).unwrap().0, OPTION_SELECTED_PRESSED_BACKGROUND, "selected option must darken when pressed" ); } #[test] fn deselected_option_is_restyled() { let mut world = World::new(); world.insert_resource(LobbyUiState::default()); let first = world .spawn(( option(LobbyPrivacy::Public), Interaction::default(), Checked, )) .id(); let second = world .spawn((option(LobbyPrivacy::FriendsOnly), Interaction::default())) .id(); let mut schedule = Schedule::default(); schedule.add_systems((update_privacy_controls, style_privacy_options).chain()); schedule.run(&mut world); assert_eq!( world.get::(first).unwrap().0, OPTION_SELECTED_BACKGROUND, "first option must be styled as checked" ); // Selecting the other option moves the resource; `sync_privacy_options` // must then move `Checked` and the style system must react to the move. world.resource_mut::().privacy = LobbyPrivacy::FriendsOnly; schedule.run(&mut world); assert_eq!( world.get::(first).unwrap().0, OPTION_BACKGROUND, "deselected option must be restyled to base color" ); assert_eq!( world.get::(second).unwrap().0, OPTION_SELECTED_BACKGROUND, "newly checked option must be styled as checked" ); } }