Today, we are going to learn about UIs in Bevy. Our main goal is to end up with a main menu that contains 2 buttons, one to launch our game, and another to exit the application. This article will explain how to render UI elements to the screen, and how to listen to button events.
Nodes and Styles
We could easily compare Bevy's UI to HTML: you create a hierarchy of elements / nodes, apply some styles to them, and Bevy's engine takes care of rendering what you asked for. A few "bundles" are exposed by Bevy to help you build your UI:
- NodeBundle: a basic node
fn root(materials: &Res<MenuMaterials>) -> NodeBundle {
NodeBundle {
style: Style {
size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..Default::default()
},
material: materials.root.clone(),
..Default::default()
}
}
- ButtonBundle: to render a button
fn button(materials: &Res<MenuMaterials>) -> ButtonBundle {
ButtonBundle {
style: Style {
size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..Default::default()
},
material: materials.button.clone(),
..Default::default()
}
}
- TextBundle: to render text
fn button_text(asset_server: &Res<AssetServer>, materials: &Res<MenuMaterials>, label: &str) -> TextBundle {
return TextBundle {
style: Style {
margin: Rect::all(Val::Px(10.0)),
..Default::default()
},
text: Text::with_section(
label,
TextStyle {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 30.0,
color: materials.button_text.clone(),
},
Default::default(),
),
..Default::default()
};
}
Each of these element have a style
property used to express how to render the node.
This style
property is a Rust struct that contains a subset of the usual properties found in CSS
. Among others, you should find:
- margin and padding: to add space around our UI elements
- position_type: for relative or absolute positioning
- most of the flexbox properties: Bevy's rendering engine implements flexbox for positioning
Our menu also includes these two nodes:
fn border(materials: &Res<MenuMaterials>) -> NodeBundle {
NodeBundle {
style: Style {
size: Size::new(Val::Px(400.0), Val::Auto),
border: Rect::all(Val::Px(8.0)),
..Default::default()
},
material: materials.border.clone(),
..Default::default()
}
}
fn menu_background(materials: &Res<MenuMaterials>) -> NodeBundle {
NodeBundle {
style: Style {
size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::ColumnReverse,
padding: Rect::all(Val::Px(5.0)),
..Default::default()
},
material: materials.menu.clone(),
..Default::default()
}
}
Text Styles
The TextBundle
, in addition to the style
property, specifies a property called text
which also has a style
property. This one exposes the possibility to modify the text font size, color and font.
If you take a look to the text node above, you should notice the use of a Res<AssetServer>
. This utility allows us to load non Rust resources such as images or fonts.
To use it, we creates a new folder to the root of our project and named it assets
. When using the asset server to load a resource, give it the path of your resource relative to this folder. Ex:
asset_server.load("fonts/FiraSans-Bold.ttf")
Colors
Nodes and Buttons also contain a material
property. This property works the same as the materials we previously added to our player and maps. To ease the development of our menu, we can centralize the definition of these colors into a resource:
struct MenuMaterials {
root: Handle<ColorMaterial>,
border: Handle<ColorMaterial>,
menu: Handle<ColorMaterial>,
button: Handle<ColorMaterial>,
button_hovered: Handle<ColorMaterial>,
button_pressed: Handle<ColorMaterial>,
button_text: Color,
}
impl FromWorld for MenuMaterials {
fn from_world(world: &mut World) -> Self {
let mut materials = world.get_resource_mut::<Assets<ColorMaterial>>().unwrap();
MenuMaterials {
root: materials.add(Color::NONE.into()),
border: materials.add(Color::rgb(0.65, 0.65, 0.65).into()),
menu: materials.add(Color::rgb(0.15, 0.15, 0.15).into()),
button: materials.add(Color::rgb(0.15, 0.15, 0.15).into()),
button_hovered: materials.add(Color::rgb(0.25, 0.25, 0.25).into()),
button_pressed: materials.add(Color::rgb(0.35, 0.75, 0.35).into()),
button_text: Color::WHITE,
}
}
}
We still need to tell Bevy to instantiate this resource:
// on our AppBuilder
.init_resource::<MenuMaterials>()
Defining the Node Hierarchy
We can add UI elements the same way we register other Bevy entities: we call AppBuilder.spawn_bundle
and register children nodes with the with_children
method:
enum MenuButton {
Play,
Quit,
}
fn setup(
mut commands: Commands,
asset_server: Res<AssetServer>,
materials: Res<MenuMaterials>,
) {
commands.spawn_bundle(UiCameraBundle::default());
commands
.spawn_bundle(root(&materials))
.with_children(|parent| {
// left vertical fill (border)
parent
.spawn_bundle(border(&materials))
.with_children(|parent| {
// left vertical fill (content)
parent
.spawn_bundle(menu_background(&materials))
.with_children(|parent| {
parent.spawn_bundle(button(&materials))
.with_children(|parent| {
parent.spawn_bundle(button_text(&asset_server, &materials, "New Game"));
})
.insert(MenuButton::Play);
parent.spawn_bundle(button(&materials))
.with_children(|parent| {
parent.spawn_bundle(button_text(&asset_server, &materials, "Quit"));
})
.insert(MenuButton::Quit);
});
});
});
}
Wiring it up
To wire everything up, we created a new Rust module called main_menu
, which contains a MainMenuPlugin
.
pub struct MainMenuPlugin;
impl Plugin for MainMenuPlugin {
fn build(&self, app: &mut AppBuilder) {
app.init_resource::<MenuMaterials>()
.add_system_set(
SystemSet::on_enter(AppState::MainMenu)
.with_system(cleanup.system())
.with_system(setup.system()),
)
.add_system_set(SystemSet::on_exit(AppState::MainMenu).with_system(cleanup.system()));
}
}
// main.rs
mod main_menu;
use main_menu::MainMenuPlugin;
// ...
.add_plugin(MainMenuPlugin)
Running the game:
Apply Different Styles on Interactions
It is possible to modify the button look with a system:
// MainMenuPlugin
.add_system(button_system.system())
// system implementation
fn button_system(
materials: Res<MenuMaterials>,
mut buttons: Query<
(&Interaction, &mut Handle<ColorMaterial>),
(Changed<Interaction>, With<Button>),
>
) {
for (interaction, mut material) in buttons.iter_mut() {
match *interaction {
Interaction::Clicked => *material = materials.button_pressed.clone(),
Interaction::Hovered => *material = materials.button_hovered.clone(),
Interaction::None => *material = materials.button.clone(),
}
}
}
This way, our buttons should change color when hovered, and turn to green when pressed.
Runnig Actions on Button Click
The same way we changed button styles, we can react to button click with a system triggered on clicks:
// main menu plugin
.add_system(button_press_system.system())
// System implementation
fn button_press_system(
buttons: Query<(&Interaction, &MenuButton), (Changed<Interaction>, With<Button>)>,
mut state: ResMut<State<AppState>>,
mut exit: EventWriter<AppExit>
) {
for (interaction, button) in buttons.iter() {
if *interaction == Interaction::Clicked {
match button {
MenuButton::Play => state
.set(AppState::InGame)
.expect("Couldn't switch state to InGame"),
MenuButton::Quit => exit.send(AppExit),
};
}
}
}
In order for this system to run, we need to ensure that a MenuButton
component is added to our button. This system will run the action linked to the type of MenuButton
that was added to our entity.
Here, pressing Play Game
should start a new game, and pressing Quit
should exit the game.
Final Bits of Code
Since we now have a menu that handles starting a new game, we can remove the main_menu_controls
system that was defined in main.rs
. Instead, we can add a new back_to_main_menu_controls
in our game
module:
// GamePlugin
.add_system_set(SystemSet::on_update(AppState::InGame).with_system(back_to_main_menu_controls.system()))
// System implementation
fn back_to_main_menu_controls(mut keys: ResMut<Input<KeyCode>>, mut app_state: ResMut<State<AppState>>) {
if *app_state.current() == AppState::InGame {
if keys.just_pressed(KeyCode::Escape) {
app_state.set(AppState::MainMenu).unwrap();
keys.reset(KeyCode::Escape);
}
}
}
All the code is available here. The most recent version of the game can be played here.
Top comments (3)
May I ask the question what is the difference between NodeBundle and ButtonBundle?
Shouldn't we use ButtonBundle here?
We do use it in the
button
function. Or maybe you meant elsewhere?It is analog to the difference between a
div
tag and abutton
tag in HTML (if you are familiar with web development).Use the
NodeBundle
whenever you need something not much interactive.Use the
ButtonBundle
whenever you need a button / clickable thing.Sorry, I am new to Rust and Bevy, have not noticed that we use it. Thanks for the explanation!