preliminary main menu
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
use crate::{AppState, WINDOW_TARGET_HEIGHT, WINDOW_TARGET_WIDTH};
|
||||
use bevy::ui_widgets::Button;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
const N_LAYERS: usize = 5;
|
||||
|
||||
/// A UI object that is selected.
|
||||
#[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)]
|
||||
struct InteractionColors {
|
||||
default: Color,
|
||||
hovered: Color,
|
||||
pressed: Color,
|
||||
selected: Color,
|
||||
selected_hovered: Color,
|
||||
selected_pressed: 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.),
|
||||
};
|
||||
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),
|
||||
};
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/// 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, Copy, Clone, PartialEq, Eq, Debug, Default)]
|
||||
struct NextLayer(MenuLayer);
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)]
|
||||
pub enum MenuLayer {
|
||||
#[default]
|
||||
Root,
|
||||
Play,
|
||||
LoadSave,
|
||||
Browse,
|
||||
DirectConnect,
|
||||
Settings,
|
||||
Controls,
|
||||
Graphics,
|
||||
}
|
||||
|
||||
impl MenuLayer {
|
||||
fn render(&self, layer: usize, menu_root: Entity) -> Box<dyn Scene> {
|
||||
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<Activate>, mut exit: MessageWriter<AppExit>| { 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 => Box::new(bsn! {
|
||||
menu_layer(layer)
|
||||
ChildOf(menu_root)
|
||||
Children [
|
||||
menu_button("Save 1", None),
|
||||
menu_button("Save 2", None),
|
||||
(
|
||||
menu_button("New Game", None)
|
||||
InteractionColors::GOOD
|
||||
),
|
||||
]
|
||||
}),
|
||||
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<MenuLayer>);
|
||||
/// The current state of the menu.
|
||||
#[derive(Resource, Debug, Clone, PartialEq, Eq, Hash, Deref, DerefMut)]
|
||||
struct CurrentMenu(Vec<MenuLayer>);
|
||||
|
||||
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>(AppState::Menu)
|
||||
Node {
|
||||
width: percent(90),
|
||||
height: percent(70),
|
||||
margin: UiRect::AUTO,
|
||||
align_items: AlignItems::Start,
|
||||
justify_content: JustifyContent::Start,
|
||||
column_gap: px(10),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn cleanup_menu(mut commands: Commands) {
|
||||
commands.remove_resource::<CurrentMenu>();
|
||||
commands.remove_resource::<LastMenu>();
|
||||
}
|
||||
|
||||
fn menu_redraw(
|
||||
mut commands: Commands,
|
||||
mut last_menu: ResMut<LastMenu>,
|
||||
current_menu: Res<CurrentMenu>,
|
||||
q_layers: Query<(Entity, &Layer), Without<MenuRoot>>,
|
||||
s_menu_root: Single<Entity, (With<MenuRoot>, Without<Layer>)>,
|
||||
) {
|
||||
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 menu_layer(layer: usize) -> impl Scene {
|
||||
let name = format!("MenuLayer{}", layer);
|
||||
bsn! {
|
||||
Name(name)
|
||||
Layer(layer)
|
||||
Node {
|
||||
width: percent(100.0 / N_LAYERS as f32),
|
||||
height: percent(100),
|
||||
flex_direction: FlexDirection::Column,
|
||||
// align_items: AlignItems::Start,
|
||||
align_items: AlignItems::Baseline,
|
||||
justify_content: JustifyContent::FlexEnd,
|
||||
row_gap: px(100),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn full_menu_layer(layer: usize) -> impl Scene {
|
||||
bsn! {
|
||||
menu_layer(layer)
|
||||
Node {
|
||||
width: Val::Auto,
|
||||
flex_grow: 1.,
|
||||
}
|
||||
BackgroundColor(Color::srgba(0.8, 0.1, 0.1, 0.7))
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_button(label: &str, next_layer: Option<MenuLayer>) -> impl Scene {
|
||||
let next_layer_component = next_layer
|
||||
.map(|n| Box::new(bsn! { NextLayer(n) }) as Box<dyn Scene>)
|
||||
.unwrap_or(Box::new(bsn! {}));
|
||||
|
||||
bsn! {
|
||||
Button
|
||||
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_component
|
||||
BorderColor::from(Color::BLACK)
|
||||
InteractionColors::DEFAULT
|
||||
Interaction
|
||||
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<Activate>,
|
||||
mut commands: Commands,
|
||||
mut menu: ResMut<CurrentMenu>,
|
||||
q_next_layer: Query<(&NextLayer, &ChildOf)>,
|
||||
q_layer: Query<(&Layer, &Children)>,
|
||||
q_selected: Query<(Entity, Has<Selected>)>,
|
||||
) {
|
||||
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);
|
||||
|
||||
// update the buttons so the correct one is selected
|
||||
for (entity, selected) in q_selected.iter_many(layer_entries) {
|
||||
if entity == event.entity {
|
||||
if !selected {
|
||||
commands.entity(entity).insert(Selected);
|
||||
}
|
||||
} else if selected {
|
||||
commands.entity(entity).remove::<Selected>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Styles the UI elements according to their [`InteractionColors`].
|
||||
fn style_ui_elements(
|
||||
mut q_interaction_set: ParamSet<(
|
||||
Query<
|
||||
(
|
||||
&Interaction,
|
||||
&mut BackgroundColor,
|
||||
&InteractionColors,
|
||||
Has<Selected>,
|
||||
),
|
||||
(Or<(Changed<Interaction>, Added<Selected>)>, With<Button>),
|
||||
>,
|
||||
Query<(&Interaction, &mut BackgroundColor, &InteractionColors)>,
|
||||
)>,
|
||||
mut removed_selected: RemovedComponents<Selected>,
|
||||
) {
|
||||
for (interaction, mut bg, colors, selected) in q_interaction_set.p0().iter_mut() {
|
||||
let new_color = match (selected, interaction) {
|
||||
(true, Interaction::Pressed) => colors.selected_pressed,
|
||||
(true, Interaction::Hovered) => colors.selected_hovered,
|
||||
(true, Interaction::None) => colors.selected,
|
||||
(false, Interaction::Pressed) => colors.pressed,
|
||||
(false, Interaction::Hovered) => colors.hovered,
|
||||
(false, Interaction::None) => colors.default,
|
||||
};
|
||||
bg.0 = new_color;
|
||||
}
|
||||
|
||||
// also do entities that have had `Selected` removed
|
||||
for entity in removed_selected.read() {
|
||||
if let Ok((interaction, mut bg, colors)) = q_interaction_set.p1().get_mut(entity) {
|
||||
let new_color = match interaction {
|
||||
Interaction::Pressed => colors.pressed,
|
||||
Interaction::Hovered => colors.hovered,
|
||||
Interaction::None => colors.default,
|
||||
};
|
||||
bg.0 = new_color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_ui(mut ui_scale: ResMut<UiScale>, 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;
|
||||
}
|
||||
Reference in New Issue
Block a user