Extreme Bevy 6: Sprite animations
In this part, we'll add sprites for our main characters and animate them.
This post is part of a series on making a p2p web game with rust and Bevy.
Sprite sheet
Staying true to the original game, we'll go with a simple straightforward way to animate game characters: Sprite sheets.
Sprite sheets are simply a bunch of images packed into one. One frame of the animation has one tile on the sprite sheet.
The characters can walk in 8 directions.
You can go ahead and download the images above. Place them in the Assets folder next to bullet.png
And let's also add them to our image assets collection
#[derive(AssetCollection, Resource)]
struct ImageAssets {
#[asset(path = "bullet.png")]
bullet: Handle<Image>,
#[asset(path = "player_1.png")] // new
player_1: Handle<Image>, // new
#[asset(path = "player_2.png")] // new
player_2: Handle<Image>, // new
}
Let's also naively add it to our player entities, and also remove their color tint:
fn spawn_players(
mut commands: Commands,
players: Query<Entity, With<Player>>,
bullets: Query<Entity, With<Bullet>>,
scores: Res<Scores>,
session_seed: Res<SessionSeed>,
images: Res<ImageAssets>, // NEW
) {
// ...
// Player 1
commands
.spawn((
Player { handle: 0 },
Transform::from_translation(p1_pos.extend(100.)),
BulletReady(true),
MoveDir(-Vec2::X),
Sprite {
image: images.player_1.clone(),
custom_size: Some(Vec2::new(1., 1.)),
..default()
},
))
.add_rollback();
// Player 2
commands
.spawn((
Player { handle: 1 },
Transform::from_translation(p2_pos.extend(100.)),
BulletReady(true),
MoveDir(-Vec2::X),
Sprite {
image: images.player_2.clone(), // new
custom_size: Some(Vec2::new(1., 1.)),
..default()
},
))
.add_rollback();
}
If you run the game now, you should see our sprite sheet appearing on the character...
However, we're showing the entire sprite sheet at once, instead of a single frame of the correct animation.
In order to tell bevy to show just a portion of the texture, we'll be using a feature of Sprite called a texture atlas.
A TextureAtlas consists of a layout and an index to a frame within that layout.
Our sprites are placed in a regular grid of 22x22 cells, so defining our layout is as simple as:
// 8 directional animations per player, up to 6 frames each
let layout = TextureAtlasLayout::from_grid(UVec2::splat(22), 6, 8, None, None);
let layout = texture_atlas_layouts.add(layout.clone());
And we can now use our texture atlas when spawning the players:
Sprite {
image: images.player_1.clone(),
texture_atlas: Some(TextureAtlas {
layout: layout.clone(),
index: 0,
}),
custom_size: Some(Vec2::new(1., 1.)),
..default()
},
If you run the game now, you should see a single sprite (the top left, with index 0) instead:
Ok, so we have something that looks good as long as we're just standing still facing right! So, there are two things we have to fix:
- We need to make sure the player is facing the right direction
- We need to change the sprite whenever the player walks
Picking the right direction
We already have a MoveDir component on our players for figuring out which way bullets should fire, so all we need to do is use that same direction to set the right index in our texture atlas.
Ok, so let's write a new system for that, we'll start by just making sure we're able to change the atlas index:
fn update_player_sprites(mut players: Query<(&mut Sprite, &MoveDir), With<Player>>) {
for (mut sprite, move_dir) in &mut players {
if let Some(atlas) = sprite.texture_atlas.as_mut() {
// todo: pick index based on move_dir
// for now simply cycle through indices for testing
atlas.index += 1;
// wrap around when we reach the end of the atlas
if atlas.index >= 6 * 8 {
atlas.index = 0;
}
}
}
}
We'll add it in our schedule after move_players:
move_players,
update_player_sprites.after(move_players), // new
resolve_wall_collisions.after(move_players),
Ok, so if we run the game now... it will immediately panic:
thread 'main' panicked at C:\Users\johan\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\bevy_ecs-0.16.0\src\schedule\schedule.rs:442:33:
Error when initializing schedule RollbackSchedule: Systems with conflicting access have indeterminate run order.
2 pairs of systems with conflicting data access have indeterminate execution order. Consider adding `before`, `after`, or `ambiguous_with` relationships between these:
-- resolve_wall_collisions (in set (kill_players, bullet_wall_collisions, move_bullet, fire_bullets, reload_bullet, resolve_wall_collisions, update_player_sprites, move_players)) and update_player_sprites (in set (kill_players, bullet_wall_collisions, move_bullet, fire_bullets, reload_bullet, resolve_wall_collisions, update_player_sprites, move_players))
conflict on: ["bevy_sprite::sprite::Sprite"]
-- bullet_wall_collisions (in set (kill_players, bullet_wall_collisions, move_bullet, fire_bullets, reload_bullet, resolve_wall_collisions, update_player_sprites, move_players)) and update_player_sprites (in set (kill_players, bullet_wall_collisions, move_bullet, fire_bullets, reload_bullet, resolve_wall_collisions, update_player_sprites, move_players))
conflict on: ["bevy_sprite::sprite::Sprite"]
What it's telling us, is that there are multiple systems operating on the Sprite component, resolve_wall_collisions and bullet_wall_collisions is reading from it, and we're writing to it. So theoretically if an an entity is both a Player and a Wall at the same time (highly unlikely), it could potentially be non-deterministic if we were changing the sprite dimensions in update_player_sprites.
So this is a false positive, we'll simply ignore it by marking the system as ambiguous with the two offending systems, to indicate that we know what we're doing:
update_player_sprites
.after(move_players)
// both systems operate on the `Sprite` component, but not on the same entities
.ambiguous_with(resolve_wall_collisions)
// both systems operate on the `Sprite` component, but not on the same entities
.ambiguous_with(bullet_wall_collisions),
Ok, now we finally have some action:
While this looks pretty cool, it's not what we wanted.
So let's use that MoveDir to select the right direction.
Our spritesheet is laid out so that each row is a single direction, and there are 8 directions total. The first row is facing right, and each row below it is rotated 45 degrees to the right.
So it means that if we have the counter-clockwise angle in degrees from right and divide by 45, we will get the index of the row we want.
There is a .to_angle() method on Bevy's Vec2, so we'll use that:
// in radians, signed: 0 is right, PI/2 is up, -PI/2 is down
let angle = move_dir.0.to_angle();
However, it gives us the angle in radians, so we'll have to keep that in mind. When dividing:
// divide the angle by 45 degrees (PI/4) to get the octant
let octant = (angle / (PI / 4.)).round() as i32;
We also round to the nearest integer here. Another gotcha is that to_angle() gives us negative values for the downwards directions, we'll fix this simply by adding 8 if we have a negative octant:
// convert to an octant index in the range [0, 7]
let octant = if octant < 0 { octant + 8 } else { octant } as usize;
Ok, so now we have an index to a row, however the atlas indexing is just a single flat index, it goes from left to right one row at a time, this means we need to multiply by the number of sprites in each row to get the first sprite for our direction:
// each row has 6 frames, so we multiply the octant index by 6
// to get the index of the first frame in that row in the texture atlas.
let anim_start = octant * 6;
And finally, we update the atlas index:
atlas.index = anim_start;
That gives us the following update function:
fn update_player_sprites(mut players: Query<(&mut Sprite, &MoveDir), With<Player>>) {
for (mut sprite, move_dir) in &mut players {
if let Some(atlas) = sprite.texture_atlas.as_mut() {
// 8 directional animations, each 45 degrees apart
// in radians, signed: 0 is right, PI/2 is up, -PI/2 is down
let angle = move_dir.0.to_angle();
// divide the angle by 45 degrees (PI/4) to get the octant
let octant = (angle / (PI / 4.)).round() as i32;
// convert to an octant index in the range [0, 7]
let octant = if octant < 0 { octant + 8 } else { octant } as usize;
// each row has 6 frames, so we multiply the octant index by 6
// to get the index of the first frame in that row in the texture atlas.
let anim_start = octant * 6;
atlas.index = anim_start;
}
}
}
Now if we run the game it looks a bit less broken :)
Animating
So we had animations earlier, kind of, so how do we restore that while keeping the directions?
We want to do something like the following:
// increase by one each frame, then wrap around after 6 frames
current_frame = (current_frame + 1) % 6;
atlas.index = anim_start + current_frame;
However, how can we get the current frame? Well, we could simply extract it from the current atlas index:
// each row is 6 frames, so by taking the current index modulo 6,
// we get the current frame in the animation.
let mut current_frame = atlas.index % 6;
Ok, so running this we have animations again:
However they look kind of broken...
- They are updating all the time, they should pause when we're standing still
- In our spritesheet not all directions use all 6 frames, they have different lengths.
Let's fix the second issue first, since our updating-all-the-time-bug is actually a bit handy to test animations are looping properly.
Instead of looping back after 6 frames, we should loop back after the animation length for that frame:
We'll do it the simple stupid way by simply matching on the octant in code:
// get animation length based on octant (row in the sprite sheet)
let anim_len = match octant {
0 => 5,
1 => 5,
2 => 4,
3 => 5,
4 => 5,
5 => 4,
6 => 4,
7 => 5,
_ => unreachable!(),
};
// increase by one each frame, then wrap around after animation completes
current_frame = (current_frame + 1) % anim_len;
Ok, so now it's a bit less broken:
Controlling animation speed
It looks okay-ish when running around, but when we stop we're moonwalking like crazy.
The animations are also running waaay too fast. They're definitely not made to be updated at 60 fps per second.
Instead of advancing the animation by 1 frame every frame, we should advance by 1 frame every time we've moved a given distance. That's how walking works, the amount of steps you've taken are more or less equal to the distance you've traveled :)
So how to translate this into code? We essentially want to end up with something like this:
current_frame += (distance * anim_speed) as usize % anim_len;
So let's do it the simple stupid way, we add a new component:
#[derive(Component, Default, Clone, Copy)]
pub struct DistanceTraveled(pub f32);
And we add it to our players by making it a required component for Players:
#[derive(Component, Clone, Copy)]
#[require(DistanceTraveled)] // NEW
pub struct Player {
pub handle: usize,
}
Although we don't use it for anything gameplay significant (yet) we'll also register the component for rollback, so animations stay in sync across clients:
.rollback_component_with_copy::<DistanceTraveled>()
Now, we can add it to our query in move_players, and update whenever we move:
fn move_players(
mut players: Query<(&mut Transform, &mut MoveDir, &mut DistanceTraveled, &Player)>, // changed
inputs: Res<PlayerInputs<Config>>,
time: Res<Time>,
) {
for (mut transform, mut move_direction, mut distance, player) in &mut players { // changed
let (input, _) = inputs[player.handle];
let direction = direction(input);
if direction == Vec2::ZERO {
continue;
}
move_direction.0 = direction;
let move_speed = 7.;
let move_delta = direction * move_speed * time.delta_secs();
let old_pos = transform.translation.xy();
let limit = Vec2::splat(MAP_SIZE as f32 / 2. - 0.5);
let new_pos = (old_pos + move_delta).clamp(-limit, limit);
transform.translation.x = new_pos.x;
transform.translation.y = new_pos.y;
distance.0 += move_delta.length(); // new
}
}
Now let's return to update_player_sprites, we use our new component:
fn update_player_sprites(
mut players: Query<(&mut Sprite, &MoveDir, &DistanceTraveled), With<Player>>,
) {
for (mut sprite, move_dir, distance) in &mut players {
/// ...
let anim_speed = 5.0; // frames per units of distance traveled
let current_frame = (distance.0 * anim_speed) as usize % anim_len;
atlas.index = anim_start + current_frame;
}
}
}
If you run the game now, it looks more reasonable:
Phew, we're finally there. Walking animations working as intended.
Tweaking
There are still a few rough edges to clean up.
While the animation is working, it looks like they're sprinting like crazy, well, they kind of have to since they're moving really fast compared to how big they are. If you looke at footage from the original game, they move quite a lot slower.
Well, part of that is because the sprites have quite a lot of transparent area around the actual player. The first thing we want to do, is make sure the player sprite is again one square tall. We can do this simply by increasing the custom_size when spawning the players:
custom_size: Some(Vec2::splat(1.4)),
It still feels like they are moving slightly too fast, so let's also decrease the move_speed in move_players slightly:
let move_speed = 6.;
Now that the move speed is slower, the animation actually slows a bit down (because we implemented it so it's proportional to distance traveled). However, now that the sprite is bigger, the character's steps are also longer, so we can slow the animation down maybe a little bit more:
let anim_speed = 4.0; // frames per units of distance traveled
That's more like it. That concludes our work on animations for this part.
Crisp edges
The character looks kind of blurry... especially after we made them bigger.
Since the assets we have are pixel art assets, we need to make sure we don't filter (smooth) our textures when we scale them up to a higher resolution.
We can switch to nearest neighbor filtering (e.g. no smoothing) globally when configuring the default plugins:
DefaultPlugins
.set(WindowPlugin {
// ...
})
.set(ImagePlugin::default_nearest()), // NEW
Now it looks more like a proper pixel art game :)
Updating collision detection
Our sprite is back at the original size. However, we still have a problem. Even though the sprite is closer to the original box size of 1x1 units, it's a little bit thinner. And will catch bullets that are outside the player sprite.
We need to update our kill_players system to account for the new shape of the player. We're currently just checking distance, which essentially means circle-circle collisions, but looking at the sprite it looks like a rectangle is probably a better fit.
Looking at the grid (which is spaced with 1 unit between each), it looks like maybe a 1x0.5 rectangle would be a good fit.
const PLAYER_WIDTH: f32 = 0.5; // new
const PLAYER_HEIGHT: f32 = 1.0; // new
const PLAYER_RADIUS: f32 = 0.5;
const BULLET_RADIUS: f32 = 0.025;
fn kill_players(
/// ...
let player_pos = player_transform.translation.xy();
let bullet_pos = bullet_transform.translation.xy();
// distance between player center and bullet center on each axis, individually
let manhattan_distance = (player_pos - bullet_pos).abs();
if manhattan_distance.x < PLAYER_WIDTH / 2. + BULLET_RADIUS
&& manhattan_distance.y < PLAYER_HEIGHT / 2. + BULLET_RADIUS
{
commands.entity(player_entity).despawn();
// ...
}
What we implemented here is basically rectangle-rectangle collision, so the bullet is now a square instead of a circle.
Although not pixel perfect, collisions now feel much more fair!
Bullet offsets
While bullet collisions now are working great, firing bullets doesn't look particularly good. The bullets are not really coming out of the gun, and it's worse when the player is facing certain directions.
Instead of spawning the bullets at a fixed offset along the move direction, we'll now look at the sprite sheet, and try to determine an exact location for where to spawn the bullet in each direction.
Let's update our fire_bullets system, instead of doing:
let pos = player_pos + move_dir.0 * PLAYER_RADIUS + BULLET_RADIUS;
We should have a specific offset for the muzzle for each of the 8 direction. So matching on the octant:
let muzzle_offset = match octant {
0 => todo!(),
1 => todo!(),
2 => todo!(),
3 => todo!(),
4 => todo!(),
5 => todo!(),
6 => todo!(),
7 => todo!(),
_ => unreachable!(),
};
let pos = player_pos + muzzle_offset;
We calculated the octant in update_player_sprites, so while we could just copy the code, let's instead let's move it to a method on the MoveDir component instead of duplicating it.
impl MoveDir {
/// Gets the index of the octant (45 degree sectors), starting from 0 (right) and going counter-clockwise:
pub fn octant(&self) -> usize {
// in radians, signed: 0 is right, PI/2 is up, -PI/2 is down
let angle = self.0.to_angle();
// divide the angle by 45 degrees (PI/4) to get the octant
let octant = (angle / (PI / 4.)).round() as i32;
// convert to an octant index in the range [0, 7]
let octant = if octant < 0 { octant + 8 } else { octant } as usize;
octant
}
}
And now we can use it in update_player_sprites:
fn update_player_sprites(
mut players: Query<(&mut Sprite, &MoveDir, &DistanceTraveled), With<Player>>,
) {
for (mut sprite, move_dir, distance) in &mut players {
if let Some(atlas) = sprite.texture_atlas.as_mut() {
// 8 directional animations, each 45 degrees apart
let octant = move_dir.octant(); // NEW
// ...
And let's get back to fire_bullets, and use it there as well:
let muzzle_offset = match move_dir.octant() {
0 => todo!(),
1 => todo!(),
2 => todo!(),
3 => todo!(),
4 => todo!(),
5 => todo!(),
6 => todo!(),
7 => todo!(),
_ => unreachable!(),
};
All we have left to do now is figure out the actual offsets. I did it through simple trial and error, and ended up with:
let muzzle_offset = match move_dir.octant() {
0 => Vec2::new(0.5, 0.0), // right
1 => Vec2::new(0.5, 0.25), // up-right
2 => Vec2::new(0.25, 0.5), // up
3 => Vec2::new(-0.4, 0.3), // up-left
4 => Vec2::new(-0.5, 0.0), // left
5 => Vec2::new(-0.4, -0.25), // down-left
6 => Vec2::new(-0.25, -0.5), // down
7 => Vec2::new(0.25, -0.25), // down-right
_ => unreachable!(),
};
And finally firing bullets looks a lot less broken
Wrapping up
That's it for now, while there wasn't much rollback or p2p-related content in this post, we learned how to use Bevy's TextureAtlas in order to create custom animations using a sprite sheet. The game looks a lot more like a game now, and I think it was the thing that was most sorely needed.
If you've come this far, thanks for reading the whole series! Maintaining and updating it for each Bevy release is quite a lot of work... so if you appreciate my work, I'd really like to hear from you, it's what's encouraging me to keep writing. Leave a comment below, or reach out to me on my discord server.
I also haven't quite decided on what part to focus on next. A rough plan:
- Sound effects (with rollback support)
- Particle effects (muzzle flash, impact, blood)
- Screen shakes
- Lobbies and 3+ multiplayer support
- Character color configuration
- Powerups
However they could really be done in any order, so if there's anything you'd like to see in the next chapter, please let me know!
Player 1:
Player 2: