Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Add a Camera

Now that our player can move, you probably discovered awfully quickly how easy it is for that little scamp to end up off the side of the screen. Well let's put those fears to rest and make a camera that intelligently follows the player instead!

Camera Component

Bloop has a builtin Camera component so it's actually pretty easy to integrate one. The component has a few fields that still are a bit of a work in progress but feel free to play around with them. We will revisit some later. The only one I'm interested in for now is clear_color, which lets you set effectively whatever color you want as the "background color" of your game. I happen to like #222 so I'm going to use that.

srclib.rs
#[system_once]
fn spawn_camera() {
    let camera = &Camera {
        clear_color: Color::from_hex_str("#222").unwrap(),
        ..Default::default()
    };

    Engine::spawn(bundle!(&Transform::default(), camera));

}

This is all it takes to spawn a camera and have its view output to the game window's viewport.

So the only thing we need to do is make the camera follow the player. We only intend to have one camera, so again we can repeat patterns from our player related systems to ensure nothing funky happens. It's important we put this after apply_velocity, so that the camera properly responds to the player's movement.

srclib.rs
#[system]
fn update_camera_position(
    mut camera_query: Query<(Mut<Transform>, Ref<Camera>)>,
    player_query: Query<(Ref<Transform>, Ref<Player>)>,
) {
    let Some((player_transform, _)) = player_query.get(0) else {
        return;
    };

    let Some((mut camera_transform, _)) = camera_query.get_mut(0) else {
        return;
    };

    camera_transform.position = player_transform.position;
}

If you load up and play now, you might not realize anything's changed until you move around a bit and realize the camera is staying locked on with the player dead in the center of the screen. But just above a grey endless plain (or whatever color you chose), it's easy to lose all sense of direction and scale. What we need is...

A Basic Environment

First we're going to spawn a grid of dots. I happen to be using pentagons for mine, but you could make them more rounded circles or whatever you want.

For my taste here's the consts I went with:

srclib.rs
const GRID_DOT_SIZE: f32 = 7.5;
const GRID_DOT_SIDES: u32 = 5;
const GRID_DOT_SPACING: f32 = 25.;
const GRID_ROWS: u32 = 200;
const GRID_COLS: u32 = 200;
const GRID_DOT_ALPHA_1: f32 = 0.1;
const GRID_DOT_ALPHA_2: f32 = 0.2;
const GRID_DOT_ALPHA_SPACING: u32 = 5;

We have to do some annoying stuff due to Rust but basically the plan is to spawn a grid centered in the world where every nth dot is slightly brighter, lending a "graph paper" like effect.

srclib.rs
#[system_once]
fn spawn_grid_dots() {
    let grid_width = (GRID_COLS - 1) as f32 * GRID_DOT_SPACING;
    let grid_height = (GRID_ROWS - 1) as f32 * GRID_DOT_SPACING;
    let start_x = -grid_width / 2.0;
    let start_y = -grid_height / 2.0;

    for row in 0..=GRID_ROWS {
        for col in 0..=GRID_COLS {
            let x = start_x + ((col) as f32 * GRID_DOT_SPACING);
            let y = start_y + ((row) as f32 * GRID_DOT_SPACING);

            let transform = &Transform {
                position: Vec2::new(x, y),
                scale: Vec2::splat(1.0),
                ..Default::default()
            };

            let is_grid_line =
                row % GRID_DOT_ALPHA_SPACING == 0 || col % GRID_DOT_ALPHA_SPACING == 0;
            let base_alpha = if is_grid_line {
                GRID_DOT_ALPHA_2
            } else {
                GRID_DOT_ALPHA_1
            };

            let color = &Color::new(1.0, 1.0, 1.0, base_alpha);

            let circle_render = &CircleRender {
                num_sides: GRID_DOT_SIDES,
                size: Vec2::splat(GRID_DOT_SIZE),
                visible: true,
            };
            Engine::spawn(bundle!(transform, color, circle_render));
        }
    }
}

You could, of course, tweak this to your hearts content, but you should end up with something like this:

basic grid

Our lib.rs is getting a little long in the tooth so we're going to do a brief refactor, after which we can embark on a couple cool sidequests or just go on with the basics.








srclib.rs
use engine::prelude::*;
mod ffi;

const PLAYER_MAX_VELOCITY: f32 = 750.;
const PLAYER_ACCELERATION: f32 = 9000.;
const PLAYER_DECELERATION: f32 = 12000.;
const ZERO_VELOCITY_THRESHOLD: f32 = 0.1;

const ENEMY_MAX_VELOCITY: f32 = 300.;
const ENEMY_ACCELERATION: f32 = 1000.;
const ENEMY_DECELERATION: f32 = 5000.;

const ENEMY_CHASE_DISTANCE: f32 = 1000.;
const ENEMY_STOP_DISTANCE: f32 = 200.;

const GRID_DOT_SIZE: f32 = 7.5;
const GRID_DOT_SIDES: u32 = 5;
const GRID_DOT_SPACING: f32 = 25.;
const GRID_ROWS: u32 = 200;
const GRID_COLS: u32 = 200;
const GRID_DOT_ALPHA_1: f32 = 0.1;
const GRID_DOT_ALPHA_2: f32 = 0.2;
const GRID_DOT_COLOR_SPACING: u32 = 5;

#[derive(Default)]
#[resource]
struct PlayerInput {
    move_axes: Vec2,
}

#[component]
struct Player;

#[component]
struct Enemy;

#[derive(Default)]
#[component]
struct Velocity {
    current_velocity: Vec2,
    max_velocity: f32,
}

#[derive(Default)]
#[component]
struct Acceleration {
    acceleration_rate: f32,
    deceleration_rate: f32,
    target_velocity: Vec2,
}

#[system_once]
fn spawn_grid_dots() {
    let grid_width = (GRID_COLS - 1) as f32 * GRID_DOT_SPACING;
    let grid_height = (GRID_ROWS - 1) as f32 * GRID_DOT_SPACING;
    let start_x = -grid_width / 2.0;
    let start_y = -grid_height / 2.0;

    for row in 0..=GRID_ROWS {
        for col in 0..=GRID_COLS {
            let x = start_x + ((col) as f32 * GRID_DOT_SPACING);
            let y = start_y + ((row) as f32 * GRID_DOT_SPACING);

            let transform = &Transform {
                position: Vec2::new(x, y),
                scale: Vec2::splat(1.0),
                ..Default::default()
            };

            let is_grid_line =
                row % GRID_DOT_ALPHA_SPACING == 0 || col % GRID_DOT_ALPHA_SPACING == 0;
            let base_alpha = if is_grid_line {
                GRID_DOT_ALPHA_2
            } else {
                GRID_DOT_ALPHA_1
            };

            let color = &Color::new(1.0, 1.0, 1.0, base_alpha);

            let circle_render = &CircleRender {
                num_sides: GRID_DOT_SIDES,
                size: Vec2::splat(GRID_DOT_SIZE),
                visible: true,
            };
            Engine::spawn(bundle!(transform, color, circle_render));
        }
    }
}

#[system]
fn sync_player_movement_to_config_consts(
    mut player: Query<(Mut<Acceleration>, Mut<Velocity>, Ref<Player>)>,
) {
    let Some((mut acceleration, mut velocity, _)) = player.get_mut(0) else {
        return;
    };
    velocity.max_velocity = PLAYER_MAX_VELOCITY;
    acceleration.acceleration_rate = PLAYER_ACCELERATION;
    acceleration.deceleration_rate = PLAYER_DECELERATION;
}

#[system]
fn update_player_input(mut player_input: Mut<PlayerInput>, input: Ref<InputState>) {
    let key_to_f32 = |key_code| {
        if input.keys[key_code].pressed() {
            1.0
        } else {
            0.0
        }
    };

    player_input.move_axes = Vec2::new(
        key_to_f32(KeyCode::KeyD) - key_to_f32(KeyCode::KeyA),
        key_to_f32(KeyCode::KeyW) - key_to_f32(KeyCode::KeyS),
    )
    .clamp_length_max(1.);
}

#[system_once]
fn spawn_player() {
    let transform = &Transform {
        position: Vec2::new(1000., 0.),
        scale: Vec2::splat(1.5),
        ..Default::default()
    };
    let color = &Color::new(0.96, 0.36, 0.9, 1.);
    let circle_render = &CircleRender {
        num_sides: 20,
        ..Default::default()
    };

    let velocity = &Velocity {
        max_velocity: PLAYER_MAX_VELOCITY,
        ..Default::default()
    };
    let acceleration = &Acceleration {
        acceleration_rate: PLAYER_ACCELERATION,
        deceleration_rate: PLAYER_DECELERATION,
        ..Default::default()
    };

    Engine::spawn(bundle!(
        transform,
        color,
        circle_render,
        &Player,
        velocity,
        acceleration
    ));
}

#[system_once]
fn spawn_camera() {
    let camera = &Camera {
        aspect: CameraAspect::FixedVirtualHeight { height: 1500. },
        clear_color: Color::from_hex_str("#222").unwrap(),
        ..Default::default()
    };

    Engine::spawn(bundle!(&Transform::default(), camera));
}

#[system_once]
fn spawn_enemy() {
    let transform = &Transform {
        position: Vec2::new(-110., 0.),
        scale: Vec2::splat(1.5),
        ..Default::default()
    };
    let color = &Color::new(0.2, 0.7, 0.7, 1.);
    let circle_render = &CircleRender {
        num_sides: 20,
        ..Default::default()
    };
    let velocity = &Velocity {
        max_velocity: ENEMY_MAX_VELOCITY,
        ..Default::default()
    };
    let acceleration = &Acceleration {
        acceleration_rate: ENEMY_ACCELERATION,
        deceleration_rate: ENEMY_DECELERATION,
        ..Default::default()
    };

    Engine::spawn(bundle!(
        transform,
        color,
        circle_render,
        &Enemy,
        velocity,
        acceleration
    ));
}

#[system]
fn update_player_acceleration(
    player_input: Ref<PlayerInput>,
    mut player_query: Query<(Mut<Acceleration>, Ref<Velocity>, Ref<Player>)>,
) {
    let Some((mut acceleration, velocity, _)) = player_query.get_mut(0) else {
        return;
    };

    acceleration.target_velocity = player_input.move_axes * velocity.max_velocity;
}

#[system]
fn update_enemy_acceleration(
    mut enemy_query: Query<(Mut<Acceleration>, Ref<Velocity>, Ref<Transform>, Ref<Enemy>)>,
    player_query: Query<(Ref<Transform>, Ref<Player>)>,
) {
    let Some((player_transform, _)) = player_query.get(0) else {
        return;
    };

    enemy_query.for_each(|(mut acceleration, velocity, transform, _)| {
        let to_player = player_transform.position - transform.position;
        let distance = to_player.length();

        if distance > ENEMY_CHASE_DISTANCE {
            acceleration.target_velocity = Vec2::ZERO;
        } else {
            let ideal_position =
                player_transform.position - to_player.normalize() * ENEMY_STOP_DISTANCE;

            let to_ideal = ideal_position - transform.position;
            let distance_to_ideal = to_ideal.length();

            if distance_to_ideal < 20.0 {
                acceleration.target_velocity = Vec2::ZERO;
            } else {
                let speed_factor = if distance_to_ideal < 50.0 { 0.5 } else { 1.0 };
                acceleration.target_velocity =
                    to_ideal.normalize_or_zero() * (velocity.max_velocity * speed_factor);
            }
        }
    });
}

#[system]
fn apply_acceleration(
    mut velocities_to_adjust: Query<(Ref<Acceleration>, Mut<Velocity>)>,
    frame_constants: Ref<FrameConstants>,
) {
    velocities_to_adjust.for_each(|(acceleration, mut velocity)| {
        let velocity_delta = acceleration.target_velocity - velocity.current_velocity;

        let is_decelerating = acceleration.target_velocity == Vec2::ZERO
            || acceleration.target_velocity.dot(velocity.current_velocity) < 0.0;

        let rate = if is_decelerating {
            acceleration.deceleration_rate
        } else {
            acceleration.acceleration_rate
        };

        let max_change = rate * frame_constants.delta_time;

        let velocity_change = velocity_delta.clamp_length_max(max_change);
        velocity.current_velocity += velocity_change;

        if acceleration.target_velocity.x.abs() < ZERO_VELOCITY_THRESHOLD
            && velocity.current_velocity.x.abs() < ZERO_VELOCITY_THRESHOLD
        {
            velocity.current_velocity.x = 0.;
        }
        if acceleration.target_velocity.y.abs() < ZERO_VELOCITY_THRESHOLD
            && velocity.current_velocity.y.abs() < ZERO_VELOCITY_THRESHOLD
        {
            velocity.current_velocity.y = 0.;
        }

        velocity.current_velocity = velocity
            .current_velocity
            .clamp_length_max(velocity.max_velocity);
    });
}

#[system]
fn apply_velocity(
    mut entities_to_move: Query<(Mut<Transform>, Ref<Velocity>)>,
    frame_constants: Ref<FrameConstants>,
) {
    entities_to_move.for_each(|(mut transform, velocity)| {
        transform.position += velocity.current_velocity * frame_constants.delta_time;
    });
}

#[system]
fn update_camera_position(
    mut camera_query: Query<(Mut<Transform>, Ref<Camera>)>,
    player_query: Query<(Ref<Transform>, Ref<Player>)>,
) {
    let Some((player_transform, _)) = player_query.get(0) else {
        return;
    };

    let Some((mut camera_transform, _)) = camera_query.get_mut(0) else {
        return;
    };

    camera_transform.position = player_transform.position;
}