From 41a523d2e3341a128efbd70d44cc3db75cc29159 Mon Sep 17 00:00:00 2001 From: Mitchell Date: Fri, 18 Sep 2026 00:11:19 -0500 Subject: [PATCH] fix the lobby privacy radio --- src/debug.rs | 5 +- src/lobby.rs | 249 ++++++++++++++++++++++++++++++++++++++++----------- src/main.rs | 2 +- 3 files changed, 200 insertions(+), 56 deletions(-) diff --git a/src/debug.rs b/src/debug.rs index 802f240..734a339 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -24,6 +24,9 @@ impl Plugin for DebugInspectorPlugin { fn toggle_inspector(keys: Res>, mut enabled: ResMut) { if keys.just_pressed(KeyCode::F1) { enabled.0 = !enabled.0; - info!("lobby ui inspector toggled {}", if enabled.0 { "on" } else { "off" }); + info!( + "lobby ui inspector toggled {}", + if enabled.0 { "on" } else { "off" } + ); } } diff --git a/src/lobby.rs b/src/lobby.rs index e445244..a456aa0 100644 --- a/src/lobby.rs +++ b/src/lobby.rs @@ -3,30 +3,42 @@ use bevy::{ input_focus::tab_navigation::TabIndex, prelude::*, text::{EditableText, EditableTextFilter, TextCursorStyle}, - ui::{Checked, Pressed}, - ui_widgets::{RadioButton, RadioGroup, ValueChange, radio_self_update}, + ui::Checked, + ui_widgets::{RadioButton, RadioGroup, ValueChange}, }; use crate::AppState; +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, Copy, PartialEq, Eq, Reflect)] #[reflect(Resource)] pub struct LobbyUiState { - pub visibility: LobbyVisibility, + pub privacy: LobbyPrivacy, } impl Default for LobbyUiState { fn default() -> Self { Self { - visibility: LobbyVisibility::Public, + privacy: LobbyPrivacy::Public, } } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Reflect)] -pub enum LobbyVisibility { +pub enum LobbyPrivacy { #[default] Public, FriendsOnly, @@ -34,7 +46,7 @@ pub enum LobbyVisibility { } #[derive(Component, Debug, Clone, Copy, Default, PartialEq, Eq)] -struct LobbyVisibilityOption(LobbyVisibility); +struct LobbyPrivacyOption(LobbyPrivacy); impl Plugin for LobbyPlugin { fn build(&self, app: &mut App) { @@ -46,10 +58,9 @@ impl Plugin for LobbyPlugin { ) .add_systems( Update, - (sync_visibility_options, style_visibility_options).chain(), + (update_privacy_controls, style_privacy_options).chain(), ); - app.add_observer(radio_self_update); - app.add_observer(on_visibility_changed); + app.add_observer(on_privacy_changed); } } @@ -58,6 +69,9 @@ impl Plugin for LobbyPlugin { 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 @@ -84,7 +98,7 @@ fn build_lobby_ui(mut commands: Commands, root_ui: Single impl Scene { bsn! { RadioButton - LobbyVisibilityOption(visibility) + LobbyPrivacyOption(privacy) + Interaction Node { display: Display::Flex, flex_grow: 1., @@ -224,66 +239,192 @@ fn visibility_option( border: UiRect::all(px(3)), border_radius, } - BorderColor::all(Color::srgb(0.12, 0.16, 0.22)) - BackgroundColor(Color::srgb(0.18, 0.22, 0.29)) + BorderColor::all(OPTION_BORDER_COLOR) + BackgroundColor(OPTION_BACKGROUND) Children [( Text(label) TextFont { font_size: px(38.0), } - TextColor(Color::srgb(0.92, 0.94, 0.98)) + TextColor(OPTION_TEXT_COLOR) )] } } -fn sync_visibility_options( +/// Updates the privacy control items from the [`LobbyUiState`] resource. +fn update_privacy_controls( lobby_ui: Res, mut commands: Commands, - options: Query< - (Entity, &LobbyVisibilityOption), - (With, Added), - >, + options: Query<(Entity, &LobbyPrivacyOption, &Interaction, Has), With>, ) { - for (entity, option) in &options { - if option.0 == lobby_ui.visibility { + 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 { - commands.entity(entity).remove::(); + } else if !should_be_checked && checked { + commands + .entity(entity) + .remove::() + // Reinserting the interaction will trigger the visual redraw + .insert(*interaction); } } } -fn on_visibility_changed( +/// Updates the lobby privacy in response to the RadioGroup change. +fn on_privacy_changed( value_change: On>, - options: Query<&LobbyVisibilityOption>, + options: Query<&LobbyPrivacyOption>, mut lobby_ui: ResMut, ) { if let Ok(option) = options.get(value_change.value) { - lobby_ui.visibility = option.0; + lobby_ui.privacy = option.0; } } -fn style_visibility_options( +/// Styles the UI options according to their interactions. +fn style_privacy_options( mut options: Query< + (&Interaction, Has, &mut BackgroundColor), ( - &Interaction, - Has, - Has, - &mut BackgroundColor, - ), - ( - With, - // Or<(Changed, Changed, Changed)>, + With, + Or<(Changed, Changed)>, ), >, ) { - for (interaction, checked, pressed, mut background) in &mut options { - background.0 = match (checked, pressed, *interaction) { - (true, true, _) => Color::srgb(0.08, 0.48, 0.58), - (true, _, Interaction::Hovered) => Color::srgb(0.10, 0.58, 0.68), - (true, _, _) => Color::srgb(0.08, 0.42, 0.52), - (false, _, Interaction::Hovered) => Color::srgb(0.26, 0.33, 0.42), - (false, _, _) => Color::srgb(0.18, 0.22, 0.29), + 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, }; } } + +#[cfg(test)] +mod tests { + use super::*; + + fn option(privacy: LobbyPrivacy) -> impl Bundle { + ( + RadioButton, + LobbyPrivacyOption(privacy), + BackgroundColor(OPTION_BACKGROUND), + ) + } + + #[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" + ); + } +} diff --git a/src/main.rs b/src/main.rs index f053901..439c949 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,8 +4,8 @@ use networking::NetworkingPlugin; use crate::{debug::DebugInspectorPlugin, lobby::LobbyPlugin, save::SavePlugin}; -mod lobby; mod debug; +mod lobby; mod main_menu; mod networking; mod save;