Maciej Główka
Blog Games Contact

← Return to Blog Index

deck.png
May 9, 2023

Bevy roguelike tutorial / devlog part 8 - deck UI

rust bevy gamedev

Well, I've tried to avoid the subject for as long as possible, but I think it's not handy to delay it any more.

Small confession: creating UIs is completely not my thing :) However handling the cards is really not handy at the moment. so we have to fix that - at least a little bit. The code in this part is going to be rather simple (in it's logic), but the volume might get heavier :)

The final code for this part can be found here: https://github.com/maciekglowka/hike_deck/tree/part_08

Assets

As the UI will surely grow into a large part of the game sooner or later, it's definitely gonna need a separate module / plugin. Let's start then by creating a brand new UI module. Next we'll define a resource in it, that is going to hold some necessary assets:


#[derive(Resource)]
pub struct UiAssets {
    pub font: Handle<Font>,
    pub textures: HashMap<&'static str, Handle<Image>>
}

For now we're going to use a single font and a single texture image for our card. (I've put those files into a separate assets/ui/ folder). The card background is a simple static PNG file (like you can see below). I'd probably prefer to use something slightly more fancy, like a 9-slice - but haven't found a good / simple way to achieve it. I now that there are some crates for that, however they're either quite early in their development or have some quirky behaviour (or I could not find the good ones - let me know :).

card.png

The texture handles are held within a HashMap so we'll be able to easily add more in the future. (without redefining the asset struct)

Let's look at loading those assets then. We are going to take the same approach as with the sprites before:


// ui/assets.rs

use bevy::prelude::*;
use std::collections::HashMap;

use super::UiAssets;

const TEXTURES: [&str; 1] = ["card"];

pub fn load_assets(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut asset_list: ResMut<crate::assets::AssetList>
) {
    // font via http://www.dsg4.com/
    let font = asset_server.load("ui/04B_03.ttf");
    asset_list.0.push(font.clone_untyped());

    let mut textures = HashMap::new();
    for name in TEXTURES {
        let handle = asset_server.load(format!("ui/{}.png", name));
        asset_list.0.push(handle.clone_untyped());
        textures.insert(name, handle);
    }

    commands.insert_resource(UiAssets { textures, font });
}

The single element TEXTURES array might look weird at the moment, but surely we are going to add more images later. So it's just a bit of future-proofing :)

Buttons

As the UI is usually drifting towards many nested pieces of code (like children nodes, inside of other children nodes etc.), I'd like to break it down a little - into separate helper functions.

Let's start with something that will be the basis for our in-game buttons:


// ui/helpers.rs

use bevy::prelude::*;

#[derive(Component)]
pub struct ClickableButton;

pub fn get_button(
    commands: &mut Commands,
    size: Size,
    margin: UiRect,
    image: &Handle<Image>,
) -> Entity {
    commands.spawn((
            ClickableButton,
            ButtonBundle {
                style: Style {
                    size,
                    margin,
                    justify_content: JustifyContent::Center,
                    align_items: AlignItems::Center,
                    ..Default::default()
                },
                image: UiImage::new(image.clone()),
                ..Default::default()
            }
        ))
        .id()
}

It is mostly just a plain ButtonBundle, with an extra marker component - that we are going to use as a filter for the click animation:


pub fn button_click_animation(
    mut interactions: Query<(&Interaction, &mut Transform), (Changed<Interaction>, With<ClickableButton>)>
) {
    for (interaction, mut transform) in interactions.iter_mut() {
        match *interaction {
            Interaction::Clicked => {
                transform.scale = Vec3::new(0.95, 0.95, 1.);
            },
            _ => {
                transform.scale = Vec3::splat(1.);
            }
        }
    }
}

As you can see, we take a very simple approach here. If we detect that the button is in a Clicked state we scale it down a bit. Otherwise we return to a scale of 1. Not very pixel-perfect - but will do for now. (this fn will need to be registered as a system).

Let's also create a helper for a TextBundle- that we're gonna use as a content for the buttons:


// in ui/helpers.rs

const FONT_SIZE: f32 = 18.;

// cut

pub fn get_text_bundle(
    text: &str,
    assets: &UiAssets
) -> impl Bundle {
    TextBundle {
        text: Text::from_section(
            text,
            TextStyle {
                color: Color::WHITE,
                font: assets.font.clone(),
                font_size: 18.,
                ..Default::default()
            }
        ),
        ..Default::default() 
    }
}

Deck Menu

Now we can move on to the actual deck menu code:


// ui/deck.rs

use bevy::prelude::*;
use crate::player::{
    cards::CardHolder,
    Deck, DeckEvent, DeckEventKind
};

use super::{helpers, UiAssets};

const DECK_HEIGHT: f32 = 150.;
const CARD_WIDTH: f32 = 96.;
const CARD_HEIGHT: f32 = 128.;
const CARD_MARGIN: f32 = 4.;
const CARD_SELECT: f32 = 24.;

#[derive(Component)]
pub struct DeckMenu;

#[derive(Component)]
pub struct CardButton(Entity, bool);

pub fn draw_deck(
    mut commands: Commands,
    deck_query: Query<Entity, With<DeckMenu>>,
    assets: Res<UiAssets>,
    deck: Res<Deck>,
    card_query: Query<&CardHolder>
) {
    clear_deck(&mut commands, &deck_query);

    let container = commands.spawn((
            DeckMenu,
            NodeBundle {
                style: Style {
                    position_type: PositionType::Absolute,
                    position: UiRect::bottom(Val::Px(0.)),
                    justify_content: JustifyContent::Center,
                    align_items: AlignItems::Center,
                    size: Size::new(Val::Percent(100.), Val::Px(DECK_HEIGHT)),
                    ..Default::default()
                },
                ..Default::default()
            }
        ))
        .id();
        
        for card_entity in deck.cards.iter() {
            let Ok(card_holder) = card_query.get(*card_entity) else { continue };

            // the active card will be shifted upwards a little
            let mut margin = UiRect::all(Val::Px(CARD_MARGIN));
            if Some(*card_entity) == deck.current_card {
                margin.bottom = Val::Px(CARD_SELECT);
            }

            let button = helpers::get_button(
                &mut commands,
                Size::new(Val::Px(CARD_WIDTH), Val::Px(CARD_HEIGHT)),
                margin,
                &assets.textures["card"],
            );

            // add card component to the button
            commands.entity(button).insert(CardButton(*card_entity, false));

            // set button's content
            let content = commands.spawn(
                    helpers::get_text_bundle(&card_holder.0.get_label(), assets.as_ref())
                )
                .id();
            commands.entity(button).add_child(content);

            // parent button to the container
            commands.entity(container).add_child(button);
        }
}

fn clear_deck(
    commands: &mut Commands,
    query: &Query<Entity, With<DeckMenu>>
) {
    for entity in query.iter() {
        commands.entity(entity).despawn_recursive();
    }
}

Phew, that's a lot of code. Let's break it down.

At the beginning we import some structs from the Player module - as we are going to render the UI elements based on that. Then we define constants, describing basic dimensions of our deck menu (again, it's easier to keep track of those values when they're in one place).

Next, we define two components: a marker for the entire deck menu and a CardButton. The button component has two fields. The first one is an entity of a card the button is going to represent (we'll get it from the deck resource). The bool field is going to help us determine the current button state (I'm gonna come back to this a bit later).

Then we create a rather lengthy system to build our card menu. At the beginning we make sure that no other deck is present (we do it via a separate helper func). Next, we spawn the container node, which will occupy the bottom part of our screen.

We iterate through our Deck resource to find all the cards the player is currently holding and create buttons based on that. The button size is set to match our card background sprite, so there is no sub-pixel scaling. To mark the actively selected card, we are going to shift the button up a bit in the deck layout - by increasing it's bottom margin. (you'll see how it works once we run it).

Finally, we create the button's text content, based on a result of a get_label method - that does not exist yet and will give you compiler errors :)

So let's jump for a while into the Player module and fix that:


// in player/cards.rs

pub trait Card: Send + Sync {
    fn get_action(&self, owner: Entity, target: Option<Vector2Int>) -> Option<Box<dyn Action>>;
    fn get_label(&self) -> String;
}

impl Card for WalkCard {
    // cut
    fn get_label(&self) -> String {
        "Walk".into()
    }
}

impl Card for MeleeCard {
    // cut
    fn get_label(&self) -> String {
        format!("Melee\n{} dmg", self.0)
    }
}

We include the get_label method in our Card trait and then do some basic implementations for both our concrete structs. For now we include a line break in the MeleeCard's label, to keep thing more simple - but it's not the best long-term solution, as we mix here data with presentation (so in the future we'll hopefully come back to that :)

UI Plugin

Now that we have all the sub-elements, we can finally create our UI Plugin and register the systems (don't forget to register the plugin in the main.rs as well!):


// ui/mod.rs

use bevy::prelude::*;
use std::collections::HashMap;

use crate::states::GameState;

mod assets;
mod deck;
mod helpers;

pub struct UiPlugin;
impl Plugin for UiPlugin {
    fn build(&self, app: &mut App) {
        app.add_event::<ReloadUiEvent>()
            .add_startup_system(assets::load_assets)
            .add_system(helpers::button_click_animation)
            .add_system(
                player_input_start.in_schedule(OnEnter(GameState::PlayerInput))
            )
            .add_system(
                deck::draw_deck.run_if(on_event::<ReloadUiEvent>())
            );
    }
}

pub struct ReloadUiEvent;

fn player_input_start(
    mut ev_ui: EventWriter<ReloadUiEvent>
) {
    ev_ui.send(ReloadUiEvent);
}

#[derive(Resource)]
pub struct UiAssets {
    pub font: Handle<Font>,
    pub textures: HashMap<&'static str, Handle<Image>>
}

You might notice that I've created a new event here - the ReloadUiEvent. We are going to fire it whenever something changes within the game and the UI has to be refreshed (eg. the player will lose HP or smth). Having a single event, means that we are going to redraw always the entire UI. If we run into performance issues we can always split this into some partial updates in the future. But again, for now I would not worry about that. The solution is not as fancy as implementing some kind of reactivity - but for a simple turn-based game it should be ok-ish.

We are going to use the event as a run condition for our deck drawing system (and in the future for all the other UI builders). We are also going to create a helper system, that would fire this event every time we enter the PlayerInput phase. This way we won't have to schedule those other systems twice (once on reload, second time on the input start).

Now we are finally ready to test effects of the above code. If you do cargo run you'll hopefully see a deck drawn like this:

deck.png

Card selection

You can click the cards and they should nicely animate the button press, but there will be no game effect yet. We still have to implement the actual card selection logic.

Let's start then by removing some redundant parts from our input module:


// delete this from input/mod.rs

    if keys.just_pressed(KeyCode::Key1) {
        if let Some(entity) = deck.cards.get(0) {
            ev_deck.send(DeckEvent(
                DeckEventKind::SelectCard(*entity)
            ));
        }
    }
    if keys.just_pressed(KeyCode::Key2) {
        if let Some(entity) = deck.cards.get(1) {
            ev_deck.send(DeckEvent(
                DeckEventKind::SelectCard(*entity)
            ));
        }
    }

Now, we will create a system somewhat similar to the button_click_animation (shown earlier) - as it is also going to deal with button interactions:


// in ui/deck.rs

pub fn card_click(
    mut interactions: Query<(&Interaction, &mut CardButton), Changed<Interaction>>,
    mut ev_deck: EventWriter<DeckEvent>,
    mut ev_ui: EventWriter<ReloadUiEvent>
) {
    for (interaction, mut button) in interactions.iter_mut() {
        match *interaction {
            Interaction::Clicked => {
                button.1 = true
            },
            Interaction::Hovered => {
                if button.1 {
                    ev_deck.send(DeckEvent(
                        DeckEventKind::SelectCard(button.0)
                    ));
                    ev_ui.send(super::ReloadUiEvent);
                }
                button.1 = false;
            },
            Interaction::None => button.1 = false
        }
    }
}

As Bevy's interactions do not report the released state (at least for now, I believe it is actually proposed to change) - we have to do some workaround here, in order to fire an action only after the release. We could react on the button down press, but I kinda prefer it the other way. It feels more natural somehow and allows for eg. cancelling the click (if we move the mouse outside of the button area, before the release).

We are going to use the boolean parameter of the CardButton component to track whether the button is currently in the press state. This way when we enter the Hovered state we can detect the button release (with the mouse cursor still above the button). This works because we can enter the Hovered state in two ways:

If we detect Hovered state and the bool parameter is set - we will send the card-select event to let the Player module know about our intent. We are also going the fire the ReloadUiEvent as we want to show that a card has been selected (so we redraw the deck).

Lastly we have to register this system in our plugin (ideally to run only during the PlayerInput phase) and we can test run our game again. Now we should be able to switch between the player's action types from our deck!

← Bevy roguelike tutorial / devlog part 7 - better animation Bevy roguelike tutorial / devlog part 9 - dungeon generation→