DEV Community

Sébastien Belzile
Sébastien Belzile

Posted on • Updated on

Making Games in Rust - Part 9 - Main Menu Screen

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()
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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()
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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()
    };
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We still need to tell Bevy to instantiate this resource:

// on our AppBuilder
.init_resource::<MenuMaterials>()
Enter fullscreen mode Exit fullscreen mode

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);
                        });
                });
        });
}
Enter fullscreen mode Exit fullscreen mode

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()));
    }
}
Enter fullscreen mode Exit fullscreen mode
// main.rs
mod main_menu;
use main_menu::MainMenuPlugin;

// ...

.add_plugin(MainMenuPlugin)
Enter fullscreen mode Exit fullscreen mode

Running the game:

Image of the menu

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(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This way, our buttons should change color when hovered, and turn to green when pressed.

Image description

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),
            };
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

All the code is available here. The most recent version of the game can be played here.

Discussion (3)

Collapse
seqq111 profile image
Pavel

May I ask the question what is the difference between NodeBundle and ButtonBundle?
Shouldn't we use ButtonBundle here?

Collapse
sbelzile profile image
Sébastien Belzile Author

Shouldn't we use ButtonBundle here?

We do use it in the button function. Or maybe you meant elsewhere?

May I ask the question what is the difference between NodeBundle and ButtonBundle?

It is analog to the difference between a div tag and a button 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.

Collapse
seqq111 profile image
Pavel

Sorry, I am new to Rust and Bevy, have not noticed that we use it. Thanks for the explanation!