preliminary main menu

This commit is contained in:
2026-08-20 14:49:09 -05:00
parent 35289ce2b7
commit f4d803eb35
6 changed files with 857 additions and 413 deletions
+377
View File
@@ -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;
}