Rust Roguelike Overexplained: NPCs
Introduction
In this post, we will include our Map in a plugin and add some debugging-help. Then we extend our turn phases to NPCs, spawn an NPC and make it move towards the player.
Bevy Plugins
On top of Rust modules, Bevy supports Plugins. In essence, anything can be a Plugin, if it implements the interface. What you gain is encapsulation of initialization code for the ECS into a plugin.
pub struct MapPlugin;
impl Plugin for MapPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(Map::default())
}
}
This creates the MapPlugin. The struct is empty for now, but we could use it to pass configuration-data for our Map into the build function.
impl Plugin for MapPlugin: this section implements thePlugininterface for ourMapPluginstruct. The compiler will complain if we do not create everything required. Once we have done that,MapPlugincan be used as BevyPluginfn build(&self, app: &mut App): the function we are required to implement. It is a member function - that means we need to pass an instance of our plugin. It takes&self- that means that our plugin is used read-only. The second parameter is theApp. That allows us to do our initialization.insert_resource: is used to register ourMap. Note: with parameters forheightandwidthin our plugin, we could callnewinstead ofdefault(). But this is an extension we can do later if required.
With the MapPlugin available, we can now .add_plugins(MapPlugin{}) in our main() function.
This doesn’t look like a great benefit right now, but it will help us with the code structure in the long run.
Spawning Monsters!
To spawn a monster, we do the same thing as for spawning a player. To distinguish monsters from players, we add a Npc component and not a Player component.
pub fn spawn_npc(&mut self, commands: &mut Commands, x: i32, y: i32, symbol: &str) {
let e = commands.spawn((
Text2d::new(symbol),
TextFont {
font_size: FontSize::Px(FIELD_SIZE_Y),
font: default(),
..default()
},
TextColor(Color::WHITE),
Transform::from_translation(map_to_screen_coordinates(x, y, ACTORS_Z)),
MapPosition { x, y },
Npc,
));
self.add_entity(MapPosition { x, y }, e.id());
}
Note: this code duplication is not good and needs to be refactored later. The common code between the spawn methods should be pulled out into a separate function. When a bug occurs in the common code, you only need to fix it once and can’t forget another location.
Moving Monsters!
The monster should move directly in our direction after we made our move. To do this, we add two new phases: NpcAi to select where to go and NpcMovement to animate the movement.
To select where to move, we check all 8 possible movement squares around the monster, discard those that are invalid and pick the one that is “closest” according to some definition of closest. How we determine “closest” influences the path the monster will take.
This method is not very intelligent behavior for our monsters but a sufficient start. It can easily be modified later on.
Preparation - Math with MapPosition
To find the 8 surrounding MapPositions, we could calculate them with loops and conditions based on our current position. Instead we will take the math approach.
const NEIGHBORS: [MapPosition; 8] = [
MapPosition { x: 1, y: 1 },
MapPosition { x: 1, y: 0 },
MapPosition { x: 1, y: -1 },
MapPosition { x: 0, y: -1 },
MapPosition { x: -1, y: -1 },
MapPosition { x: -1, y: 0 },
MapPosition { x: -1, y: 1 },
MapPosition { x: 0, y: 1 },
];
NEIGHBORS creates a static array (with so called static lifetime, i.e. it lives for the whole runtime of your program) with the deviations from the center position. This requires that we refactor our MapPosition from u32 for x and y to i32.
Now that we have the neighbors, we can simply add our position to each of them and get our neighbors. To be able to add two types in Rust, you need to implement Add (and Sub for symmetry).
impl Add for MapPosition {
type Output = MapPosition;
fn add(self, other: MapPosition) -> MapPosition {
MapPosition {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
impl Sub for MapPosition {
type Output = MapPosition;
fn sub(self, other: MapPosition) -> MapPosition {
MapPosition {
x: self.x - other.x,
y: self.y - other.y,
}
}
}
Since addition could change the output type, you have to declare it with type Output = MapPosition;. Then you have to implement add and sub. The code should be easy enough to understand by now.
At last, we add a function that returns the neighbors of a given MapPosition:
pub fn neighbors_of(center: MapPosition) -> [MapPosition; 8] {
NEIGHBORS.map(|offset| center + offset)
}
This needs explanation.
[MapPosition;8]: this is an array with 8 elements of typeMapPosition. You can’t enlarge it or shrink it - it has exactly those 8 elements.NEIGHBORS.map(): this is a call to themapfunction of array. The map function applies a function to each element of the array and returns the result as a new array. In our case, we want to add our position to each of the elements of the NEIGHBORS array.|offset| center + offset: a rust closure. These are unnamed functions that capture parts of their environment.map()requires a function that takes aMapPositionand returns aMapPosition(e.g.fn foo(p: MapPosition) -> MapPositions), but we want ourcenterto be part of that function. We can’t add a second parameter, otherwisemap()would not know how to call it. Here is where the closure comes into play. It capturescenterand makes it part of the function.|offset|declares the function parameter to be called offset and the function body iscenter + offset. It could also have multiple lines, but in our case, it’s not required. Since this is the last statement and it has no;at the end, the return value of the function is the value of the statement. That means the closure calculates what we want - it adds anoffsetto ourcenter(using theAddtrait implementation).map()calls this for every element inNEIGHBORSand the resulting array now contains all neighbors ofcenter.
The next thing we need, is our distance function. We implement it as associated function for MapPosition:
impl MapPosition {
/// Calculates the Manhattan distance between two MapPositions.
pub fn distance_to(self, other: MapPosition) -> i32 {
(self.x - other.x).abs() + (self.y - other.y).abs()
}
}
abs() calculates the absolute value of a number. The absolute value of a number is the number itself if it is positive or -1 times the number if negative (i.e. the abs() of -2 is -1 * -2 = 2).
The Manhattan distance describes the distance if you can only move single units up/down or left/right. This isn’t actually what we’re doing (the true distance would be the Chebyshev distance) - but it produces results that favor the diagonals and that’s more what the user would expect.
Once you have everything working, play around by changing the distance function to see the different behaviors caused by it.
We could have used the “normal” distance (Euclidean), but that would have been more expensive to calculate for the same result.
All of this needs unit-tests as well. You can find them in the repository
Finding the Player
To be able to move towards the player, the monster must be able to query the position of the player. To be able to do this, we need to extend Map.
- Track the player Entity as field of the map:
player: Option<Entity>,. Since we can’t be sure that the player is always there in all states, it’s anOption. Whenspawn_playeris called, theOptionis set. - Allow a reverse-lookup from
EntitytoMapPositionvia a secondHashMap: ` positions: HashMap<Entity, MapPosition>,. All functions need to be updated to track bothHashMap`s. - Add functions to help with getting the player position.
pub fn player(&self) -> Option<Entity> {
self.player
}
pub fn position(&self, e: Entity) -> Option<MapPosition> {
self.positions.get(&e).cloned()
}
pub fn player_position(&self) -> Option<MapPosition> {
match self.player() {
Some(player) => self.position(player),
None => None,
}
}
Nothing fancy here - but you can see how small functions play together to compose to something bigger like getting the player position. Although by itself player_position doesn’t do much, it’s a function worth having - if the implementation changes in the future, you would need to search for all occurrences of that match block.
The match block itself shows how Options can be dealt with elegantly in Rust. Some(player) checks as condition whether the Option contains a value and makes that value available as player in the section after the =>. This allows calling the next helper function although it does not take an Option by itself.
Interesting is also the code for update_entity_position:
pub fn update_entity_position(
&mut self,
old_position: &MapPosition,
new_position: MapPosition,
) {
if let Some(entity) = self.entities.remove(old_position) {
self.entities.insert(new_position, entity);
self.positions.remove(&entity);
self.positions.insert(entity, new_position);
}
}
if let Some(entity) = self.entities.remove(old_position) means: if the Option returned by self.entities.remove(old_position) contains a value, then execute the block and assign the value to the name entity.
Checking Collisions
pub fn check_collision(&self, pos: MapPosition) -> bool {
self.entities.contains_key(&pos)
}
We also need to give our map a function to ask for collisions at a MapPosition. Luckily that’s simply asking our HashMap whether the key is contained or not.
Pulling Everything Together - Moving the Monster
We need a system to execute during the NpcAi phase:
fn npc_ai(
mut map: ResMut<Map>,
mut query: Query<&mut MapPosition, With<Npc>>,
mut next_turn_phase: ResMut<NextState<TurnPhases>>,
) {
for mut map_position in query.iter_mut() {
//get player position
if let Some(player_position) = map.player_position() {
//calculate all possible next positions
let mut neighbors: Vec<(MapPosition, i32)> = neighbors_of(*map_position)
.into_iter()
.filter(|pos| !map.check_collision(*pos))
.map(|pos| (pos, pos.distance_to(player_position)))
.collect();
neighbors.sort_by_key(|(_, distance)| *distance);
debug!("neighbors: {:?}", neighbors);
let current_distance = map_position.distance_to(player_position);
if let Some((next_position, distance)) = neighbors.first()
&& *distance <= current_distance
{
let original_position = *map_position;
*map_position = *next_position;
map.update_entity_position(&original_position, *next_position);
}
}
}
next_turn_phase.set(TurnPhases::NpcMovement);
}
- We ensure that our query runs with every
Npc. if let Some(player_position) = map.player_position()we determine the player position. It should always be available but our function doesn’t guarantee that - hence theif.let mut neighbors: Vec<(MapPosition, i32)> = ...: a mutableVecof tuples of MapPositions and an integer that stores the distance.neighbors_of(*map_position): thatVecis calculated from the array of neighbors from our position..into_iter(): then we turn our array into an iterator that can iterate over the array. The iterator provides an abstraction for different containers to walk through all items..filter(|pos| !map.check_collision(*pos)): filter out everything that does not match the condition. Our condition is a closure that returnstrueif the field is clear.!is the operator forNOT, negating a boolean. This is done lazily while iterating..map(|pos| (pos, pos.distance_to(player_position))): This turns eachMapPositioninto a tuple with theMapPositionand the distance to the player..collect();: turns the iterator into aVeccontaining the data. Up until that point, we simply chained lazy operations and nothing happened.collect()now actually iterates over the iterator and collects the data into aVec.neighbors.sort_by_key(|(_, distance)| *distance);: this sorts theVecby distance - shortest distance first.- Next, we calculate the current distance and then try to find a point (
neighbors.first()might be empty) with a distance smaller than the current distance. If so, we update the position of our monster. Theif let Some(...) = ... && *distance <= current_distanceis a so called let-chain: it lets you combine a pattern match with a plain boolean condition in a singleif, so the block only runs when both theOptioncontains a value and the extra condition holds - without it we would need a nestedifinside theif let. next_turn_phase.set(TurnPhases::NpcMovement);: always move to the next phase, otherwise the player will never be able to move again.
fn move_npc(
time: Res<Time>,
mut query: Query<(&mut Transform, &MapPosition), With<Npc>>,
mut next_turn_phase: ResMut<NextState<TurnPhases>>,
) {
for (mut transform, map_position) in query.iter_mut() {
let speed = 100.0;
let delta = time.delta_secs();
let target_position = map_to_screen_coordinates(map_position.x, map_position.y, ACTORS_Z);
let direction = (target_position - transform.translation).normalize_or_zero();
let distance = (target_position - transform.translation).length();
let movement_distance = speed * delta;
if distance <= movement_distance {
transform.translation = target_position;
next_turn_phase.set(TurnPhases::PlayerInput);
} else {
transform.translation += direction * movement_distance;
}
}
}
move_npc is more or less a copy of move_player, except that it works with Npc and not Player and that the next phase is TurnPhases::PlayerInput.
And now the monster moves!
Summary
At first, we made a MapPlugin to better organize our code. Then we added functionality to our MapPosition to be able to add and subtract them from each other. We learned about map() and filter() to calculate the neighbors of our current position. Using the Manhattan distance, we selected from our possible positions the “best”. This was all done during the new NpcAi-phase.