Skip to content

Commit

Permalink
Setting up the UI around the game
Browse files Browse the repository at this point in the history
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`. Then immediately below that we'll bring everything defined as public in `ui` into main's scope.

```rust
mod ui;
use 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, we still need the `use` to pull the public functions into scope.

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`.

```rust
pub struct GameUiPlugin
```

The `pub` before it makes it so that `main.rs` can access the struct. Specicially, we're going to use `add_plugin` in `main.rs` on our `App` builder, passing the `GameUiPlugin` struct as an argument.

```rust
.add_plugin(GameUiPlugin)
```

Back in `ui.rs` we're going to bring Bevy's prelude into scope in the ui module.

```rust
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 `AppBuilder` 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.

```rust
impl Plugin for GameUiPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_startup_system(setup_ui.system());
    }
}
```

I mentioned cameras earlier. Anything we want to show on screen in Bevy needs to be visible by a camera. For our game board and tiles this is taken care of by a 2d Orthographic camera that we set up at the beginning of the project. For the game UI or HUDs or overlays, we need to use a specialized UI camera that can show UI elements. We can do this by using a `UICameraBundle`.

```rust
fn setup_ui(
    mut commands: Commands,
) {
    commands.spawn_bundle(UiCameraBundle::default());
}
```

If you're familiar with html, we're about to build a structure like this.

```html
<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`.

We'll add one more material to our `Materials`: a `NONE` color.

```rust
struct Materials {
    board: Handle<ColorMaterial>,
    tile_placeholder: Handle<ColorMaterial>,
    tile: Handle<ColorMaterial>,
    none: Handle<ColorMaterial>,
}

impl FromWorld for Materials {
    fn from_world(world: &mut World) -> Self {
        let mut materials = world
            .get_resource_mut::<Assets<ColorMaterial>>()
            .unwrap();
        Materials {
            board: materials
                .add(Color::rgb(0.7, 0.7, 0.8).into()),
            tile_placeholder: materials
                .add(Color::rgb(0.75, 0.75, 0.9).into()),
            tile: materials
                .add(Color::rgb(0.9, 0.9, 1.0).into()),
            none: materials.add(Color::NONE.into()),
        }
    }
}
```

The commands we're command to use are `.spawn_bundle`, which we've seen before, `with_children`, and `insert`.

We'll use `spawn_bundle` 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`.

```rust
commands
    .spawn_bundle(NodeBundle {...})
    .with_children(|parent| {
        parent.spawn_bundle(TextBundle {...});
    });
```

The full layout will look like this:

```rust
commands
    .spawn_bundle(NodeBundle {...})
    .with_children(|parent| {
        parent.spawn_bundle(TextBundle {...});

        parent
            .spawn_bundle(NodeBundle {...})
            .with_children(|parent| {
                // scorebox
                parent
                    .spawn_bundle(NodeBundle {...})
                    .with_children(|parent| {
                        parent.spawn_bundle(TextBundle {...});
                        parent
                            .spawn_bundle(TextBundle {...})
                            .insert(ScoreDisplay);
                    });
                // end scorebox
                // best scorebox
                parent
                    .spawn_bundle(NodeBundle {...})
                    .with_children(|parent| {
                        parent.spawn_bundle(TextBundle {...});
                        parent
                            .spawn_bundle(TextBundle {...})
                            .insert(BestScoreDisplay);
                    });
                // end best scorebox
            });
        parent
            .spawn_bundle(ButtonBundle {...})
            .with_children(|parent| {
                parent.spawn_bundle(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 never really need to change that.

```rust
Style {
    size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
    align_items: AlignItems::FlexEnd,
    padding: Rect::all(Val::Px(50.0)),
    ..Default::default()
},
```

Notably all `NodeBundle`s are created with a default material that has a white background, which will obscure the game board. To get rid of it we can use the `NONE` material we set up earlier.

```rust
material: materials.none.clone(),
```

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.

```rust
parent
    .spawn_bundle(TextBundle {...})
    .insert(ScoreDisplay);
```

We repeat these patterns to create the whole UI as such.

```rust
use crate::{FontSpec, Materials};
use bevy::prelude::*;

pub struct ScoreDisplay;
pub struct BestScoreDisplay;

pub struct GameUiPlugin;

impl Plugin for GameUiPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_startup_system(setup_ui.system());
    }
}

fn setup_ui(
    mut commands: Commands,
    materials: Res<Materials>,
    font_spec: Res<FontSpec>,
) {
    commands.spawn_bundle(UiCameraBundle::default());
    commands
        .spawn_bundle(NodeBundle {
            style: Style {
                size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
                align_items: AlignItems::FlexEnd,
                padding: Rect::all(Val::Px(50.0)),
                ..Default::default()
            },
            material: materials.none.clone(),
            ..Default::default()
        })
        .with_children(|parent| {
            parent.spawn_bundle(TextBundle {
                text: Text::with_section(
                    "2048",
                    TextStyle {
                        font: font_spec.family.clone(),
                        font_size: 40.0,
                        color: Color::WHITE,
                    },
                    TextAlignment::default(),
                ),
                ..Default::default()
            });

            parent
                .spawn_bundle(NodeBundle {
                    style: Style {
                        justify_content: JustifyContent::Center,
                        size: Size::new(Val::Percent(100.0), Val::Auto),
                        ..Default::default()
                    },
                    material: materials.none.clone(),
                    ..Default::default()
                })
                .with_children(|parent| {
                    // scorebox
                    parent
                        .spawn_bundle(NodeBundle {
                            style: Style {
                                flex_direction: FlexDirection::ColumnReverse,
                                align_items: AlignItems::Center,
                                margin: Rect {
                                    left: Val::Px(20.0),
                                    right: Val::Px(20.0),
                                    top: Val::Px(0.0),
                                    bottom: Val::Px(0.0),
                                },
                                padding: Rect::all(Val::Px(10.0)),
                                ..Default::default()
                            },
                            material: materials.tile_placeholder.clone(),
                            ..Default::default()
                        })
                        .with_children(|parent| {
                            parent.spawn_bundle(TextBundle {
                                text: Text::with_section(
                                    "Score",
                                    TextStyle {
                                        font: font_spec.family.clone(),
                                        font_size: 15.0,
                                        color: Color::WHITE,
                                    },
                                    TextAlignment {
                                        vertical: VerticalAlign::Center,
                                        horizontal: HorizontalAlign::Center,
                                    },
                                ),
                                ..Default::default()
                            });
                            parent
                                .spawn_bundle(TextBundle {
                                    text: Text::with_section(
                                        "<score>",
                                        TextStyle {
                                            font: font_spec.family.clone(),
                                            font_size: 20.0,
                                            color: Color::WHITE,
                                        },
                                        TextAlignment {
                                            vertical: VerticalAlign::Center,
                                            horizontal: HorizontalAlign::Center,
                                        },
                                    ),
                                    ..Default::default()
                                })
                                .insert(ScoreDisplay);
                        });
                    // end scorebox
                    // best scorebox
                    parent
                        .spawn_bundle(NodeBundle {
                            style: Style {
                                flex_direction: FlexDirection::ColumnReverse,
                                align_items: AlignItems::Center,
                                padding: Rect::all(Val::Px(10.0)),
                                ..Default::default()
                            },
                            material: materials.tile_placeholder.clone(),
                            ..Default::default()
                        })
                        .with_children(|parent| {
                            parent.spawn_bundle(TextBundle {
                                text: Text::with_section(
                                    "Best",
                                    TextStyle {
                                        font: font_spec.family.clone(),
                                        font_size: 15.0,
                                        color: Color::WHITE,
                                    },
                                    TextAlignment {
                                        vertical: VerticalAlign::Center,
                                        horizontal: HorizontalAlign::Center,
                                    },
                                ),
                                ..Default::default()
                            });
                            parent
                                .spawn_bundle(TextBundle {
                                    text: Text::with_section(
                                        "<score>",
                                        TextStyle {
                                            font: font_spec.family.clone(),
                                            font_size: 20.0,
                                            color: Color::WHITE,
                                        },
                                        TextAlignment {
                                            vertical: VerticalAlign::Center,
                                            horizontal: HorizontalAlign::Center,
                                        },
                                    ),
                                    ..Default::default()
                                })
                                .insert(BestScoreDisplay);
                        });
                    // end best scorebox
                });
            parent
                .spawn_bundle(ButtonBundle {
                    style: Style {
                        size: Size::new(Val::Px(100.0), Val::Px(30.0)),
                        justify_content: JustifyContent::Center,
                        align_items: AlignItems::Center,
                        ..Default::default()
                    },
                    ..Default::default()
                })
                .with_children(|parent| {
                    parent.spawn_bundle(TextBundle {
                        text: Text::with_section(
                            "Button",
                            TextStyle {
                                font: font_spec.family.clone(),
                                font_size: 20.0,
                                color: Color::rgb(0.9, 0.9, 0.9),
                            },
                            Default::default(),
                        ),
                        ..Default::default()
                    });
                });
        });
}
```

At the top we've `use`d some structs using a new module path that starts with `crate::`.

```
use crate::{FontSpec, Materials};
```

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 these 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 `Materials`.

Altogether this gives us the static UI elements that we'll update with the score in the middle of the screen.
  • Loading branch information
ChristopherBiscardi committed Jul 19, 2021
1 parent fa8eade commit 9e7f6ea
Show file tree
Hide file tree
Showing 2 changed files with 189 additions and 0 deletions.
6 changes: 6 additions & 0 deletions src/main.rs
Expand Up @@ -4,6 +4,9 @@ use bevy::prelude::*;
use itertools::Itertools;
use rand::prelude::*;

mod ui;
use ui::*;

const TILE_SIZE: f32 = 40.0;
const TILE_SPACER: f32 = 10.0;

Expand Down Expand Up @@ -50,6 +53,7 @@ struct Materials {
board: Handle<ColorMaterial>,
tile_placeholder: Handle<ColorMaterial>,
tile: Handle<ColorMaterial>,
none: Handle<ColorMaterial>,
}

impl FromWorld for Materials {
Expand All @@ -64,6 +68,7 @@ impl FromWorld for Materials {
.add(Color::rgb(0.75, 0.75, 0.9).into()),
tile: materials
.add(Color::rgb(0.9, 0.9, 1.0).into()),
none: materials.add(Color::NONE.into()),
}
}
}
Expand Down Expand Up @@ -180,6 +185,7 @@ fn main() {
.init_resource::<Materials>()
.init_resource::<FontSpec>()
.init_resource::<Game>()
.add_plugin(GameUiPlugin)
.add_startup_system(setup.system())
.add_startup_system(spawn_board.system())
.add_startup_system_to_stage(
Expand Down
183 changes: 183 additions & 0 deletions src/ui.rs
@@ -0,0 +1,183 @@
use crate::{FontSpec, Materials};
use bevy::prelude::*;

pub struct ScoreDisplay;
pub struct BestScoreDisplay;

pub struct GameUiPlugin;

impl Plugin for GameUiPlugin {
fn build(&self, app: &mut AppBuilder) {
app.add_startup_system(setup_ui.system());
}
}

fn setup_ui(
mut commands: Commands,
materials: Res<Materials>,
font_spec: Res<FontSpec>,
) {
commands.spawn_bundle(UiCameraBundle::default());
commands
.spawn_bundle(NodeBundle {
style: Style {
size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
align_items: AlignItems::FlexEnd,
padding: Rect::all(Val::Px(50.0)),
..Default::default()
},
material: materials.none.clone(),
..Default::default()
})
.with_children(|parent| {
parent.spawn_bundle(TextBundle {
text: Text::with_section(
"2048",
TextStyle {
font: font_spec.family.clone(),
font_size: 40.0,
color: Color::WHITE,
},
TextAlignment::default(),
),
..Default::default()
});

parent
.spawn_bundle(NodeBundle {
style: Style {
justify_content: JustifyContent::Center,
size: Size::new(Val::Percent(100.0), Val::Auto),
..Default::default()
},
material: materials.none.clone(),
..Default::default()
})
.with_children(|parent| {
// scorebox
parent
.spawn_bundle(NodeBundle {
style: Style {
flex_direction: FlexDirection::ColumnReverse,
align_items: AlignItems::Center,
margin: Rect {
left: Val::Px(20.0),
right: Val::Px(20.0),
top: Val::Px(0.0),
bottom: Val::Px(0.0),
},
padding: Rect::all(Val::Px(10.0)),
..Default::default()
},
material: materials.tile_placeholder.clone(),
..Default::default()
})
.with_children(|parent| {
parent.spawn_bundle(TextBundle {
text: Text::with_section(
"Score",
TextStyle {
font: font_spec.family.clone(),
font_size: 15.0,
color: Color::WHITE,
},
TextAlignment {
vertical: VerticalAlign::Center,
horizontal: HorizontalAlign::Center,
},
),
..Default::default()
});
parent
.spawn_bundle(TextBundle {
text: Text::with_section(
"<score>",
TextStyle {
font: font_spec.family.clone(),
font_size: 20.0,
color: Color::WHITE,
},
TextAlignment {
vertical: VerticalAlign::Center,
horizontal: HorizontalAlign::Center,
},
),
..Default::default()
})
.insert(ScoreDisplay);
});
// end scorebox
// best scorebox
parent
.spawn_bundle(NodeBundle {
style: Style {
flex_direction: FlexDirection::ColumnReverse,
align_items: AlignItems::Center,
padding: Rect::all(Val::Px(10.0)),
..Default::default()
},
material: materials.tile_placeholder.clone(),
..Default::default()
})
.with_children(|parent| {
parent.spawn_bundle(TextBundle {
text: Text::with_section(
"Best",
TextStyle {
font: font_spec.family.clone(),
font_size: 15.0,
color: Color::WHITE,
},
TextAlignment {
vertical: VerticalAlign::Center,
horizontal: HorizontalAlign::Center,
},
),
..Default::default()
});
parent
.spawn_bundle(TextBundle {
text: Text::with_section(
"<score>",
TextStyle {
font: font_spec.family.clone(),
font_size: 20.0,
color: Color::WHITE,
},
TextAlignment {
vertical: VerticalAlign::Center,
horizontal: HorizontalAlign::Center,
},
),
..Default::default()
})
.insert(BestScoreDisplay);
});
// end best scorebox
});
parent
.spawn_bundle(ButtonBundle {
style: Style {
size: Size::new(Val::Px(100.0), Val::Px(30.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..Default::default()
},
..Default::default()
})
.with_children(|parent| {
parent.spawn_bundle(TextBundle {
text: Text::with_section(
"Button",
TextStyle {
font: font_spec.family.clone(),
font_size: 20.0,
color: Color::rgb(0.9, 0.9, 0.9),
},
Default::default(),
),
..Default::default()
});
});
});
}

0 comments on commit 9e7f6ea

Please sign in to comment.