666 lines
20 KiB
Rust
666 lines
20 KiB
Rust
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::<LobbyUiState>();
|
|
app.register_type::<LobbyUiState>();
|
|
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>(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<Entity, With<LobbyUiRoot>>) {
|
|
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<Activate>, mut fields: Query<&mut EditableText, With<PortField>>| {
|
|
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<Activate>, mut lobby: ResMut<LobbyUiState>| {
|
|
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<PortField>>,
|
|
password_field: Single<&EditableText, With<PasswordField>>,
|
|
mut lobby: ResMut<LobbyUiState>,
|
|
) {
|
|
if let Ok(port) = port_field.value().to_string().parse::<u16>() {
|
|
lobby.port = port;
|
|
}
|
|
lobby.password = password_field.value().to_string();
|
|
}
|
|
|
|
fn session_visuals(
|
|
lobby_ui: Res<LobbyUiState>,
|
|
q_session_control_text: Option<Single<&mut Text, With<SessionStatusLabel>>>,
|
|
) {
|
|
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<LobbyUiState>,
|
|
mut commands: Commands,
|
|
options: Query<(Entity, &LobbyPrivacyOption, &Interaction, Has<Checked>), With<RadioButton>>,
|
|
) {
|
|
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::<Checked>()
|
|
// 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<ValueChange<Entity>>,
|
|
options: Query<&LobbyPrivacyOption>,
|
|
mut lobby_ui: ResMut<LobbyUiState>,
|
|
) {
|
|
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<Checked>, &mut BackgroundColor),
|
|
(
|
|
With<LobbyPrivacyOption>,
|
|
Or<(Changed<Interaction>, Changed<Checked>)>,
|
|
),
|
|
>,
|
|
) {
|
|
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<InputFocus>,
|
|
mut options: Query<
|
|
(&ChildOf, &mut BorderColor),
|
|
(With<LobbyPrivacyOption>, Without<InteractionColors>),
|
|
>,
|
|
) {
|
|
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::<InputFocus>().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::<BackgroundColor>(checked).unwrap();
|
|
assert_eq!(
|
|
checked_bg.0, OPTION_SELECTED_BACKGROUND,
|
|
"checked option must be restyled"
|
|
);
|
|
|
|
let unchecked_bg = *world.get::<BackgroundColor>(unchecked).unwrap();
|
|
assert_eq!(
|
|
unchecked_bg.0, OPTION_BACKGROUND,
|
|
"unchecked option must keep its base color"
|
|
);
|
|
|
|
let untouched_bg = *world.get::<BackgroundColor>(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::<BackgroundColor>(hovered).unwrap().0,
|
|
OPTION_SELECTED_BACKGROUND,
|
|
"selected option must not change on hover"
|
|
);
|
|
assert_eq!(
|
|
world.get::<BackgroundColor>(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::<BackgroundColor>(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::<LobbyUiState>().privacy = LobbyPrivacy::FriendsOnly;
|
|
schedule.run(&mut world);
|
|
|
|
assert_eq!(
|
|
world.get::<BackgroundColor>(first).unwrap().0,
|
|
OPTION_BACKGROUND,
|
|
"deselected option must be restyled to base color"
|
|
);
|
|
assert_eq!(
|
|
world.get::<BackgroundColor>(second).unwrap().0,
|
|
OPTION_SELECTED_BACKGROUND,
|
|
"newly checked option must be styled as checked"
|
|
);
|
|
}
|
|
}
|