Rust Roguelike Overexplained: Movement 2
Introduction
Last time, we made the @ sign move and follow our keyboard commands. But the movement itself wasn’t the style of movement you’ll expect in a turn-based roguelike. It was continuous as long as you pressed a movement key. In this part of the series, we’re going to change to a more turn-based style.
Disclaimer
So far, I stuck relatively close to other tutorials and thus was very confident that what I explained was correct. Now we’re slowly but steadily leaving that realm. We’ll make progressive use of ECS and its scheduling capabilities. I don’t know whether the structure that I will propose will scale to game level - I just know that it works. I will explain my rationale and where it might give problems - but in the end, only the future will show whether the decisions were good.
Turn-Based Movement
With turn-based movement, you issue a movement command via a single button click and then the movement continues until your @ reaches the next field. In the original rogue, the graphics were a text terminal with rows and columns of characters. A character’s position was always aligned to a row and column - it did not move smoothly between cells but jumped from one to the next instead. We have the benefit of a graphics engine and can move the @ sign pixel by pixel smoothly. The following code will make use of this.
Solution Outline
When a key is pressed, we don’t move the character instantly but instead we will store a location where the character will move to. In a second system, we’ll evaluate whether there is a target to move to and calculate the translation for this frame and move the character accordingly.
Code
Logging
Logging is a technique in computer programming that helps narrowing down programming errors. It is done by inserting statements into the code that print out important information into a file or on console. Log-messages are usually associated with severities and logging can be configured to log only messages above a certain level. The levels in bevy are trace, debug, info, warn and error (in order of severity from lowest to highest).
use bevy::log::LogPlugin;
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(LogPlugin {
level: bevy::log::Level::DEBUG,
..default()
}))
.run();
}
The code above initializes logging to the console for your Bevy application. The level determines which messages are logged. In this case everything except trace is logged.
A New Component
#[derive(Component)]
struct Movement
{
target: Vec3
}
As a first step, we need a new component that stores the movement. For now it simply stores the movement vector required to reach the target.
This is most likely not the final solution. As you can see later, there is a potential rounding issue that can add up. Additionally, you’ll most likely want to have the destination stored to interact with the map. For now, this is sufficient and once we deal with the map, it might change.
Keyboard System
This time, we’ll have a dedicated system for keyboard input. It is scheduled with every frame (Update schedule) for fast reaction time.
fn keyboard_input(
keyboard_input: Res<ButtonInput<KeyCode>>,
query: Query<Entity, (With<Player>, Without<Movement>)>,
mut commands: Commands
) {
let field_size = 24.0;
for player in query.iter() {
let mut direction = Vec3::ZERO;
if keyboard_input.pressed(KeyCode::KeyW) {
direction.y += 1.0;
}
if keyboard_input.pressed(KeyCode::KeyS) {
direction.y -= 1.0;
}
if keyboard_input.pressed(KeyCode::KeyA) {
direction.x -= 1.0;
}
if keyboard_input.pressed(KeyCode::KeyD) {
direction.x += 1.0;
}
if direction != Vec3::ZERO {
direction *= field_size;
debug!("Player {player} moving toward {direction:?}");
let movement = Movement { target: direction };
commands.entity(player).insert(movement);
}
}
}
Query<Entity, (With<Player>, Without<Movement>)>: This queries for the Entity and not a component of that entity. It does have an extended filter. You can read that filter as “every entity that has aPlayercomponent and at the same time does not have aMovementcomponent”. This means, although the system is scheduled every frame, it will not run the body of the function when there is either no player or the player is moving. We have seenWithbefore.Withoutis similar but checks for the absence of a component. Both conditions are combined with the logical AND by placing them in a tuple (indicated by the extra parantheses). If you haven’t heard of boolean logic, now is a good time to read up on it. It will come up often in programming & computer science.mut commands: Commands: gives us access to the command queue that allows us to modify things. Bevy requires this for inserting/removing components since it is a structural change.let field_size = 24.0;: a local constant to make a field 24*24 pixel. It is always better to use named constants and not magic numbers. This makes the code more readable and easier to change.if: determine the movement vector in “fields”.if direction != Vec3::ZERO {: run the following code only when there is a movement command issued.direction *= field_size;: scaling up the vector according to the size of a field.let movement = Movement { target: direction };: create a Movement component instance.commands.entity(player).insert(movement);: schedule the insertion of the component into the player. After this line, the player entity will have a movement component. Since we excluded entities withMovement, this code will not be called again until the component is removed again.debug!("Player {player} moving toward {direction:?}");: a message that helps debugging the code. It will be printed on the console. As an experiment, you should move thedebug!statement before theifand see what happens.{}means that the element inside the brackets is not resolved to some readable representation. It does not print the contents literally.{player}for example prints the entity number.directionis aVec3and:?selects the debug representation ofVec3. Omitting it also works, sinceVec3also has a text representation. It will print the coordinates inside[]and leave out the type name.
Summary: The system reacts only on players without movement. If there is such an entity, it checks whether one or multiple of WASD keys are pressed. If so, it calculates a translation vector to the next field. Please note, this vector is not normalized - it points directly to the next field and must be longer with diagonal movement. It inserts this vector as part of a Movement component into player. This results in it not being called again until Movement is removed from player.
Player Movement
This will react on Players that have Movement and Transform components.
fn move_player(time: Res<Time>, mut query: Query<(&mut Transform, &mut Movement, Entity), With<Player>>, mut commands: Commands) {
for (mut transform, mut movement, entity) in query.iter_mut() {
let speed = 100.0;
let delta = time.delta_secs();
let direction = movement.target;
let displacement = if direction.length_squared()>1.0 {
direction.normalize_or_zero() * speed * delta
} else {
direction
};
transform.translation += displacement;
movement.target -= displacement;
if movement.target.length_squared() < 0.1 {
debug!("Player {entity} reached target");
commands.entity(entity).remove::<Movement>();
}
}
}
- we don’t need the keyboard in this system.
mut query: Query<(&mut Transform, &mut Movement, Entity), With<Player>>: read this as every entity that hasTransformandMovementfiltered by components that havePlayer.TransformandMovementwill be changed and the entity id should also be provided. Here the tuple is with the elements that we want to work with in the system. This is hard to understand. Read it multiple times and try to understand the differences between this query and the one in keyboard.mut commands: Commands: access to the command queue as with thekeyboard_input.let direction = movement.target;: we use the direction vector stored in theMovementcomponentlet displacement = if direction.length_squared()>1.0 {: check if the length is > 1.0. This can be done on the squared length to save some CPU cycles. Please note that hereifis used as an expression that returns a value. For this to work the last lines in each part of the if body denoted by{}must not have a;.} else {: run the following block encased in{}in case the condition of the if is not met.- The combined if-else expression returns either the normalized and speed adjusted vector for vectors with a length larger than 1.0 or the vector itself. That means if the distance is smaller than 1 pixel, we simply complete the movement.
transform.translation += displacement;: do the actual movementmovement.target -= displacement;: subtract what we moved from the way that we still need to go.if movement.target.length_squared() < 0.1 {: if we are close to the target. This is to accommodate potential rounding errors during the floating-point calculations.debug!("Player {entity} reached target");: emit a debug messagecommands.entity(entity).remove::<Movement>();: issue a command to remove theMovementcomponent. After this step is completed, thekeyboard_inputsystem will be able to react to keyboard inputs again.
This is still not perfect. It will not work if delta is large due to overshooting. But fixing this right now is not worth it due to upcoming changes. Try to imagine what happens if you’re close to your target point but suddenly delta is very large.
Schedule Setup
For completeness
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(LogPlugin {
level: bevy::log::Level::DEBUG,
..default()
}))
.add_systems(Startup, setup)
.add_systems(Update, keyboard_input) //added code
.add_systems(Update, move_player) //added code
.run();
}
Summary
We implemented step-wise movement. We did this by separating the keyboard logic from the rendering logic using an extra component. As a first step, logging was added to be able to extract information from the running program to help in finding bugs. As a next step, we added a new component. Then the separate systems were set up. Both with very different query logic. The systems “communicate” via the presence/absence of the Movement component - forming a small state machine. Inserting and removing components in Bevy is costly. In our case this does not matter since we do it rarely. In other cases, other Bevy mechanisms like States can be a better choice.
Potentially Wrong Decisions
- adding/removing components: it is costly. The reason for this approach is that this allows filtering in the query (which is fast). Otherwise the value inside the
Movementcomponent would have to be checked. - not storing the target point but the movement itself: this is most likely wrong - but good enough for now. Once we figured out how maps work, this will be changed.
- allowing multiple key presses affect the movement: as user experience in turn based movement, you probably want a special key for diagonal movement. Otherwise the success of diagonal movement depends on the keyboards capabilities & user dexterity. This will be re-visited.
- storing the un-normalized movement vector: since
Movement.targetholds the raw per-axis step, a diagonal target is√2times longer than an orthogonal one. Becausemove_playermoves at a constant normalized speed, this means diagonal steps currently take noticeably longer (in real time) to complete than orthogonal ones - unlike in the previous part, where the input vector was normalized so that diagonal and orthogonal movement had the same speed. This falls out of point 2 above and should be revisited together with it.