johan helsing.studio

Extreme Bevy 3: Adding violence

In this part, we’ll turn our pacifistic grid walking game into a violent killing ground. We’ll add bullets and hit detection.

Managing assets and states

For drawing the bullets, we’ll use a very simple image consisting of three pixels.

Go ahead and create an assets folder in your project root and save the image above in it as bullet.png.

While assets can quite easily be loaded in bevy using AssetServer::load, it leads to quite a bit of boiler-plate, especially since we want to make sure the images are not released when there are no bullets on the screen.

We will be using bevy_asset_loader to help us with this.

Go ahead and add it in Cargo.toml:

bevy_asset_loader = "0.8"

We can now go ahead and create an asset collection for our images, currently only with the bullet in it:

#[derive(AssetCollection)]
struct ImageAssets {
    #[asset(path = "bullet.png")]
    bullet: Handle<Image>,
}

With bevy_asset_loader all assets are loaded in a special loading state, and then it continues to the actual game state. So let’s create some states:

#[derive(Clone, Eq, PartialEq, Debug, Hash)]
enum GameState {
    AssetLoading,
    InGame,
}

And initialize it in main:

fn main() {
    let mut app = App::new();

    AssetLoader::new(GameState::AssetLoading)
        .continue_to_state(GameState::InGame)
        .with_collection::<ImageAssets>()
        .build(&mut app);

    app.add_state(GameState::AssetLoading)
        .insert_resource(ClearColor(Color::rgb(0.53, 0.53, 0.53)))
        .add_plugins(DefaultPlugins)
        .add_plugin(GGRSPlugin)
        .with_input_system(input)
        .with_rollback_schedule(Schedule::default().with_stage(
            "ROLLBACK_STAGE",
            SystemStage::single_threaded().with_system(move_players),
        ))
        .register_rollback_type::<Transform>()
        .add_startup_system(setup)
        .add_startup_system(start_matchbox_socket)
        .add_startup_system(spawn_players)
        .add_system(wait_for_players)
        .add_system(camera_follow)
        .run();
}

Now, as the game launches it will load all the image assets first, and then transition to the InGame state.

We can take advantage of this, and make sure some systems are only run after loading has finished, that way we don’t have to deal with the possibility that some assets may not yet be loaded. In fact, if we look at our systems, it makes sense to add another stage as well, so we can differentiate between before and after the GGRS session has started.

Let’s go ahead and create another state. We’ll call it Matchmaking:

#[derive(Clone, Eq, PartialEq, Debug, Hash)]
enum GameState {
    AssetLoading,
    Matchmaking,
    InGame,
}

Now, let’s update our main function to make sure we continue to this state after loading:

    AssetLoader::new(GameState::AssetLoading)
        .continue_to_state(GameState::Matchmaking) // <-- NEW

And also divide our systems into system sets for the appropriate states:

        .add_system_set(
            SystemSet::on_enter(GameState::Matchmaking)
                .with_system(start_matchbox_socket)
                .with_system(setup),
        )
        .add_system_set(SystemSet::on_update(GameState::Matchmaking).with_system(wait_for_players))
        .add_system_set(SystemSet::on_enter(GameState::InGame).with_system(spawn_players))
        .add_system_set(SystemSet::on_update(GameState::InGame).with_system(camera_follow))

Finally, we have to make sure that we actually enter the InGame state in wait_for_players when we start the GGRS session.

fn wait_for_players(
    mut commands: Commands,
    mut socket: ResMut<Option<WebRtcNonBlockingSocket>>,
    mut state: ResMut<State<GameState>>, // <-- NEW
) {
    // ...

    // start the GGRS session
    commands.start_p2p_session(p2p_session);

    state.set(GameState::InGame).unwrap(); // <-- NEW

And with that, the player sprites won’t spawn before we have enough players. Nice!

Ok, let’s get back to the bullet sprite. Let’s start simple. When a player presses a button, we want to spawn a sprite with the bullet image. Let’s make a new system for this and add it to our rollback stage (if a bullet was fired by the other player was mispredicted, this is obviously something we’d want to correct!)

       .with_rollback_schedule(
            Schedule::default().with_stage(
                "ROLLBACK_STAGE",
                SystemStage::single_threaded()
                    .with_system(move_players)
                    .with_system(fire_bullets),
            ),
        )

// ...

fn fire_bullets(
    mut commands: Commands,
    player_query: Query<(&Transform, &Player)>,
    inputs: Res<Vec<ggrs::GameInput>>,
    images: Res<ImageAssets>,
) {
    for (transform, player) in player_query.iter() {
        // TODO: Check if player pressed fire button
        // Spawn bullet
    }
}

Now, let’s add a method to our input module to check for the fire button:

pub fn fired(game_input: &GameInput) -> bool {
    let input = game_input.buffer[0];
    input & INPUT_FIRE != 0
}

And let’s use it to spawn our bullet sprite:

        if fired(&inputs[player.handle]) {
            commands.spawn_bundle(SpriteBundle {
                transform: Transform::from_translation(transform.translation),
                texture: images.bullet.clone(),
                sprite: Sprite {
                    custom_size: Some(Vec2::new(0.3, 0.1)),
                    ..Default::default()
                },
                ..Default::default()
            });
        }

Ok, finally we have some bullets on screen.

Time to fix all our mistakes again.

It feels weird that bullets are spawned continuously fire is held. Let’s make it so it only happens when the button transitions from being not pressed to pressed:

We’ll add a new component that keeps track of whether a bullet is ready to be fired. This way we can extend it to also handle other kinds of cooldowns if necessary:

#[derive(Component, Reflect, Default)]
pub struct BulletReady(pub bool);

We need to derive Reflect and Default as this is a component that we need to be able to roll back. Go ahead and register it as a rollback type:

        .register_rollback_type::<Transform>()
        .register_rollback_type::<BulletReady>() // <-- new

And add it to the players when we spawn them:

        .insert(Player { handle: 0 })
        .insert(BulletReady(true))
    // ...
        .insert(Player { handle: 1 })
        .insert(BulletReady(true))

Now let’s add a reload_bullet system and add it to our rollback stage:

       .with_rollback_schedule(
            Schedule::default().with_stage(
                "ROLLBACK_STAGE",
                SystemStage::single_threaded()
                    .with_system(move_players)
                    .with_system(reload_bullet)
                    .with_system(fire_bullets),
            ),
        )

// ...

fn reload_bullet(inputs: Res<Vec<ggrs::GameInput>>, mut query: Query<(&mut BulletReady, &Player)>) {
    for (mut bullet_ready, player) in query.iter_mut() {
        if !fire(&inputs[player.handle]) {
            bullet_ready.0 = true;
        }
    }
}

Now, we can simply check whether a bullet is ready (and also remember to set bullet ready to false when we actually fire):

fn fire_bullets(
    mut commands: Commands,
    inputs: Res<Vec<ggrs::GameInput>>,
    images: Res<ImageAssets>,
    mut player_query: Query<(&Transform, &Player, &mut BulletReady)>, // <-- new
) {
    for (transform, player, mut bullet_ready) in player_query.iter_mut() { // <-- new
        if fire(&inputs[player.handle]) && bullet_ready.0 { // <-- new
            commands.spawn_bundle(SpriteBundle {
                transform: Transform::from_translation(transform.translation),
                texture: images.bullet.clone(),
                sprite: Sprite {
                    custom_size: Some(Vec2::new(0.3, 0.1)),
                    ..Default::default()
                },
                ..Default::default()
            });
            bullet_ready.0 = false; // <-- new
        }
    }
}

Another problem with our bullets, is that they aren’t currently rolled back because we forgot to add the Rollback component. Let’s do that now:

fn fire_bullets(
    mut commands: Commands,
    inputs: Res<Vec<ggrs::GameInput>>,
    images: Res<ImageAssets>,
    mut player_query: Query<(&Transform, &Player, &mut BulletReady)>,
    mut rip: ResMut<RollbackIdProvider>, // <-- new
) {
    for (transform, player, mut bullet_ready) in player_query.iter_mut() {
        if fire(&inputs[player.handle]) && bullet_ready.0 {
            commands
                .spawn_bundle(SpriteBundle {
                    transform: Transform::from_translation(transform.translation),
                    texture: images.bullet.clone(),
                    sprite: Sprite {
                        custom_size: Some(Vec2::new(0.3, 0.1)),
                        ..Default::default()
                    },
                    ..Default::default()
                })
                .insert(Rollback::new(rip.next_id())); // <-- new
            bullet_ready.0 = false;
        }
    }
}

Now our game shouldn’t desync anymore and we can run around spawning stationary bullets… or is it?

System order ambiguities

We now have three systems in our rollback schedule, and they operate on some of the same components. So it matters a lot in what order the systems are executed.

For instance, it is very important whether we move the player first, then fire the bullet, or the other way around.

We can see these types of gotchas if we insert a special resource into our app. in main, add the following:

            .insert_resource(bevy::ecs::schedule::ReportExecutionOrderAmbiguities)

If you look at the logs now, you will see a bunch of output. Not all of it is interesting, though, some of it is totally ok. We’re interested in the output at the very end that is logged as the GGRS session starts:

Execution order ambiguities detected, you might want to add an explicit dependency relation between some of these systems:
 * Parallel systems:
 -- "extreme_bevy::move_players" and "extreme_bevy::fire_bullets"
    conflicts: ["bevy_transform::components::transform::Transform"]
 -- "extreme_bevy::reload_bullet" and "extreme_bevy::fire_bullets"
    conflicts: ["extreme_bevy::components::BulletReady"]

It tells us what we already knew, as one more important thing, we also need to specify the order between reloading and firing.

Let’s add labels and explicit ordering to our rollback systems:

#[derive(SystemLabel, Debug, Clone, Hash, Eq, PartialEq)]
enum Systems {
    Move,
    Reload,
    Fire,
}

//fn main()
                SystemStage::single_threaded()
                    .with_system(move_players.label(Systems::Move))
                    .with_system(reload_bullet.label(Systems::Reload))
                    .with_system(
                        fire_bullets
                            .label(Systems::Fire)
                            .after(Systems::Move)
                            .after(Systems::Reload),
                    ),

That should be enough to make it deterministic again!

You can go ahead and remove or comment out the ReportExecutionOrderAmbiguities resource again now. I like to keep it commented out so I can easily enable it again if I’m worried something is non-deterministic.

A sitting bullet is not a bullet

Ok, time to make the bullets move!

To start with, we’ll just move the bullets to the right every frame. We’ll make a new label for it:

enum Systems {
    Move,
    Reload,
    Fire,
    MoveBullet
}

And add it to the rollback stage:

.with_system(move_bullet.label(Systems::MoveBullet)),

Now, before we can write the system, we need to know which components are actually bullets… We’ll add another marker component in components.rs:

#[derive(Component, Reflect, Default)]
pub struct Bullet;

And add it in fire_bullets:

.insert(Bullet)

Now we can move on to write the system:

fn move_bullet(mut query: Query<&mut Transform, With<Bullet>>) {
    for mut transform in query.iter_mut() {
        transform.translation.x += 0.1;
    }
}

And we have moving bullets!

Now we just need to make sure they fly in the right direction.

We’ll start by spawning them with the right orientation, we will do that by setting their rotation to the direction the player is currently facing. Since we will use sprites for representing player direction, we won’t actually rotate the players themselves, but what we’ll do is instead keep track of the player direction in a special MoveDir component. It will be a newtype of Vec2. So, we do the usual dance that should be somewhat familiar by now:

// components.rs
#[derive(Component, Reflect, Default, Clone, Copy)]
pub struct MoveDir(pub Vec2);

// main.rs
//fn main()
            .register_rollback_type::<MoveDir>()

// fn spawn_players()
    // Player 1
        .insert(MoveDir(-Vec2::X))
    // Player 2
        .insert(MoveDir(Vec2::X))

Now we simply need to set this in move_player, after we’ve checked it for being zero:

fn move_players(
    inputs: Res<Vec<ggrs::GameInput>>,
    mut player_query: Query<(&mut Transform, &mut MoveDir, &Player)>,
) {
    for (mut transform, mut move_dir, player) in player_query.iter_mut() {
        let direction = direction(&inputs[player.handle]);

        if direction == Vec2::ZERO {
            continue;
        }

        move_dir.0 = direction;

And while we’re at it, we should probably also make sure that the direction is normalized, so we don’t move faster diagonally than up/down. At the end of input::direction():

    direction.normalize_or_zero()

Ok, now that we have a player direction, we can copy it to our bullets in fire_bullets, and also set their transforms’ rotations based on it.

fn fire_bullets(
    mut commands: Commands,
    inputs: Res<Vec<ggrs::GameInput>>,
    images: Res<ImageAssets>,
    mut player_query: Query<(&Transform, &Player, &mut BulletReady, &MoveDir)>, // <-- new
    mut rip: ResMut<RollbackIdProvider>,
) {
    for (transform, player, mut bullet_ready, move_dir) in player_query.iter_mut() {
        if fire(&inputs[player.handle]) && bullet_ready.0 {
            commands
                .spawn_bundle(SpriteBundle {
                    transform: Transform::from_translation(transform.translation)
                        .with_rotation(Quat::from_rotation_arc_2d(Vec2::X, move_dir.0)), // <-- new
                    texture: images.bullet.clone(),
                    sprite: Sprite {
                        custom_size: Some(Vec2::new(0.3, 0.1)),
                        ..Default::default()
                    },
                    ..Default::default()
                })
                .insert(*move_dir) // <-- new
                .insert(Bullet)
                .insert(Rollback::new(rip.next_id()));
            bullet_ready.0 = false;
        }
    }
}

We use Quat::from_rotation_arc_2d(Vec2::X, move_dir.0) to find the new rotation. This means that we use the old convention that all our rotatable sprites face right in the original image.

And let’s update move_bullet to make use of our shiny new directions, and also increase their speed a little:

fn move_bullet(mut query: Query<(&mut Transform, &MoveDir), With<Bullet>>) {
    for (mut transform, dir) in query.iter_mut() {
        let delta = (dir.0 * 0.35).extend(0.);
        transform.translation += delta;
    }
}

Now we just need to make the characters die when they are hit

Detecting collisions

Detecting collisions can be very complicated, but again, let’s start naively and fix corner cases as required. In fact, let’s remove all our corners, and just use circles for collision.

Let’s start with the basics: A player should die if the bullet overlaps the player. In other words, if the distance between their centers are smaller than the sum of their radii.

const PLAYER_RADIUS: f32 = 0.5;
const BULLET_RADIUS: f32 = 0.025;

fn kill_players(
    mut commands: Commands,
    player_query: Query<(Entity, &Transform), (With<Player>, Without<Bullet>)>,
    bullet_query: Query<&Transform, With<Bullet>>,
) {
    for (player, player_transform) in player_query.iter() {
        for bullet_transform in bullet_query.iter() {
            let distance = Vec2::distance(
                player_transform.translation.xy(),
                bullet_transform.translation.xy(),
            );
            if distance < PLAYER_RADIUS + BULLET_RADIUS {
                commands.entity(player).despawn_recursive();
            }
        }
    }
}

So before we add it, let’s think about system order. We probably want to kill players at the very end. That ensures the player is destroyed as close as possible to the detection (commands are executed at the end of the stage). That way they’re destroyed before they get the chance to take more actions, otherwise we’d have to deal with scenarios like a player firing a bullet after they’ve been hit etc. Our system only depends on output from move_bullet and move_players, so all we have to do is make sure it runs after both systems:

enum Systems {
    Move,
    Reload,
    Fire,
    MoveBullet,
    Kill, // <-- NEW
}

// fn main()

                    .with_system(
                        kill_players
                            .label(Systems::Kill)
                            .after(Systems::Move)
                            .after(Systems::MoveBullet),
                    ),

And now, let’s try it.

So if we fire, we seem to just immediately kill ourselves. So that means dying works… kinda. Let’s solve it by moving the bullet a little bit away from the player when spawning it:

// fn fire_bullets
            let player_pos = transform.translation.xy();
            let pos = player_pos + move_dir.0 * PLAYER_RADIUS + BULLET_RADIUS;
            commands
                .spawn_bundle(SpriteBundle {
                    transform: Transform::from_translation(pos.extend(200.))

That’s more like it!

In the next part we’ll add scoring, respawning, and some basic UI.

Reference implementation

Diff for this part

Comments

Loading comments...