bevy/examples/games/game_menu.rs
Nico Burns b995827013
Have a separate implicit viewport node per root node + make viewport node Display::Grid (#9637)
# Objective

Make `bevy_ui` "root" nodes more intuitive to use/style by:
- Removing the implicit flexbox styling (such as stretch alignment) that
is applied to them, and replacing it with more intuitive CSS Grid
styling (notably with stretch alignment disabled in both axes).
- Making root nodes layout independently of each other. Instead of there
being a single implicit "viewport" node that all root nodes are children
of, there is now an implicit "viewport" node *per root node*. And layout
of each tree is computed separately.

## Solution

- Remove the global implicit viewport node, and instead create an
implicit viewport node for each user-specified root node.
- Keep track of both the user-specified root nodes and the implicit
viewport nodes in a separate `Vec`.
- Use the window's size as the `available_space` parameter to
`Taffy.compute_layout` rather than setting it on the implicit viewport
node (and set the viewport to `height: 100%; width: 100%` to make this
"just work").

---

## Changelog

- Bevy UI now lays out root nodes independently of each other in
separate layout contexts.
- The implicit viewport node (which contains each user-specified root
node) is now `Display::Grid` with `align_items` and `justify_items` both
set to `Start`.

## Migration Guide

- Bevy UI now lays out root nodes independently of each other in
separate layout contexts. If you were relying on your root nodes being
able to affect each other's layouts, then you may need to wrap them in a
single root node.
- The implicit viewport node (which contains each user-specified root
node) is now `Display::Grid` with `align_items` and `justify_items` both
set to `Start`. You may need to add `height: Val::Percent(100.)` to your
root nodes if you were previously relying on being implicitly set.
2023-09-19 15:14:46 +00:00

830 lines
34 KiB
Rust

//! This example will display a simple menu using Bevy UI where you can start a new game,
//! change some settings or quit. There is no actual game, it will just display the current
//! settings for 5 seconds before going back to the menu.
use bevy::prelude::*;
const TEXT_COLOR: Color = Color::rgb(0.9, 0.9, 0.9);
// Enum that will be used as a global state for the game
#[derive(Clone, Copy, Default, Eq, PartialEq, Debug, Hash, States)]
enum GameState {
#[default]
Splash,
Menu,
Game,
}
// One of the two settings that can be set through the menu. It will be a resource in the app
#[derive(Resource, Debug, Component, PartialEq, Eq, Clone, Copy)]
enum DisplayQuality {
Low,
Medium,
High,
}
// One of the two settings that can be set through the menu. It will be a resource in the app
#[derive(Resource, Debug, Component, PartialEq, Eq, Clone, Copy)]
struct Volume(u32);
fn main() {
App::new()
.add_plugins(DefaultPlugins)
// Insert as resource the initial value for the settings resources
.insert_resource(DisplayQuality::Medium)
.insert_resource(Volume(7))
// Declare the game state, whose starting value is determined by the `Default` trait
.add_state::<GameState>()
.add_systems(Startup, setup)
// Adds the plugins for each state
.add_plugins((splash::SplashPlugin, menu::MenuPlugin, game::GamePlugin))
.run();
}
fn setup(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
}
mod splash {
use bevy::prelude::*;
use super::{despawn_screen, GameState};
// This plugin will display a splash screen with Bevy logo for 1 second before switching to the menu
pub struct SplashPlugin;
impl Plugin for SplashPlugin {
fn build(&self, app: &mut App) {
// As this plugin is managing the splash screen, it will focus on the state `GameState::Splash`
app
// When entering the state, spawn everything needed for this screen
.add_systems(OnEnter(GameState::Splash), splash_setup)
// While in this state, run the `countdown` system
.add_systems(Update, countdown.run_if(in_state(GameState::Splash)))
// When exiting the state, despawn everything that was spawned for this screen
.add_systems(OnExit(GameState::Splash), despawn_screen::<OnSplashScreen>);
}
}
// Tag component used to tag entities added on the splash screen
#[derive(Component)]
struct OnSplashScreen;
// Newtype to use a `Timer` for this screen as a resource
#[derive(Resource, Deref, DerefMut)]
struct SplashTimer(Timer);
fn splash_setup(mut commands: Commands, asset_server: Res<AssetServer>) {
let icon = asset_server.load("branding/icon.png");
// Display the logo
commands
.spawn((
NodeBundle {
style: Style {
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
..default()
},
OnSplashScreen,
))
.with_children(|parent| {
parent.spawn(ImageBundle {
style: Style {
// This will set the logo to be 200px wide, and auto adjust its height
width: Val::Px(200.0),
..default()
},
image: UiImage::new(icon),
..default()
});
});
// Insert the timer as a resource
commands.insert_resource(SplashTimer(Timer::from_seconds(1.0, TimerMode::Once)));
}
// Tick the timer, and change state when finished
fn countdown(
mut game_state: ResMut<NextState<GameState>>,
time: Res<Time>,
mut timer: ResMut<SplashTimer>,
) {
if timer.tick(time.delta()).finished() {
game_state.set(GameState::Menu);
}
}
}
mod game {
use bevy::prelude::*;
use super::{despawn_screen, DisplayQuality, GameState, Volume, TEXT_COLOR};
// This plugin will contain the game. In this case, it's just be a screen that will
// display the current settings for 5 seconds before returning to the menu
pub struct GamePlugin;
impl Plugin for GamePlugin {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(GameState::Game), game_setup)
.add_systems(Update, game.run_if(in_state(GameState::Game)))
.add_systems(OnExit(GameState::Game), despawn_screen::<OnGameScreen>);
}
}
// Tag component used to tag entities added on the game screen
#[derive(Component)]
struct OnGameScreen;
#[derive(Resource, Deref, DerefMut)]
struct GameTimer(Timer);
fn game_setup(
mut commands: Commands,
display_quality: Res<DisplayQuality>,
volume: Res<Volume>,
) {
commands
.spawn((
NodeBundle {
style: Style {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
// center children
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
..default()
},
OnGameScreen,
))
.with_children(|parent| {
// First create a `NodeBundle` for centering what we want to display
parent
.spawn(NodeBundle {
style: Style {
// This will display its children in a column, from top to bottom
flex_direction: FlexDirection::Column,
// `align_items` will align children on the cross axis. Here the main axis is
// vertical (column), so the cross axis is horizontal. This will center the
// children
align_items: AlignItems::Center,
..default()
},
background_color: Color::BLACK.into(),
..default()
})
.with_children(|parent| {
// Display two lines of text, the second one with the current settings
parent.spawn(
TextBundle::from_section(
"Will be back to the menu shortly...",
TextStyle {
font_size: 80.0,
color: TEXT_COLOR,
..default()
},
)
.with_style(Style {
margin: UiRect::all(Val::Px(50.0)),
..default()
}),
);
parent.spawn(
TextBundle::from_sections([
TextSection::new(
format!("quality: {:?}", *display_quality),
TextStyle {
font_size: 60.0,
color: Color::BLUE,
..default()
},
),
TextSection::new(
" - ",
TextStyle {
font_size: 60.0,
color: TEXT_COLOR,
..default()
},
),
TextSection::new(
format!("volume: {:?}", *volume),
TextStyle {
font_size: 60.0,
color: Color::GREEN,
..default()
},
),
])
.with_style(Style {
margin: UiRect::all(Val::Px(50.0)),
..default()
}),
);
});
});
// Spawn a 5 seconds timer to trigger going back to the menu
commands.insert_resource(GameTimer(Timer::from_seconds(5.0, TimerMode::Once)));
}
// Tick the timer, and change state when finished
fn game(
time: Res<Time>,
mut game_state: ResMut<NextState<GameState>>,
mut timer: ResMut<GameTimer>,
) {
if timer.tick(time.delta()).finished() {
game_state.set(GameState::Menu);
}
}
}
mod menu {
use bevy::{app::AppExit, prelude::*};
use super::{despawn_screen, DisplayQuality, GameState, Volume, TEXT_COLOR};
// This plugin manages the menu, with 5 different screens:
// - a main menu with "New Game", "Settings", "Quit"
// - a settings menu with two submenus and a back button
// - two settings screen with a setting that can be set and a back button
pub struct MenuPlugin;
impl Plugin for MenuPlugin {
fn build(&self, app: &mut App) {
app
// At start, the menu is not enabled. This will be changed in `menu_setup` when
// entering the `GameState::Menu` state.
// Current screen in the menu is handled by an independent state from `GameState`
.add_state::<MenuState>()
.add_systems(OnEnter(GameState::Menu), menu_setup)
// Systems to handle the main menu screen
.add_systems(OnEnter(MenuState::Main), main_menu_setup)
.add_systems(OnExit(MenuState::Main), despawn_screen::<OnMainMenuScreen>)
// Systems to handle the settings menu screen
.add_systems(OnEnter(MenuState::Settings), settings_menu_setup)
.add_systems(
OnExit(MenuState::Settings),
despawn_screen::<OnSettingsMenuScreen>,
)
// Systems to handle the display settings screen
.add_systems(
OnEnter(MenuState::SettingsDisplay),
display_settings_menu_setup,
)
.add_systems(
Update,
(
setting_button::<DisplayQuality>
.run_if(in_state(MenuState::SettingsDisplay)),
),
)
.add_systems(
OnExit(MenuState::SettingsDisplay),
despawn_screen::<OnDisplaySettingsMenuScreen>,
)
// Systems to handle the sound settings screen
.add_systems(OnEnter(MenuState::SettingsSound), sound_settings_menu_setup)
.add_systems(
Update,
setting_button::<Volume>.run_if(in_state(MenuState::SettingsSound)),
)
.add_systems(
OnExit(MenuState::SettingsSound),
despawn_screen::<OnSoundSettingsMenuScreen>,
)
// Common systems to all screens that handles buttons behavior
.add_systems(
Update,
(menu_action, button_system).run_if(in_state(GameState::Menu)),
);
}
}
// State used for the current menu screen
#[derive(Clone, Copy, Default, Eq, PartialEq, Debug, Hash, States)]
enum MenuState {
Main,
Settings,
SettingsDisplay,
SettingsSound,
#[default]
Disabled,
}
// Tag component used to tag entities added on the main menu screen
#[derive(Component)]
struct OnMainMenuScreen;
// Tag component used to tag entities added on the settings menu screen
#[derive(Component)]
struct OnSettingsMenuScreen;
// Tag component used to tag entities added on the display settings menu screen
#[derive(Component)]
struct OnDisplaySettingsMenuScreen;
// Tag component used to tag entities added on the sound settings menu screen
#[derive(Component)]
struct OnSoundSettingsMenuScreen;
const NORMAL_BUTTON: Color = Color::rgb(0.15, 0.15, 0.15);
const HOVERED_BUTTON: Color = Color::rgb(0.25, 0.25, 0.25);
const HOVERED_PRESSED_BUTTON: Color = Color::rgb(0.25, 0.65, 0.25);
const PRESSED_BUTTON: Color = Color::rgb(0.35, 0.75, 0.35);
// Tag component used to mark which setting is currently selected
#[derive(Component)]
struct SelectedOption;
// All actions that can be triggered from a button click
#[derive(Component)]
enum MenuButtonAction {
Play,
Settings,
SettingsDisplay,
SettingsSound,
BackToMainMenu,
BackToSettings,
Quit,
}
// This system handles changing all buttons color based on mouse interaction
fn button_system(
mut interaction_query: Query<
(&Interaction, &mut BackgroundColor, Option<&SelectedOption>),
(Changed<Interaction>, With<Button>),
>,
) {
for (interaction, mut color, selected) in &mut interaction_query {
*color = match (*interaction, selected) {
(Interaction::Pressed, _) | (Interaction::None, Some(_)) => PRESSED_BUTTON.into(),
(Interaction::Hovered, Some(_)) => HOVERED_PRESSED_BUTTON.into(),
(Interaction::Hovered, None) => HOVERED_BUTTON.into(),
(Interaction::None, None) => NORMAL_BUTTON.into(),
}
}
}
// This system updates the settings when a new value for a setting is selected, and marks
// the button as the one currently selected
fn setting_button<T: Resource + Component + PartialEq + Copy>(
interaction_query: Query<(&Interaction, &T, Entity), (Changed<Interaction>, With<Button>)>,
mut selected_query: Query<(Entity, &mut BackgroundColor), With<SelectedOption>>,
mut commands: Commands,
mut setting: ResMut<T>,
) {
for (interaction, button_setting, entity) in &interaction_query {
if *interaction == Interaction::Pressed && *setting != *button_setting {
let (previous_button, mut previous_color) = selected_query.single_mut();
*previous_color = NORMAL_BUTTON.into();
commands.entity(previous_button).remove::<SelectedOption>();
commands.entity(entity).insert(SelectedOption);
*setting = *button_setting;
}
}
}
fn menu_setup(mut menu_state: ResMut<NextState<MenuState>>) {
menu_state.set(MenuState::Main);
}
fn main_menu_setup(mut commands: Commands, asset_server: Res<AssetServer>) {
// Common style for all buttons on the screen
let button_style = Style {
width: Val::Px(250.0),
height: Val::Px(65.0),
margin: UiRect::all(Val::Px(20.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
};
let button_icon_style = Style {
width: Val::Px(30.0),
// This takes the icons out of the flexbox flow, to be positioned exactly
position_type: PositionType::Absolute,
// The icon will be close to the left border of the button
left: Val::Px(10.0),
..default()
};
let button_text_style = TextStyle {
font_size: 40.0,
color: TEXT_COLOR,
..default()
};
commands
.spawn((
NodeBundle {
style: Style {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
..default()
},
OnMainMenuScreen,
))
.with_children(|parent| {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
..default()
},
background_color: Color::CRIMSON.into(),
..default()
})
.with_children(|parent| {
// Display the game name
parent.spawn(
TextBundle::from_section(
"Bevy Game Menu UI",
TextStyle {
font_size: 80.0,
color: TEXT_COLOR,
..default()
},
)
.with_style(Style {
margin: UiRect::all(Val::Px(50.0)),
..default()
}),
);
// Display three buttons for each action available from the main menu:
// - new game
// - settings
// - quit
parent
.spawn((
ButtonBundle {
style: button_style.clone(),
background_color: NORMAL_BUTTON.into(),
..default()
},
MenuButtonAction::Play,
))
.with_children(|parent| {
let icon = asset_server.load("textures/Game Icons/right.png");
parent.spawn(ImageBundle {
style: button_icon_style.clone(),
image: UiImage::new(icon),
..default()
});
parent.spawn(TextBundle::from_section(
"New Game",
button_text_style.clone(),
));
});
parent
.spawn((
ButtonBundle {
style: button_style.clone(),
background_color: NORMAL_BUTTON.into(),
..default()
},
MenuButtonAction::Settings,
))
.with_children(|parent| {
let icon = asset_server.load("textures/Game Icons/wrench.png");
parent.spawn(ImageBundle {
style: button_icon_style.clone(),
image: UiImage::new(icon),
..default()
});
parent.spawn(TextBundle::from_section(
"Settings",
button_text_style.clone(),
));
});
parent
.spawn((
ButtonBundle {
style: button_style,
background_color: NORMAL_BUTTON.into(),
..default()
},
MenuButtonAction::Quit,
))
.with_children(|parent| {
let icon = asset_server.load("textures/Game Icons/exitRight.png");
parent.spawn(ImageBundle {
style: button_icon_style,
image: UiImage::new(icon),
..default()
});
parent.spawn(TextBundle::from_section("Quit", button_text_style));
});
});
});
}
fn settings_menu_setup(mut commands: Commands) {
let button_style = Style {
width: Val::Px(200.0),
height: Val::Px(65.0),
margin: UiRect::all(Val::Px(20.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
};
let button_text_style = TextStyle {
font_size: 40.0,
color: TEXT_COLOR,
..default()
};
commands
.spawn((
NodeBundle {
style: Style {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
..default()
},
OnSettingsMenuScreen,
))
.with_children(|parent| {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
..default()
},
background_color: Color::CRIMSON.into(),
..default()
})
.with_children(|parent| {
for (action, text) in [
(MenuButtonAction::SettingsDisplay, "Display"),
(MenuButtonAction::SettingsSound, "Sound"),
(MenuButtonAction::BackToMainMenu, "Back"),
] {
parent
.spawn((
ButtonBundle {
style: button_style.clone(),
background_color: NORMAL_BUTTON.into(),
..default()
},
action,
))
.with_children(|parent| {
parent.spawn(TextBundle::from_section(
text,
button_text_style.clone(),
));
});
}
});
});
}
fn display_settings_menu_setup(mut commands: Commands, display_quality: Res<DisplayQuality>) {
let button_style = Style {
width: Val::Px(200.0),
height: Val::Px(65.0),
margin: UiRect::all(Val::Px(20.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
};
let button_text_style = TextStyle {
font_size: 40.0,
color: TEXT_COLOR,
..default()
};
commands
.spawn((
NodeBundle {
style: Style {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
..default()
},
OnDisplaySettingsMenuScreen,
))
.with_children(|parent| {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
..default()
},
background_color: Color::CRIMSON.into(),
..default()
})
.with_children(|parent| {
// Create a new `NodeBundle`, this time not setting its `flex_direction`. It will
// use the default value, `FlexDirection::Row`, from left to right.
parent
.spawn(NodeBundle {
style: Style {
align_items: AlignItems::Center,
..default()
},
background_color: Color::CRIMSON.into(),
..default()
})
.with_children(|parent| {
// Display a label for the current setting
parent.spawn(TextBundle::from_section(
"Display Quality",
button_text_style.clone(),
));
// Display a button for each possible value
for quality_setting in [
DisplayQuality::Low,
DisplayQuality::Medium,
DisplayQuality::High,
] {
let mut entity = parent.spawn((
ButtonBundle {
style: Style {
width: Val::Px(150.0),
height: Val::Px(65.0),
..button_style.clone()
},
background_color: NORMAL_BUTTON.into(),
..default()
},
quality_setting,
));
entity.with_children(|parent| {
parent.spawn(TextBundle::from_section(
format!("{quality_setting:?}"),
button_text_style.clone(),
));
});
if *display_quality == quality_setting {
entity.insert(SelectedOption);
}
}
});
// Display the back button to return to the settings screen
parent
.spawn((
ButtonBundle {
style: button_style,
background_color: NORMAL_BUTTON.into(),
..default()
},
MenuButtonAction::BackToSettings,
))
.with_children(|parent| {
parent.spawn(TextBundle::from_section("Back", button_text_style));
});
});
});
}
fn sound_settings_menu_setup(mut commands: Commands, volume: Res<Volume>) {
let button_style = Style {
width: Val::Px(200.0),
height: Val::Px(65.0),
margin: UiRect::all(Val::Px(20.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
};
let button_text_style = TextStyle {
font_size: 40.0,
color: TEXT_COLOR,
..default()
};
commands
.spawn((
NodeBundle {
style: Style {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
..default()
},
OnSoundSettingsMenuScreen,
))
.with_children(|parent| {
parent
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
..default()
},
background_color: Color::CRIMSON.into(),
..default()
})
.with_children(|parent| {
parent
.spawn(NodeBundle {
style: Style {
align_items: AlignItems::Center,
..default()
},
background_color: Color::CRIMSON.into(),
..default()
})
.with_children(|parent| {
parent.spawn(TextBundle::from_section(
"Volume",
button_text_style.clone(),
));
for volume_setting in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] {
let mut entity = parent.spawn((
ButtonBundle {
style: Style {
width: Val::Px(30.0),
height: Val::Px(65.0),
..button_style.clone()
},
background_color: NORMAL_BUTTON.into(),
..default()
},
Volume(volume_setting),
));
if *volume == Volume(volume_setting) {
entity.insert(SelectedOption);
}
}
});
parent
.spawn((
ButtonBundle {
style: button_style,
background_color: NORMAL_BUTTON.into(),
..default()
},
MenuButtonAction::BackToSettings,
))
.with_children(|parent| {
parent.spawn(TextBundle::from_section("Back", button_text_style));
});
});
});
}
fn menu_action(
interaction_query: Query<
(&Interaction, &MenuButtonAction),
(Changed<Interaction>, With<Button>),
>,
mut app_exit_events: EventWriter<AppExit>,
mut menu_state: ResMut<NextState<MenuState>>,
mut game_state: ResMut<NextState<GameState>>,
) {
for (interaction, menu_button_action) in &interaction_query {
if *interaction == Interaction::Pressed {
match menu_button_action {
MenuButtonAction::Quit => app_exit_events.send(AppExit),
MenuButtonAction::Play => {
game_state.set(GameState::Game);
menu_state.set(MenuState::Disabled);
}
MenuButtonAction::Settings => menu_state.set(MenuState::Settings),
MenuButtonAction::SettingsDisplay => {
menu_state.set(MenuState::SettingsDisplay);
}
MenuButtonAction::SettingsSound => {
menu_state.set(MenuState::SettingsSound);
}
MenuButtonAction::BackToMainMenu => menu_state.set(MenuState::Main),
MenuButtonAction::BackToSettings => {
menu_state.set(MenuState::Settings);
}
}
}
}
}
}
// Generic system that takes a component as a parameter, and will despawn all entities with that component
fn despawn_screen<T: Component>(to_despawn: Query<Entity, With<T>>, mut commands: Commands) {
for entity in &to_despawn {
commands.entity(entity).despawn_recursive();
}
}