We can play the game at this point and keep score, but we don't have any scorekeeping display so the user can't see their score. We're going to cover two pieces of functionality to show the score (and some other UI): Bevy Plugins and UI layout.
UI in Bevy lives on its own layer with its own camera. When working with Bevy UI it feels like it would work well as a HUD in a 3d shooter or 2d platformer. 2048 is none of those things, so the orginal game UI feels a bit awkward to build because it was built on the web, which is a single-layer.
We'll be going with a UI layout that displays the game name (2048) on the left, score display in the middle and a "new game" button on the right.
:2048: :score: :best-score: :new-game:
Since the UI isn't directly affecting the game logic, we can put the UI layout and logic in its own module.
In main.rs
we're going to declare a new sub-module called ui
right next to where we defined colors
.
mod colors;
mod ui;
Module paths in Rust often mirror the filesystem but they aren't required to. mod ui
in our case does point to a file (at src/ui.rs
), but we could also have written mod ui {}
and put all of the relevant code inside of the module's block (between the braces). Either way it functions the same.
In src/ui.rs
we'll start by adding a new struct to represent our plugin. This plugin is going to be responsible for dealing with all of the UI related concerns, so I'll name it GameUiPlugin
.
pub struct GameUiPlugin
The pub
before it makes it so that main.rs
can access the struct. Specifically, we're going to use add_plugin
in main.rs
on our App
builder, passing the GameUiPlugin
struct as an argument.
.add_plugin(ui::GameUiPlugin)
Back in ui.rs
we're going to bring Bevy's prelude into scope in the ui module.
use bevy::prelude::*;
Bevy Plugins are a way to organize groups of related functionality. Similar to the way we set up our game with an App
builder, we can do the same in a plugin and then add that plugin to the main App
instead.
To do that we are going to implement the Plugin
trait for GameUiPlugin
which requires us to implement the build
method to satisfy the trait.
You'll notice the implementation is very similar to our App
in main.rs
. We only have one startup system at the moment (setup_ui
), but as we add more systems to handle updating the UI, they will also go here.
impl Plugin for GameUiPlugin {
fn build(&self, app: &mut App) {
app.add_startup_system(setup_ui);
}
}
If you're familiar with html, we're about to build a structure like this.
<div>
<span>2048</span>
<div>
<div>
<span>Score</span>
<span>200</span>
</div>
<div>
<span>Best</span>
<span>200</span>
</div>
</div>
<button><span>Button</span></button>
</div>
This is a three-column layout where the middle column is a set of two boxes that holds the current score and the top score ever achieved.
We'll be using three bundles to build the UI: NodeBundle
which is like a div, TextBundle
, and ButtonBundle
.
The commands we're command to use are .spawn
, which we've seen before, with_children
, and insert
.
We'll use spawn
to create each of the bundles we talked about earlier: NodeBundle
, TextBundle
, and ButtonBundle
.
.with_children
will give us a builder for the entity we just spawned. For example, the following code with spawn a NodeBundle
, then spawn a TextBundle
as a child of the NodeBundle
.
commands
.spawn(NodeBundle {...})
.with_children(|parent| {
parent.spawn(TextBundle {...});
});
The full layout will look like this. I’ve removed all of the fields so that you can see the structure of the nodes, text, and buttons.
commands
.spawn(NodeBundle {..})
.with_children(|parent| {
parent.spawn(TextBundle{..});
parent
.spawn(NodeBundle {..})
.with_children(|parent| {
// scorebox
parent
.spawn(NodeBundle {..})
.with_children(|parent| {
parent.spawn(TextBundle{..});
parent.spawn((
TextBundle{..},
ScoreDisplay,
));
});
// end scorebox
// best scorebox
parent
.spawn(NodeBundle {..})
.with_children(|parent| {
parent.spawn(TextBundle{..});
parent.spawn((
TextBundle{..},
BestScoreDisplay,
));
});
// end best scorebox
});
parent
.spawn(ButtonBundle {..})
.with_children(|parent| {
parent.spawn(TextBundle {..});
});
});
All styles and positioning are handled through creating structs. A NodeBundle
for example, accepts a style
field that takes a Style
struct. The Style
struct has a number of fields that accept additional structs or enums. For any fields we construct the values we want to change or control, and leave the rest to their default implementation using update struct syntax.
The Size
struct has a method called new
, which is a common pattern for structs in general in Rust. The new
method takes two Val
enums for how wide and tall the node should be. In this case we use percentage values. We could have also used Val::Px
or Val::Auto
.
The default layout (or display) mechanism in Bevy is flexbox so we can use that.
Style {
size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
align_items: AlignItems::FlexEnd,
padding: Rect::all(Val::Px(50.0)),
..default()
}
Lastly, we need to label some of the text fields so that we can access them later. After we spawn the TextBundle
s, we can insert ScoreDisplay
and BestScoreDisplay
components to use later in a system to update them.
parent
.spawn_bundle(TextBundle {...})
.insert(ScoreDisplay);
We repeat these patterns to create the whole UI as such.
use bevy::prelude::*;
use crate::{colors, FontSpec};
mod styles;
pub struct GameUiPlugin;
impl Plugin for GameUiPlugin {
fn build(&self, app: &mut App) {
app.add_startup_system(setup_ui);
}
}
#[derive(Component)]
pub struct ScoreDisplay;
#[derive(Component)]
pub struct BestScoreDisplay;
fn setup_ui(
mut commands: Commands,
font_spec: Res<FontSpec>,
) {
commands
.spawn(NodeBundle {
style: Style {
size: Size::new(
Val::Percent(100.0),
Val::Percent(100.0),
),
align_items: AlignItems::FlexStart,
justify_content:
JustifyContent::SpaceBetween,
padding: UiRect::all(Val::Px(50.0)),
..default()
},
..default()
})
.with_children(|parent| {
parent.spawn(TextBundle::from_section(
"2048",
TextStyle {
font: font_spec.family.clone(),
font_size: 40.0,
color: Color::WHITE,
},
));
parent
.spawn(NodeBundle {
style: Style {
justify_content:
JustifyContent::Center,
size: Size::AUTO,
gap: Size::all(Val::Px(20.0)),
..default()
},
..default()
})
.with_children(|parent| {
// scorebox
parent
.spawn(NodeBundle {
style: styles::SCORE_CONTAINER,
background_color:
BackgroundColor(
colors::SCORE_BOX,
),
..default()
})
.with_children(|parent| {
parent.spawn(
TextBundle::from_section(
"Score",
TextStyle {
font: font_spec
.family
.clone(),
font_size: 15.0,
color: Color::WHITE,
},
)
.with_text_alignment(
TextAlignment::Center,
),
);
parent.spawn((
TextBundle::from_section(
"<score>",
TextStyle {
font: font_spec
.family
.clone(),
font_size: 20.0,
color: Color::WHITE,
},
)
.with_text_alignment(
TextAlignment::Center,
),
ScoreDisplay,
));
});
// end scorebox
// best scorebox
parent
.spawn(NodeBundle {
style: styles::SCORE_CONTAINER,
background_color:
BackgroundColor(
colors::SCORE_BOX,
),
..default()
})
.with_children(|parent| {
parent.spawn(
TextBundle::from_section(
"Best",
TextStyle {
font: font_spec
.family
.clone(),
font_size: 15.0,
color: Color::WHITE,
},
)
.with_text_alignment(
TextAlignment::Center,
),
);
parent.spawn((
TextBundle::from_section(
"<score>",
TextStyle {
font: font_spec
.family
.clone(),
font_size: 20.0,
color: Color::WHITE,
},
)
.with_text_alignment(
TextAlignment::Center,
),
BestScoreDisplay,
));
});
// end best scorebox
});
parent
.spawn(ButtonBundle {
style: Style {
size: Size::new(
Val::Px(130.0),
Val::Px(50.0),
),
justify_content:
JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
..default()
})
.with_children(|parent| {
parent.spawn(TextBundle {
text: Text::from_section(
"Button",
TextStyle {
font: font_spec
.family
.clone(),
font_size: 20.0,
color: Color::rgb(
0.9, 0.9, 0.9,
),
},
),
..default()
});
});
});
}
Notably we’re also using a new color in colors::SCORE_BOX
.
pub const SCORE_BOX: Color = Color::Lcha {
lightness: 0.55,
chroma: 0.5,
hue: 315.0,
alpha: 1.0,
};
and we’ve created a new styles
module from our ui
module. Not all of our styles need to be placed here, only the ones we’re re-using across elements.
The big interesting note here is that the Default::default()
trait’s function isn’t const
, which means it can’t be evaluated at compile time. So if we want to set up a const
with a type of Style
, Bevy has provided a temporary workaround for us. In this case, there is a const
associated with Style
that is arbitrarily named DEFAULT
that we can use instead.
use bevy::prelude::*;
pub const SCORE_CONTAINER: Style = Style {
flex_direction: FlexDirection::ColumnReverse,
align_items: AlignItems::Center,
padding: UiRect {
left: Val::Px(20.0),
right: Val::Px(20.0),
top: Val::Px(10.0),
bottom: Val::Px(10.0),
},
..Style::DEFAULT
};
At the top we've use
d some structs using a new module path that starts with crate::
.
use crate::{colors, FontSpec};
A crate in Rust is a tree of modules that produce a library or executable. A tree of modules can start at src/main.rs
for executables, like we have, or at src/lib.rs
for libraries. You can use other filenames for the crate root files, but main and lib are what you'll see the most.
We can use
items from the current crate root then, by using the crate::
prefix.
In this case since we're writing use
in an executable with the crate root at src/main.rs
, crate::
refers to items in src/main.rs
, such as FontSpec
and colors
.
Altogether this gives us the static UI elements that we'll update with the score in the middle of the screen.
The Button is supposed to be white, we’ll handle that in another lesson.