As of Bevy 0.6 it was pretty well understood that Bevy’s internal UI affordances needed work. The Bevy UI system is on deck to be redesigned but we don’t know when that will land or how that will impact user interfaces in Bevy.
In the meantime we have choices. There are a number of different UI crates that integrate with Bevy that serve different purposes.
Today we’ll be using kayak_ui, which is approximates some of the nicer pieces of modern web UI development like components.
To add kayak_ui to our game, we’re going to use the source code directly from GitHub instead of from crates.io. In Cargo.toml
, add kayak_ui
as a dependency, specify the git
field, which is the git repo, the rev
, or revision, which is the commit hash from the commit we want to use.
In this case I’ve picked a hash that I know will work for us, but you can go grab a more recent one if you feel adventurous.
Finally we enable the bevy_renderer
feature for the kayak_ui
crate, which is a feature just like any other feature we’ve used.
kayak_ui = { git = "https://github.com/StarArawn/kayak_ui", rev = "2e8c374943dc7be4f7febc95ae2a3d273ad90038", features = [
"bevy_renderer",
] }
In this way we don’t actually need to use dependencies from crates.io, we can use git repos, branches, or specific hashes as dependencies.
We’ll build our ui in ui.rs
so add a public submodule to lib.rs
.
pub mod board;
pub mod colors;
pub mod controls;
pub mod food;
pub mod snake;
pub mod ui;
Then in ui.rs
we can get our UI started.
We have quite a few items to bring into scope. This isn’t that unusual but the biggest architectural point to note here is that we haven’t brought bevy’s entire prelude into scope. We’ve chosen to specify the prelude items we’re using specifically.
This is because kayak_ui
has a fair number of items that have names that are the same as different items from the bevy crate. These include: App
, Style
, and others.
use bevy::{
app::AppExit,
prelude::{
AssetServer, Commands, EventWriter, Plugin, Res,
ResMut,
},
};
use kayak_ui::{
bevy::{
BevyContext, BevyKayakUIPlugin, FontMapping,
UICameraBundle,
},
core::{
render, rsx,
styles::{
Corner, Edge, LayoutType, Style, StyleProp,
Units,
},
widget, Color, EventType, Index, OnEvent,
},
widgets::{App, Background, Button, Text},
};
Then we’ll set up our own UiPlugin
. I’ve chosen to specify the full module path for bevy::prelude::App
where we use it because we use a different App
from kayak_ui later.
Then we need to add the BevyKayakUIPlugin
to the game, and a new startup system called game_ui
that will handle setting up our Kayak UI.
pub struct UiPlugin;
impl Plugin for UiPlugin {
fn build(&self, app: &mut bevy::prelude::App) {
app.add_plugin(BevyKayakUIPlugin)
.add_startup_system(game_ui);
}
}
The game_ui
system will only run once. This is a very important fact as we get deeper into using bindings to different data from our game like score or game states.
The game_ui
system is meant to set our UI into motion, not to constantly run to update it.
// THIS ONLY RUNS ONCE. VERY IMPORTANT FACT.
fn game_ui(
mut commands: Commands,
mut font_mapping: ResMut<FontMapping>,
asset_server: Res<AssetServer>,
) {
commands.spawn_bundle(UICameraBundle::new());
font_mapping.set_default(
asset_server.load("roboto.kayak_font"),
);
let context = BevyContext::new(|context| {
render! {
<App>
<GameMenu/>
</App>
}
});
commands.insert_resource(context);
}
Similar to how we need to set up a camera to be able to see any sprites we spawn into our game, we need to add the Kayak UICameraBundle
to the game to be able to see any of the UI we create.
The FontMapping
argument is a new type from the kayak_ui crate. It’s what we’ll use to set the default font for all of our UI.
Fonts in kayak are different than the .otf or .ttf font files you may be used to. Kayak fonts are MSDF files which is short for **Multi-channel signed distance field atlas**. These files look super funky and you can find out more about creating your own MSDF files in msdf-atlas-gen.
We’re using the Robot font, which we use Bevy’s asset server to load in and set as the default.
After setting the font, we bootstrap our game UI using a JSX-like syntax and the render!
macro.
We then take the result of that context setup and insert it as a resource into Bevy using commands.
It’s worth taking a look at what’s going on in this “jsx” like syntax and what code actually ends up running.
If you’re using VSCode you can expand the render! macro using the “Expand Macro Recursively” command.
You don’t need to understand this code. The purpose of using the expand macro command is to show that there is real, non-magical code behind the macro and jsx shortcuts.
If you ever have a problem with your JSX or a macro that you can’t quite understand, use the expand macro command and copy and paste the code that Rust Analyzer shows you in the spot where the macro is.
You’ll get to see exactly where whatever error you’re seeing is happening.
let mut context = kayak_ui::core::KayakContextRef::new(context, None);
let parent_id: Option<Index> = None;
let children: Option<kayak_ui::core::Children> = None;
{
let mut internal_rsx_props = <App as kayak_ui::core::Widget>::Props::default();
let children = children.clone();
kayak_ui::core::WidgetProps::set_children(
&mut internal_rsx_props,
Some(kayak_ui::core::Children::new(
move |parent_id: Option<kayak_ui::core::Index>,
context: &mut kayak_ui::core::KayakContextRef| {
let child_widget = {
let mut internal_rsx_props =
<GameMenu as kayak_ui::core::Widget>::Props::default();
let widget =
<GameMenu as kayak_ui::core::Widget>::constructor(internal_rsx_props);
widget
};
context.add_widget(child_widget, 0usize);
context.commit();
},
)),
);
let built_widget = <App as kayak_ui::core::Widget>::constructor(internal_rsx_props);
context.add_widget(built_widget, 0usize);
}
context.commit();
Building Widgets
The App
widget comes directly from kayak_ui. It’s a widget that matches the size of of the Bevy window, which is very useful for us.
Inside of the App
widget we have our own widget: GameMenu
.
We start out by decorating a function with the #[widget]
macro from kayak_ui. This is how we build out functionality and visuals that we can use repeatedly or just colocate related UI.
#[widget]
fn GameMenu() {
let container_styles = Style {
border_radius: StyleProp::Value(Corner::all(15.0)),
background_color: StyleProp::Value(Color::WHITE),
bottom: StyleProp::Value(Units::Stretch(1.0)),
height: StyleProp::Value(Units::Pixels(500.0)),
layout_type: StyleProp::Value(LayoutType::Column),
left: StyleProp::Value(Units::Stretch(1.0)),
padding: StyleProp::Value(Edge::all(
Units::Stretch(1.0),
)),
right: StyleProp::Value(Units::Stretch(1.0)),
row_between: StyleProp::Value(Units::Pixels(20.0)),
top: StyleProp::Value(Units::Stretch(1.0)),
width: StyleProp::Value(Units::Pixels(360.0)),
..Default::default()
};
let button_styles = Style {
background_color: StyleProp::Value(Color::BLACK),
height: StyleProp::Value(Units::Pixels(50.0)),
width: StyleProp::Value(Units::Pixels(200.0)),
padding_top: StyleProp::Value(Units::Stretch(1.0)),
padding_bottom: StyleProp::Value(Units::Stretch(
1.0,
)),
..Default::default()
};
let on_click_new_game =
OnEvent::new(|_, event| {
match event.event_type {
EventType::Click(..) => {
dbg!("new game!");
}
_ => {}
}
});
let on_click_settings =
OnEvent::new(|_, event| {
match event.event_type {
EventType::Click(..) => {
dbg!("clicked settings");
}
_ => {}
}
});
let on_click_exit = OnEvent::new(|ctx, event| {
match event.event_type {
EventType::Click(..) => {
ctx
.query_world::<EventWriter<AppExit>, _, _>(
|mut exit| {
exit.send(AppExit);
},
);
}
_ => {}
}
});
rsx! {
<Background
styles={Some(container_styles)}
>
<Button
on_event={Some(on_click_new_game)}
styles={Some(button_styles)}
>
<Text
size={20.0}
content={"New Game".to_string()}
/>
</Button>
<Button
on_event={Some(on_click_settings)}
styles={Some(button_styles)}
>
<Text
size={20.0}
content={"Settings".to_string()}
/>
</Button>
<Button
on_event={Some(on_click_exit)}
styles={Some(button_styles)}
>
<Text
size={20.0}
content={"Exit".to_string()}
/>
</Button>
</Background>
}
}
GameMenu
has a container with three buttons in it. The container is centered on the screen using a css-like API and buttons center their text children. Each button has a click handler that will do something different.
Styles
The Style
struct that we use with kayakui is _like CSS, but is not CSS. For example, you won’t find margins in kayak_ui because it’s based on the subform layout algorithm implementation contained in the morphorm crate.
Notably our container sets top/bottom/left/right to Stretch(1.0
) which will center our container. We also do this with the padding, which will center the children elements. The layout_type
is set to Column
which handles laying out the child buttons in a vertical column, and setting row_between
ensures we have space between each of the buttons.
Our button styles take a similar approach to center the text children inside of them.
Event Handlers
Event handlers in kayak_ui are very similar to any other click handler on the web platform. To create a new event handler, we use OnEvent::new
and pass in the event handler.
let on_click_new_game =
OnEvent::new(|_, event| {
match event.event_type {
EventType::Click(..) => {
dbg!("new game!");
}
_ => {}
}
});
In all three of our handlers we pass in a closure as the event handler function, which is called a lambda or anonymous function in other languages.
The event handler accepts a context
and the event
as arguments. We can use context
to access the world and make queries or changes, while we can use the event
to determine what happened.
In all of our cases we only do something if the event is a click event. Both the New Game
and Settings
buttons currently only debug that they were indeed clicked.
The Exit
button uses context to access the world. Bevy offers us an AppExit
event that will close the game out for us. We use query_world
to query for an EventWriter
that behaves the same way our apple EventWriter
works.
EventWriter
s need to be mutable to write events to them, so we pass in a closure to query_world
that accepts a mut exit
that we use to send the AppExit
event.
This is pretty typical for how we’ll interact with the world from event handlers.
ctx.query_world::<EventWriter<AppExit>, _, _>(
|mut exit| {
exit.send(AppExit);
},
);
Finally we use rsx
to build up our elements, apply their styles, and set event handlers.
Don’t forget to add the UiPlugin
to our app in main.rs
.
.add_plugins(DefaultPlugins)
.add_plugin(ControlsPlugin)
.add_plugin(FoodPlugin)
.add_plugin(UiPlugin)
We see a three button menu when we cargo run
, where two of the buttons log out debug information and the last closes the entire application.
[src/ui.rs:88] "new game!" = "new game!"
[src/ui.rs:96] "clicked settings" = "clicked settings"