In the previous part we ended up with a simple board / map layout displayed. Today I'd like to add a next element to that - spawn the player character.
You can see the source code for this part in the repo branch here: https://github.com/maciekglowka/hike_deck/tree/part_02
I like to design turn-based games as if they where board games. So if we have a board, we should also have pieces that will populate it. Usually in my setup a piece can be anything that is positioned on the map: a player, a npc unit, an item etc. All those kinds of objects are going to share some behaviour, therefore we need to create some common components for them.
Let's add a new (folder-based) module called pieces
and immediately create a components.rs
file inside of it.
// pieces/components.rs
use bevy::prelude::*;
#[derive(Component)]
pub struct Piece {
pub kind: String
}
For now we have just created an initial marker component for all the pieces. It includes a single field that will define the type of the spawned piece. I am actually using the word kind
here to avoid some possible conflicts with reserved language keywords (it's something I often tend to do). The field is of a String
type, rather than eg. an enum
, as later on it will be easier this way to load piece's data from external files (like YAML
). We will get to that hopefully in the future parts.
As the player would surely be a very unique piece, with loads of custom behaviour, I think it is best to create a completely separate module for it. This time we will start with just the basic mod.rs
file:
// player/mod.rs
use bevy::prelude::*;
use crate::board::components::Position;
use crate::pieces::components::Piece;
use crate::states::MainState;
use crate::vectors::Vector2Int;
pub struct PlayerPlugin;
impl Plugin for PlayerPlugin {
fn build(&self, app: &mut App) {
app.add_system(spawn_player.in_schedule(OnEnter(MainState::Game)));
}
}
#[derive(Component)]
pub struct Player;
fn spawn_player(
mut commands: Commands
) {
commands.spawn((
Player,
Piece { kind: "Player".to_string() },
Position { v: Vector2Int::new(0, 0) }
));
}
As you can see, unlike the pieces
, this module is also a Bevy plugin - so let's not forget to register it in our main.rs
file! The only thing we do here (for now) is spawning the player entity when we enter Game
state. As it will reside on the board, we equip it with the Position
component as well - temporarily with hard-coded coords. The marker Player
component is added to make our future queries a bit easier.
Our project structure should look like so:
src/
├── assets.rs
├── board
│ ├── components.rs
│ ├── mod.rs
│ └── systems.rs
├── camera.rs
├── globals.rs
├── graphics
│ ├── assets.rs
│ ├── mod.rs
│ └── tiles.rs
├── main.rs
├── pieces
│ ├── components.rs
│ └── mod.rs
├── player
│ └── mod.rs
├── states.rs
└── vectors.rs
Obviously, when we run our game now we are not going to see any difference. Remember how we split the tile logic and graphics in the first part? It is the same here - the player exists in our logic layer, but has no means to be visible yet.
So let's go back to our graphics
module and add another file called pieces.rs
. It is going to hold all the systems responsible for rendering our pieces. As with the Tile
rendering, we will have to translate somehow our Vector2Int
position into Bevy's Transform
. For now we did this directly in the spawn_tile_renderer
system:
let v = Vec3::new(
TILE_SIZE * position.v.x as f32,
TILE_SIZE * position.v.y as f32,
0.
);
But since we are going to use it again, it makes sense to move it out into a separate function. I am going to put it in the top mod.rs
of our graphics plugin:
fn get_world_position(
position: &Position,
z: f32
) -> Vec3 {
Vec3::new(
TILE_SIZE * position.v.x as f32,
TILE_SIZE * position.v.y as f32,
z
)
}
And we can change the part in the tile spawning system to:
let v = super::get_world_position(&position, TILE_Z);
We introduce here some new consts (like TILE_Z
and PIECE_Z
) to make sure that we keep our z-index ordering consistent. I'll put them in the mod.rs
as well.
Now, for our piece-spawning system - it should look very much alike the tile one (maybe too alike, I should rethink my DRY perhaps:) :
// graphics/pieces.rs
use bevy::prelude::*;
use crate::board::components::Position;
use crate::pieces::components::Piece;
use super::{GraphicsAssets, TILE_SIZE, PIECE_Z};
pub fn spawn_piece_renderer(
mut commands: Commands,
query: Query<(Entity, &Position, &Piece), Added<Piece>>,
assets: Res<GraphicsAssets>
) {
for (entity, position, piece) in query.iter() {
let sprite_idx = match piece.kind.as_str() {
"Player" => 1,
_ => 63
};
let mut sprite = TextureAtlasSprite::new(sprite_idx);
sprite.custom_size = Some(Vec2::splat(TILE_SIZE));
sprite.color = Color::WHITE;
let v = super::get_world_position(&position, PIECE_Z);
commands.entity(entity)
.insert(
SpriteSheetBundle {
sprite,
texture_atlas: assets.sprite_texture.clone(),
transform: Transform::from_translation(v),
..Default::default()
}
);
}
}
One major difference though is that we assign the sprite index dynamically: an ASCII face for the player and the question mark for everything else. Later on obviously we are going to do this in a more civilised way :)
And the modified mod.rs
:
// graphics/mod.rs
use bevy::prelude::*;
use crate::board::components::Position;
pub const TILE_SIZE: f32 = 32.;
pub const TILE_Z: f32 = 0.;
pub const PIECE_Z: f32 = 10.;
mod assets;
mod pieces;
mod tiles;
#[derive(Resource)]
pub struct GraphicsAssets {
pub sprite_texture: Handle<TextureAtlas>
}
pub struct GraphicsPlugin;
impl Plugin for GraphicsPlugin {
fn build(&self, app: &mut App) {
app.add_startup_system(assets::load_assets)
.add_system(pieces::spawn_piece_renderer)
.add_system(tiles::spawn_tile_renderer);
}
}
fn get_world_position(
position: &Position,
z: f32
) -> Vec3 {
Vec3::new(
TILE_SIZE * position.v.x as f32,
TILE_SIZE * position.v.y as f32,
z
)
}
So, (fingers crossed now) if we hit cargo run
we should actually see some change this time (an extremely impressive result ;)
There is one last thing I'd like to include in this part - update of the piece position. Obviously in the logic layer it is as simple as changing the v
field on the Position
component. With the graphics part though it would be nice to have some smooth transition animation, so I will elaborate a bit on that.
But firstly, we need something to trigger the player's movement. For the initial testing purposes we will create a simple input
plugin / module. It is rather self explanatory and will be completely changed later, so I will not dwell too much here. (remember to register the plugin though)
// input/mod.rs
use bevy::prelude::*;
use crate::board::components::Position;
use crate::player::Player;
use crate::vectors::Vector2Int;
pub struct InputPlugin;
impl Plugin for InputPlugin {
fn build(&self, app: &mut App) {
app.add_system(player_position);
}
}
const DIR_KEY_MAPPING: [(KeyCode, Vector2Int); 4] = [
(KeyCode::W, Vector2Int::UP), (KeyCode::S, Vector2Int::DOWN),
(KeyCode::A, Vector2Int::LEFT), (KeyCode::D, Vector2Int::RIGHT),
];
fn player_position(
keys: ResMut<Input<KeyCode>>,
mut player_query: Query<&mut Position, With<Player>>,
) {
let Ok(mut position) = player_query.get_single_mut() else { return };
for (key, dir) in DIR_KEY_MAPPING {
if !keys.just_pressed(key) { continue; }
position.v += dir;
}
}
The only thing we need to know here is that if we press WASD keys the player should move on the board. Of course we are not validating the moves yet - so we can walk outside of the tile range.
Now let's add a new system in our graphics/pieces.rs
:
// graphics/pieces.rs
use bevy::prelude::*;
use crate::board::components::Position;
use crate::pieces::components::Piece;
use super::{GraphicsAssets, TILE_SIZE, PIECE_Z, PIECE_SPEED, POSITION_TOLERANCE};
pub fn update_piece_position(
mut query: Query<(&Position, &mut Transform), With<Piece>>,
time: Res<Time>,
) {
for (position, mut transform) in query.iter_mut() {
let target = super::get_world_position(&position, PIECE_Z);
let d = (target - transform.translation).length();
if d > POSITION_TOLERANCE {
transform.translation = transform.translation.lerp(
target,
PIECE_SPEED * time.delta_seconds()
);
} else {
transform.translation = target;
}
}
}
// CUT!
We query here through all the spawned pieces in our game and calculate their target Bevy-World position (based on the set game-logic position). If the distance between the actual position and the target is greater than a specific threshold (const of 0.1 for now) then we animate piece's movement, lerping it's translation towards the target. (I keep the PIECE_SPEED
at 10. now).
Otherwise, if the distance is small we make sure that the piece is placed at the exact target position (to avoid some pesky pixel-imperfect rendering issues).
Now if we run our game again we should be able to move the player around. Finally something interactive, yaay!
The update system runs at every frame and always checks all the possible pieces. In the future we might need to think about some optimization. Note however, that we cannot simply use Changed<Position>
filter here - as the logical position changes instantly within a single frame, but our movement animation is at least several frames long.
In the next parts I will focus on the NPC units and introduce some movement queue.