johan helsing.studio

Extreme Bevy 2: Camera and background

We’ll continue where we left off and start making the cube moving application look and feel more like a game by making the camera follow the local player and adding a map background and some borders to the map.

This post is part of a series on making a p2p web game with rust and Bevy.

Keeping things organized

So before we go on, I’d like to take a short moment to reorganize our project before it gets messy.

Our main.rs file is starting to get long, and has a mix of different concerns in it.

For one, there is a lot of input stuff that in my mind is kind of its own topic. So let’s go ahead and create a new module, input.rs and move all the input related code there:

use crate::Config;
use bevy::{prelude::*, utils::HashMap};
use bevy_ggrs::{LocalInputs, LocalPlayers};

const INPUT_UP: u8 = 1 << 0;
const INPUT_DOWN: u8 = 1 << 1;
const INPUT_LEFT: u8 = 1 << 2;
const INPUT_RIGHT: u8 = 1 << 3;
const INPUT_FIRE: u8 = 1 << 4;

pub fn read_local_inputs(
    mut commands: Commands,
    keys: Res<ButtonInput<KeyCode>>,
    local_players: Res<LocalPlayers>,
) {
    let mut local_inputs = HashMap::new();

    for handle in &local_players.0 {
        let mut input = 0u8;

        if keys.any_pressed([KeyCode::ArrowUp, KeyCode::KeyW]) {
            input |= INPUT_UP;
        }
        if keys.any_pressed([KeyCode::ArrowDown, KeyCode::KeyS]) {
            input |= INPUT_DOWN;
        }
        if keys.any_pressed([KeyCode::ArrowLeft, KeyCode::KeyA]) {
            input |= INPUT_LEFT
        }
        if keys.any_pressed([KeyCode::ArrowRight, KeyCode::KeyD]) {
            input |= INPUT_RIGHT;
        }
        if keys.any_pressed([KeyCode::Space, KeyCode::Enter]) {
            input |= INPUT_FIRE;
        }

        local_inputs.insert(*handle, input);
    }

    commands.insert_resource(LocalInputs::<Config>(local_inputs));
}

The input system now needs to be public, so we can add it to the schedule in main.rs.

There is also some input related code inside move_players. What it does is basically convert the low-level input format to a direction, so let’s wrap that code in its own function:

pub fn direction(input: u8) -> Vec2 {
    let mut direction = Vec2::ZERO;
    if input & INPUT_UP != 0 {
        direction.y += 1.;
    }
    if input & INPUT_DOWN != 0 {
        direction.y -= 1.;
    }
    if input & INPUT_RIGHT != 0 {
        direction.x += 1.;
    }
    if input & INPUT_LEFT != 0 {
        direction.x -= 1.;
    }
    direction
}

Now lets add the module in main.rs and use it.

use input::*;

mod input;

fn move_players(
    mut players: Query<(&mut Transform, &Player)>,
    inputs: Res<PlayerInputs<Config>>,
    time: Res<Time>,
) {
    for (mut transform, player) in &mut players {
        let (input, _) = inputs[player.handle];

        let direction = direction(input);

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

        let move_speed = 7.;
        let move_delta = direction * move_speed * time.delta_seconds();
        transform.translation += move_delta.extend(0.);
    }
}

NOTE: In previous versions of bevy_ggrs, it was not safe to use Time in rollback systems. Starting with Bevy 0.12 and bevy_ggrs 0.14, Time is perfectly safe to use.

That unclutters main.rs quite a bit. I’d like us to do one more thing before we move on: let’s create a new module where we’ll keep all our custom components, aptly named components.rs.

use components::*;

// ...

mod components;

Player is our only component so far, lets move it into components.rs and make it public:

use bevy::prelude::*;

#[derive(Component)]
pub struct Player {
    pub handle: usize,
}

That’s enough for now, let’s get on with the actual content of this tutorial.

We’ll start by making the camera follow the local player.

The first thing we’re going to need, is some way to tell which player is the local player so we know what player the camera should follow.

We can get this information through the LocalPlayers resource which we've already used in read_local_inputs.

Now, we’re ready to write the camera system. It will take the local player resource as a dependency, find the right player, and snap the camera position to the player:

fn camera_follow(
    local_players: Res<LocalPlayers>,
    players: Query<(&Player, &Transform)>,
    mut cameras: Query<&mut Transform, (With<Camera>, Without<Player>)>,
) {
    for (player, player_transform) in &players {
        // only follow the local player
        if !local_players.0.contains(&player.handle) {
            continue;
        }

        let pos = player_transform.translation;

        for mut transform in &mut cameras {
            transform.translation.x = pos.x;
            transform.translation.y = pos.y;
        }
    }
}

When we update the camera position, we keep its original z position.

We’ll add this system to the regular systems and not the rollback schedule, since the camera shouldn’t affect shared game state, it’s only concerned with the latest player position.

        .add_systems(Startup, (setup, spawn_players, start_matchbox_socket))
        .add_systems(Update, (wait_for_players, camera_follow)) // <-- CHANGED
        .add_systems(ReadInputs, read_local_inputs)

If your run the code now, you’ll see a panic in the browser console:

panicked at 'error[B0001]: Query<&mut bevy_transform::components::transform::Transform, bevy_ecs::query::filter::With<bevy_render::camera::camera::Camera>> in system extreme_bevy::camera_follow accesses component(s) bevy_transform::components::transform::Transform in a way that conflicts with a previous system parameter. Consider using Without<T> to create disjoint Queries or merging conflicting Queries into a QuerySet.'

It happens because both our queries access the Transform component, and one of them is a mutable query, and it could theoretically be problematic if a component had both a Player and a Camera component on it. We’ll do what the error message suggests and make sure our queries are disjoint by adding Without<Player> to our camera query:

    mut cameras: Query<&mut Transform, (With<Camera>, Without<Player>)>,

If you run the game now, it should work as expected.

Adding a background

It does feel a bit weird however, it kind of looks like the local player is stationary, while it’s the other player that moves in the opposite direction.

We need some kind of reference to tell what is moving in relation to what. The original Extreme Violence uses a simple grid for this. The simplest way I could think of to add a grid is to use colored rectangular sprites as lines. Let’s start by adding a constant that defines our map width and height. This will be useful later.

const MAP_SIZE: u32 = 41;
const GRID_WIDTH: f32 = 0.05;

Then in setup, we simply spawn the sprites:

    // Horizontal lines
    for i in 0..=MAP_SIZE {
        commands.spawn(SpriteBundle {
            transform: Transform::from_translation(Vec3::new(
                0.,
                i as f32 - MAP_SIZE as f32 / 2.,
                0.,
            )),
            sprite: Sprite {
                color: Color::rgb(0.27, 0.27, 0.27),
                custom_size: Some(Vec2::new(MAP_SIZE as f32, GRID_WIDTH)),
                ..default()
            },
            ..default()
        });
    }

    // Vertical lines
    for i in 0..=MAP_SIZE {
        commands.spawn(SpriteBundle {
            transform: Transform::from_translation(Vec3::new(
                i as f32 - MAP_SIZE as f32 / 2.,
                0.,
                0.,
            )),
            sprite: Sprite {
                color: Color::rgb(0.27, 0.27, 0.27),
                custom_size: Some(Vec2::new(GRID_WIDTH, MAP_SIZE as f32)),
                ..default()
            },
            ..default()
        });
    }

Now if you run the game, you’ll see the grid, but it is probably on top of the players. Instead of moving the background forward, we’ll move the players closer to the camera:

fn spawn_players(mut commands: Commands, mut rip: ResMut<RollbackIdProvider>) {
    // Player 1
    commands
        .spawn((
            Player { handle: 0 },
            SpriteBundle {
                transform: Transform::from_translation(Vec3::new(-2., 0., 100.)), // new
                sprite: Sprite {
                    color: Color::rgb(0., 0.47, 1.),
                    custom_size: Some(Vec2::new(1., 1.)),
                    ..default()
                },
                ..default()
            },
        ))
        .add_rollback();

    // Player 2
    commands
        .spawn((
            Player { handle: 1 },
            SpriteBundle {
                transform: Transform::from_translation(Vec3::new(2., 0., 100.)), // new
                sprite: Sprite {
                    color: Color::rgb(0., 0.4, 0.),
                    custom_size: Some(Vec2::new(1., 1.)),
                    ..default()
                },
                ..default()
            },
        ))
        .add_rollback();

Now we have a simple background that gives us some impression that we are actually moving.

One more thing, our players shouldn’t be able to move off the map. For now we’ll simply enforce this in the move_player system:

        let move_speed = 7.;
        let move_delta = direction * move_speed * time.delta_seconds();

        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;

Player 1:

Player 2:

Ok, now the players have a very minimal space to move around in, however there isn’t much interaction between the players. In the next part we’ll be finally be adding some violence to this game.

Reference implementation

Diff for this part

Comments

Loading comments...