Add gameplay to your project

In this section, you will integrate gameplay to the Bevy/LDtk project created in the previous sections. This includes tile-based movement, collision, and level transitions. You are welcome to bring your own tile-based LDtk project to this tutorial, but some of the values specified in here are specific to the LDtk project created in this tutorial, such as...

  • the IntGrid value of walls (1)

For details about the tutorial in general, including prerequisites, please see the parent page.

Add marker component and GridCoords to the player

In order to implement tile-based movement and tile-based mechanics, you'll need to deal with an entity's position in tile-space rather than just Bevy world translation. bevy_ecs_ldtk provides a component that is suitable for this, and it has integration with the LdtkEntity derive. Add the GridCoords component to the PlayerBundle, and give it the #[grid_coords] attribute. The player entity will then be spawned with a GridCoords component whose value matches the entity's position in grid-space.

Also give it a Player marker component so that you can query for it more easily in future systems. Derive Default for this component. bevy_ecs_ldtk will use this default implementation when spawning the component unless otherwise specified.

#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_ecs_ldtk::prelude::*;
#[derive(Default, Component)]
struct Player;

#[derive(Default, Bundle, LdtkEntity)]
struct PlayerBundle {
    player: Player,
    #[sprite_sheet_bundle]
    sprite_bundle: LdtkSpriteSheetBundle,
    #[grid_coords]
    grid_coords: GridCoords,
}
}

Implement tile-based movement

The player now has the components you will need to implement tile-based movement. Write a system that checks for just-pressed WASD input and converts it to a GridCoords direction. I.e., (0,1) for W, (-1,0) for A, (0,-1) for S, and (1,0) for D. Then, add the new direction to the player entity's GridCoords component.

use bevy::prelude::*;
use bevy_ecs_ldtk::prelude::*;
#[derive(Component)]
struct Player;
fn main() {
    App::new()
        // other App builders
        .add_systems(Update, move_player_from_input)
        .run();
}

fn move_player_from_input(
    mut players: Query<&mut GridCoords, With<Player>>,
    input: Res<ButtonInput<KeyCode>>,
) {
    let movement_direction = if input.just_pressed(KeyCode::KeyW) {
        GridCoords::new(0, 1)
    } else if input.just_pressed(KeyCode::KeyA) {
        GridCoords::new(-1, 0)
    } else if input.just_pressed(KeyCode::KeyS) {
        GridCoords::new(0, -1)
    } else if input.just_pressed(KeyCode::KeyD) {
        GridCoords::new(1, 0)
    } else {
        return;
    };

    for mut player_grid_coords in players.iter_mut() {
        let destination = *player_grid_coords + movement_direction;
        *player_grid_coords = destination;
    }
}

Update translation from GridCoords value

If you play the game at this point, you'll notice that the player entity doesn't appear to be moving at all. The GridCoords component may be updating correctly, but the entity's Transform is what determines where it is rendered. bevy_ecs_ldtk does not maintain the Transform of GridCoords entities automatically. This is left up to the user, which allows you to implement custom tweening or animation of the transform as you please.

Write a system that updates the Transform of GridCoords entities when their GridCoords value changes. bevy_ecs_ldtk does provide a utility function to help calculate the resulting translation - provided you know the size of the cells of the grid. For the LDtk project set up in this tutorial using the SunnyLand tilesets, this grid size is 16.

use bevy::prelude::*;
use bevy_ecs_ldtk::prelude::*;
fn move_player_from_input() {}
fn main() {
    App::new()
        // other App builders
        .add_systems(
            Update,
            (
                move_player_from_input,
                translate_grid_coords_entities,
            ),
        )
        .run();
}

const GRID_SIZE: i32 = 16;

fn translate_grid_coords_entities(
    mut grid_coords_entities: Query<(&mut Transform, &GridCoords), Changed<GridCoords>>,
) {
    for (mut transform, grid_coords) in grid_coords_entities.iter_mut() {
        transform.translation =
            bevy_ecs_ldtk::utils::grid_coords_to_translation(*grid_coords, IVec2::splat(GRID_SIZE))
                .extend(transform.translation.z);
    }
}

Prevent tile-based movement into walls

Movement works logically and visually now. However, you might notice that you can move into the walls of the level. To implement tile-based collision, you will need to add components to the walls to identify their locations, and check against these locations when trying to move the player.

Create a new bundle for the wall entities, and give them a marker component. Derive LdtkIntCell for this bundle, and register it to the app with register_ldtk_int_cell and the wall's intgrid value. This bundle actually only needs this one marker component - IntGrid entities spawn with a GridCoords without requesting it.

use bevy::prelude::*;
use bevy_ecs_ldtk::prelude::*;
fn main() {
    App::new()
        // other App builders
        .register_ldtk_int_cell::<WallBundle>(1)
        .run();
}

#[derive(Default, Component)]
struct Wall;

#[derive(Default, Bundle, LdtkIntCell)]
struct WallBundle {
    wall: Wall,
}

There are a lot of ways to go about implementing the collision systems. Naively, you could query for all of the Wall entities every time the player tries to move and check their GridCoords values. In this tutorial, you will implement something a little more optimized: caching the wall locations into a resource when levels spawn.

Create a LevelWalls resource for storing the current wall locations that can be looked up by-value. Give it a HashSet<GridCoords> field for the wall locations. Give it fields for the level's width and height as well so you can prevent the player from moving out-of-bounds. Then, implement a method fn in_wall(&self, grid_coords: &GridCoords) -> bool that returns true if the provided grid_coords is outside the level bounds or contained in the HashSet.

use bevy::prelude::*;
use bevy_ecs_ldtk::prelude::*;
use std::collections::HashSet;

fn main() {
    App::new()
        // other App builders
        .init_resource::<LevelWalls>()
        .run();
}

#[derive(Default, Resource)]
struct LevelWalls {
    wall_locations: HashSet<GridCoords>,
    level_width: i32,
    level_height: i32,
}

impl LevelWalls {
    fn in_wall(&self, grid_coords: &GridCoords) -> bool {
        grid_coords.x < 0
            || grid_coords.y < 0
            || grid_coords.x >= self.level_width
            || grid_coords.y >= self.level_height
            || self.wall_locations.contains(grid_coords)
    }
}

Now, add a system that listens for LevelEvent::Spawned and populates this resource. It will need access to all of the wall locations to populate the HashSet (Query<&GridCoords, With<Wall>>). It will also need access to the LdtkProject data to find the current level's width/height (Query<&Handle<LdtkProject>> and Res<Assets<LdtkProject>>).

use bevy::prelude::*;
use bevy_ecs_ldtk::prelude::*;
use std::collections::HashSet;
const GRID_SIZE: i32 = 16;
#[derive(Default, Resource)]
struct LevelWalls {
    wall_locations: HashSet<GridCoords>,
    level_width: i32,
    level_height: i32,
}
impl LevelWalls {
    fn in_wall(&self, grid_coords: &GridCoords) -> bool {
        grid_coords.x < 0
            || grid_coords.y < 0
            || grid_coords.x >= self.level_width
            || grid_coords.y >= self.level_height
            || self.wall_locations.contains(grid_coords)
    }
}
#[derive(Component)]
struct Wall;
fn move_player_from_input() {}
fn translate_grid_coords_entities() {}
fn main() {
    App::new()
        // other App builders
        .add_systems(
            Update,
            (
                move_player_from_input,
                translate_grid_coords_entities,
                cache_wall_locations,
            )
        )
        .run();
}

fn cache_wall_locations(
    mut level_walls: ResMut<LevelWalls>,
    mut level_events: EventReader<LevelEvent>,
    walls: Query<&GridCoords, With<Wall>>,
    ldtk_project_entities: Query<&Handle<LdtkProject>>,
    ldtk_project_assets: Res<Assets<LdtkProject>>,
) {
    for level_event in level_events.read() {
        if let LevelEvent::Spawned(level_iid) = level_event {
            let ldtk_project = ldtk_project_assets
                .get(ldtk_project_entities.single())
                .expect("LdtkProject should be loaded when level is spawned");
            let level = ldtk_project
                .get_raw_level_by_iid(level_iid.get())
                .expect("spawned level should exist in project");

            let wall_locations = walls.iter().copied().collect();

            let new_level_walls = LevelWalls {
                wall_locations,
                level_width: level.px_wid / GRID_SIZE,
                level_height: level.px_hei / GRID_SIZE,
            };

            *level_walls = new_level_walls;
        }
    }
}

Finally, update the move_player_from_input system to access the LevelWalls resource and check whether or not the player's destination is in a wall.

#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_ecs_ldtk::prelude::*;
use std::collections::HashSet;
#[derive(Component)]
struct Player;
#[derive(Default, Resource)]
struct LevelWalls {
    wall_locations: HashSet<GridCoords>,
    level_width: i32,
    level_height: i32,
}
impl LevelWalls {
    fn in_wall(&self, grid_coords: &GridCoords) -> bool {
        grid_coords.x < 0
            || grid_coords.y < 0
            || grid_coords.x >= self.level_width
            || grid_coords.y >= self.level_height
            || self.wall_locations.contains(grid_coords)
    }
}
fn move_player_from_input(
    mut players: Query<&mut GridCoords, With<Player>>,
    input: Res<ButtonInput<KeyCode>>,
    level_walls: Res<LevelWalls>,
) {
    let movement_direction = if input.just_pressed(KeyCode::KeyW) {
        GridCoords::new(0, 1)
    } else if input.just_pressed(KeyCode::KeyA) {
        GridCoords::new(-1, 0)
    } else if input.just_pressed(KeyCode::KeyS) {
        GridCoords::new(0, -1)
    } else if input.just_pressed(KeyCode::KeyD) {
        GridCoords::new(1, 0)
    } else {
        return;
    };

    for mut player_grid_coords in players.iter_mut() {
        let destination = *player_grid_coords + movement_direction;
        if !level_walls.in_wall(&destination) {
            *player_grid_coords = destination;
        }
    }
}
}

With this check in place, the player should now be unable to move into walls!

Trigger level transitions on victory

The final step is to implement the goal functionality. When the player reaches the goal, the next level should spawn until there are no levels remaining.

Similar to the PlayerBundle, give the GoalBundle its own marker component and GridCoords.

#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_ecs_ldtk::prelude::*;
#[derive(Default, Component)]
struct Goal;

#[derive(Default, Bundle, LdtkEntity)]
struct GoalBundle {
    goal: Goal,
    #[sprite_sheet_bundle]
    sprite_bundle: LdtkSpriteSheetBundle,
    #[grid_coords]
    grid_coords: GridCoords,
}
}

Then, write a system that checks if the player's GridCoords and the goal's GridCoords match. For a small optimization, filter the player query for Changed<GridCoords> so it's only populated if the player moves. If they do match, update the LevelSelection resource, increasing its level index by 1. bevy_ecs_ldtk will automatically despawn the current level and spawn the next one when this resource is updated.

use bevy::prelude::*;
use bevy_ecs_ldtk::prelude::*;
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Goal;
fn move_player_from_input() {}
fn translate_grid_coords_entities() {}
fn cache_wall_locations() {}
fn main() {
    App::new()
        // other App builders
        .add_systems(
            Update,
            (
                move_player_from_input,
                translate_grid_coords_entities,
                cache_wall_locations,
                check_goal,
            ),
        )
        .run();
}

fn check_goal(
    level_selection: ResMut<LevelSelection>,
    players: Query<&GridCoords, (With<Player>, Changed<GridCoords>)>,
    goals: Query<&GridCoords, With<Goal>>,
) {
    if players
        .iter()
        .zip(goals.iter())
        .any(|(player_grid_coords, goal_grid_coords)| player_grid_coords == goal_grid_coords)
    {
        let indices = match level_selection.into_inner() {
            LevelSelection::Indices(indices) => indices,
            _ => panic!("level selection should always be Indices in this game"),
        };

        indices.level += 1;
    }
}

With this, the simple tile-based game is complete. When you navigate the player to the goal, the next level will begin until there are no levels remaining.