This chapter shows how to add a player sprite with a texture to a game. This allows us to use images in our game.
This chapter introduces:
- The Bevy
Handle
- The Bevy
Image
cfg!(test)
, anif
statement only true during testing- Adding the
AssetPlugin
andTaskPoolPlugin
- Initializing a Bevy image
- Where to put assets (i.e. images, sounds, etc.)
- Loading an image from file using
AssetServer
Our first tests are the same as in earlier chapters,
forcing us to write create_app
, count_n_players
and
the Player
component:
fn test_empty_app_has_no_players() {
let mut app = App::new();
app.update();
assert_eq!(count_n_players(&mut app), 0);
}
fn test_our_app_has_a_player() {
let mut app = create_app();
app.update();
assert_eq!(count_n_players(&mut app), 1);
}
See the 'Add a player' chapter for the implementations and an in-depth discussion.
There is one big difference in these tests: app.update
is called
in each test! In all earlier chapters, we called app.update
at the end of the create_app
function.
In this chapter, we don't, because doing so finalizes the App
too early and this will cause that our game
to panic in the main
function (hence we cannot play the game).
This will be discussed in both the create_app
and
the main
function paragraph.
In the 'Add a player sprite' chapter
the size of the player's sprite was set to its
size (i.e width and height) in pixels, using
the scale
of a player's Transform
. In that context,
scale
is a misnomer: it indicates the size of the player (and not the
scale).
When using an image, 'scale' is nicely named: an image
has its inherent size in pixels, where scale
will scale the image up or down. When the scale is set to one,
an image will be displayed at its original 1:1 scale.
In Bevy, a scale has three dimensions.
Here we test that the scale of our image is one in both two dimensions, as we are working on as 2D game. We will later assert that the scale in the third dimension is one too.
Here is the test:
fn test_player_has_the_default_scale() {
let mut app = create_app();
app.update();
assert_eq!(get_player_scale(&mut app), Vec2::new(1.0, 1.0));
}
Although the function name get_player_scale
is new, it has
exactly the same implementation as get_player_size
in the previous chapter:
fn get_player_scale(app: &mut App) -> Vec2 {
let mut query = app.world_mut().query::<(&Transform, &Player)>();
let (transform, _) = query.single(app.world());
transform.scale.xy()
}
In the context of using an image, it is more natural to work with a scale than with a size.
Now for the texture:
fn test_player_has_a_texture() {
let mut app = create_app();
app.update();
assert!(get_player_has_texture(&mut app));
}
The seemingly innocent test above, forces us into a complex fix:
- Write the
get_player_has_texture
function - Modify the
create_app
function - Write the
add_player
function
The get_player_has_texture
function contains a query similar to what
we know:
fn get_player_has_texture(app: &mut App) -> bool {
let mut query = app.world_mut().query::<(&Handle<Image>, &Player)>();
let (handle, _) = query.single(app.world());
handle.is_strong()
}
There are two new elements:
&Handle<Image>
: this reads as 'a handle to an image'. This data type is chosen from the Bevy documentation ofSpriteBundle
(see below), as it is the data type of theSpriteBundle
field calledtexture
. A 'handle to an image' is a lightweight way to work with images, similar to the Rustbox
class: both can be copied and cloned and ... emptyhandle.is_strong()
determines if the handle indeed refers to an image that is actually (being) loaded
Part of the Bevy SpriteBundle documentation
The create_app
function may look like this:
pub fn create_app() -> App {
let mut app = App::new();
if cfg!(test) {
app.add_plugins(AssetPlugin::default());
app.add_plugins(TaskPoolPlugin::default());
app.init_asset::<bevy::render::texture::Image>();
}
app.add_systems(Startup, add_player);
app
}
The if
statement and its body are new, I unpack it here:
if cfg!(test) { ... }
is read as 'when running tests, run the code between curly brackets'. This means that this code is ignored when running our game from themain
functionapp.add_plugins(AssetPlugin::default())
: add the defaultAssetPlugin
app.add_plugins(TaskPoolPlugin::default())
: add the defaultTaskPoolPlugin
app.init_asset::<bevy::render::texture::Image>()
: initialize theImage
asset
A plugin is a combination of entities, components and systems that work together to achieve a goal. It is a unit of modularity: you can put these in a Rust crate and become part of the Bevy ecosystem.
With the AssetPlugin
present, we need to initialize it to allow for
working with images.
Note that one only needs to remember to add the AssetPlugin
: the following
two lines were copied from helpful error messages.
Adding the default plugins (called DefaultPlugins
) seems like
a good idea: one can be sure that the most important plugins
are present. The problem with this, however, is that will turn
our program into a full-blown application with a window that
needs to be closed by a user. Our tests should run without
user input, hence we cannot use all the default plugins.
Instead, we only add the plugins we need for testing in a build
that runs the test.
The reason why we do not add app.update()
to the create_app
function
is because of the if cfg!(test)
: as a consequence of this if statement,
running the tests will differ from running the game: the test build
will have two plugins added and one asset initialized,
where this will not be done when running the game normally.
When running the game normally with an app.update()
in the end,
the program will panic, because add_player
is called without
the needed plugins. This problem is solved by, in main
,
adding the (many more) plugins there.
The add_player
function may look like this:
fn add_player(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn((
SpriteBundle {
texture: asset_server.load("bevy_bird_dark.png"),
..default()
},
Player,
));
}
The biggest surprise is the asset_server: Res<AssetServer>
:
apparently, when adding a Bevy system, one can 'magically' get
access to an asset_server
of datatype Res<AssetServer>
.
A Res
is a unique Bevy resource, in this case of type AssetServer
:
a structure for assets.
The creation of the player's SpriteBundle
is similar too, except for
texture: asset_server.load("bevy_bird_dark.png")
, where a texture is
loaded from file by the assets server. For this to work, that file (in this
case, bevy_bird_dark.png
) must be present in the assets
folder
of the Rust project.
The main
function has not changed at all:
fn main() {
let mut app = create_app();
let add_camera_fn = |mut commands: Commands| {
commands.spawn(Camera2dBundle::default());
};
app.add_systems(Startup, add_camera_fn);
app.add_plugins(DefaultPlugins);
app.run();
}
The reason why we did not add app.update()
to the create_app
function
is because of that we add the default plugins here.
Would the plugins from the test build already have been added,
this would cause our program to panic: Bevy asserts
that a plugin is not added twice.
Because the plugins we add in our test build are part of the
default plugins added in our main
function,
we need a mechanism to have the right plugins added to the right build.
Only after adding the default plugins here should our App
be updated,
as calling app.update
(which will be done by app.run
) will assume
that the plugin for the AsserServer
is loaded.
Here is how our game looks like:
We can now create an App
with a player sprite that has a texture.
When running the App
, we can see the player.
We have tested everything that the App does!
Full code can be found at https://github.com/richelbilderbeek/bevy_tdd_book_add_player_sprite_with_texture.