Maciej Główka
Blog Games Contact

← Return to Blog Index

melee.png
April 19, 2023

Bevy roguelike tutorial / devlog part 5 - more actions

rust bevy gamedev

In the previous part we have managed to give the NPC units an ability to walk. This time we are going to extend their action spectrum by implementing some melee hits.

The entire source code for this episode can be found here: https://github.com/maciekglowka/hike_deck/tree/part_05

Actor component

As you might remember, last time we have created an Actor component that could hold a planned action for the entity:


pub struct Actor(pub Option<Box<dyn Action>>);

The movement action for the NPC units has been created inside of the plan_walk system, where a single random direction has been drawn. Now, we'd like to add another system to create the attack possibility. If we assume that those systems should run in parallel, then I think we can notice a possible problem here. One of the systems will overwrite the other's result - as there can only be a single action stored in the component. The last system in the schedule, would be the one to produce the executed action. But how are we going to decide what the order should be? (especially if the requirement could change from turn to turn)

Juggling the system queue could be quite tricky. That is why we are going to handle things a bit differently. We are going to change the Actor component in a way that it can accept multiple possible actions each turn. We are also going to give every action a score, in order to prioritize them. Right before we process the actions, we will sort them and pick the one with the highest score - as this would be the best choice at the moment. If the first action proves invalid, we are going to take the next one - until we find a valid one or run out of options.

In the build from the last tutorial part, you might have noticed that the NPCs sometimes did not move. It could happen when a randomly selected direction was not a valid position. Now, since we are going to iterate through a list of all possible actions it should not happen any more.

Let's start then by changing our components:


// pieces/components.rs
use bevy::prelude::*;

use crate::actions::Action;

#[derive(Component, Default)]
pub struct Actor(pub Vec<(Box<dyn Action>, i32)>);

#[derive(Component)]
pub struct Health {
    pub value: u32
}

#[derive(Component)]
pub struct Melee {
// melee attack behaviour for the npcs
    pub damage: u32
}

#[derive(Component)]
// there can be only a single occupier piece on the same tile
pub struct Occupier;

#[derive(Component)]
pub struct Piece {
    pub kind: String
}

#[derive(Component)]
// movement behaviour for non-player pieces
pub struct Walk;

As you can see, the Actor component holds now a vector of tuples. The first tuple element is an action, the second one is the score. I have also already added some additional components here, that are going to be useful once we start implementing the attacks. Most of them should be quite obvious. Perhaps only the Occupier requires a bit of explanation.

We are going to spawn it on entities that cannot be walked on - like the units. We assume that only one unit can occupy a tile at a time. Therefore no other unit can step into tile that already contains another unit (or any entity with the Occupier component). Not every piece should share this behaviour though. The items for example, should allow picking them up, so their entities would not have the Occupier component on them.

We can already modify our WalkAction to use this information when validating moves (of course we have to spawn this component on all the units as well). Just include this line somewhere at the beginning of the execute function:


if world.query_filtered::<&Position, With<Occupier>>().iter(world).any(|p| p.v == self.1) { return false };

Chase the player

The base logic of our NPCs is going to be very simple (at least for now): get as close as possible to the player and attack. Since we have the movement actions already somewhat implemented, let's first modify them to follow this goal.

I have included in the vectors module a simple path finding function that will enable our NPCs to efficiently move towards the player. I am not going to discuss here the implementation, as I'd rather focus on other aspects. If you want to know more on the subject, Red Blob Games is always a great resource for that: https://www.redblobgames.com/pathfinding/a-star/introduction.html

For now it is enough to know that the function has a signature like so:


pub fn find_path(
    start: Vector2Int,
    end: Vector2Int,
    tiles: &HashSet<Vector2Int>,
    blockers: &HashSet<Vector2Int>
) -> Option<VecDeque<Vector2Int>> {

First of all, please notice that it can fail to find the right path and return None - which would mean that the player cannot be reached at the moment. There is also a number of arguments that we have to provide:

Let's try now to change the plan_walk system to match our modified Actor component:


pub fn plan_walk(
    mut query: Query<(&Position, &mut Actor), With<Walk>>,
    queue: Res<ActorQueue>,
    player_query: Query<&Position, With<Player>>,
    occupier_query: Query<&Position, With<Occupier>>,
    board: Res<CurrentBoard>,
) {
    let Some(entity) = queue.0.get(0) else { return };
    let Ok((position, mut actor)) = query.get_mut(*entity) else { return };
    let Ok(player_position) = player_query.get_single() else { return };
    // get all possible move targets
    let positions = ORTHO_DIRECTIONS.iter().map(|d| *d + position.v).collect::<Vec<_>>();
    // find possible path to the player
    let path_to_player = find_path(
        position.v,
        player_position.v,
        &board.tiles.keys().cloned().collect(),
        &occupier_query.iter().map(|p| p.v).collect()
    );
    let mut rng = thread_rng();
    let actions = positions.iter()
        .map(|v| {
            // randomize movement choices
            let mut d = rng.gen_range(-10..0);
            if let Some(path) = &path_to_player {
                // however prioritze a movement if it leads to the player
                if path.contains(v) { d = 5 }
            }
            (Box::new(WalkAction(*entity, *v)) as Box<dyn super::Action>, MOVE_SCORE + d)
        })
        .collect::<Vec<_>>();
    actor.0.extend(actions);
}

It got a little bit more complex indeed, but the idea is rather simple. We create actions for all the possible movement locations (based on four cardinal directions from the NPC's current position). If a position leads to the player (lies on the found path) - it receives the highest score. Other moves are scored lower and randomly, to fake some organic behaviour. Finally we push those actions to the Actor component of our unit.

You can also notice a MOVE_SCORE const, that has not been mentioned yet. It is defined on top of the actions/systems.rs , together with the (unused now) ATTACK_SCORE. Those two const have values of 50 and 100 respectively, to guarantee that the attacks would always be ranked much higher. It makes our life a bit easier to have those grouped in a single place in the code, as their relation is immediately visible this way. It would become especially handy if we will add even more possible actions for the NPCs in the future.

Now let's modify the action queue system to also match the new form of the Actor component:


pub fn process_action_queue(world: &mut World) {
    let Some(mut queue) = world.get_resource_mut::<ActorQueue>() else { return };
    let Some(entity) = queue.0.pop_front() else {
        world.send_event(ActionsCompleteEvent);
        return;
    };
    let Some(mut actor) = world.get_mut::<Actor>(entity) else { return };
    // clear the Actor vec
    let mut possible_actions = actor.0.drain(..).collect::<Vec<_>>();
    // highest score first
    possible_actions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());

    let mut success = false;
    for action in possible_actions{
        if action.0.execute(world) {
            success = true;
            break;
        }
    }
    if !success && world.get::<Player>(entity).is_some() {
        world.send_event(InvalidPlayerActionEvent);
        return;
    }
    world.send_event(NextActorEvent);
}

There are two main changes here.

Again, when the player cannot perform a valid action we send an event to rollback to the input phase.

There is one last change that we have to do, before we can test our code. In the input handling system we have to build a vector of actions and push it into the Actor component (even though the player always plans only a single one):


// action score does not matter for the player
actor.0 = vec![(Box::new(action), 0)];

Now if we run the game we should already notice that the NPCs consistently try to get closer to the player. (and they can't walk on each other any more)

Melee attack

Ok, so now that we figured a better way to solve NPC movements, let's finally try to handle the attacks.

We are going to define a basis for our action struct first:


pub struct MeleeHitAction{
    pub attacker: Entity,
    pub target: Vector2Int,
    pub damage: u32
}
impl Action for MeleeHitAction {
    fn execute(&self, world: &mut World) -> bool {
        let Some(attacker_position) = world.get::<Position>(self.attacker) else { return false };
        if attacker_position.v.manhattan(self.target) > 1 { return false };
        let target_entities = world.query_filtered::<(Entity, &Position), With<Health>>()
            .iter(world)
            .filter(|(_, p)| p.v == self.target)
            .collect::<Vec<_>>();
        if target_entities.len() == 0 { return false };
        // TODO deal actual damage
        info!("Hit!");
        true
    }
}

The action is slightly more complex that the movement one. It has three parameters:

We might have used here an Entity instead of a Vector2Int for the attack target - which would save us from making a more costly position lookup. But, I think in the long run we will get more flexibility this way (esp. when we get to implementing player's actions).

As you can see, for now I've skipped the actual attack / dealing damage part. We will come back to this a bit later. For now we are going to mark a successful attack by writing down some message to the terminal.

The only thing we do here at the moment is actually a validation. First, we query for our attacker's position. Then we check that the target's distance is no more than a tile apart. Finally, we try to find entities in the target position with a Health component on them (only those can be damaged).

Now, the planning system for this action should be rather simple:


pub fn plan_melee(
    mut query: Query<(&mut Actor, &Melee)>,
    player_query: Query<&Position, With<Player>>,
    queue: Res<ActorQueue>
) {
    let Some(entity) = queue.0.get(0) else { return };
    let Ok((mut actor, melee)) = query.get_mut(*entity) else { return };
    let Ok(player_position) = player_query.get_single() else { return };
    let action = Box::new(MeleeHitAction{
        attacker: *entity,
        target: player_position.v,
        damage: melee.damage
    });
    actor.0.push((action, PLAYER_ATTACK_SCORE + melee.damage as i32))
}

After getting a NPC unit from the queue, we search for player's position and build and action targeting this tile. Then we calculate action's score based on the global const mentioned earlier. We also add the damage value to the score, in case we implement more sources of the attack actions in the future (the ones that deal more damage would have a higher priority).

System sets

Now, we have to register this system to make it work. It should run more or less at the same moment as the plan_walk system (they can't be parallelized though - as they both require a mutable access to the Actor component). So ideally we'd like to group them somehow - instead of scheduling each separately with duplicated run conditions. Fortunately, the recent changes in Bevy 0.10 make it really easy to do so.

We are going to used as SystemSet for that:


#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
enum ActionSet {
    Planning,
    Late
}

The set will also allow us to schedule the process_action_queue system to run strictly after our planning systems (that's why we add the Late phase).


fn build(&self, app: &mut App) {
        app.init_resource::<ActorQueue>()
            .add_event::<TickEvent>()
            .add_event::<NextActorEvent>()
            .add_event::<ActionsCompleteEvent>()
            .add_event::<InvalidPlayerActionEvent>()
            .configure_set(ActionSet::Planning.run_if(on_event::<NextActorEvent>()))
            .configure_set(ActionSet::Planning.before(ActionSet::Late))
            .add_system(systems::process_action_queue
                .run_if(on_event::<TickEvent>())
                .in_set(ActionSet::Late)
            )
            .add_system(systems::populate_actor_queue
                .in_schedule(OnExit(GameState::PlayerInput))
            )
            .add_systems(
                (systems::plan_walk, systems::plan_melee)
                .in_set(ActionSet::Planning)
            );
    }

As you can see, both planning systems are grouped nicely together in a single registration line. We have also configured the system set phases to run in a desired order. The planning phase will run only when the NextActorEvent is fired.

Now, let's make our player entity targetable, by inserting a Health component on it and we can run the game again. If we let the NPCs close enough we should be able to see (in the terminal for now) that we are under attack!

Action result

For the final part of this episode we are going to deal the actual damage and decrease the HP value of the attacked unit. We could do it in a very straightforward way - right in the melee action's execute function and it might work well for a simple game (as we have now). Imagine though, that in the future we are going to add some other attack types (like ranged or magic or whatever). They'd also want to deal damage to the Health component, so it might lead to some unnecessary code duplication.

Moreover, as we'd want to include more animations in our game, we could face another issue. If we dealt the damage at the same tick moment as the attack, both attack and damage animations would be played simultaneously. For the melee attack it might not be too much of a problem, but imagine seeing a unit getting hit before an arrow reaches it :) (might be not something we want).

That's why we are going to complicate things a little bit more again and a add mechanism that would allow actions to spawn other actions as their result. In order to do so, we are going to modify our trait slightly:


pub trait Action: Send + Sync {
    fn execute(&self, world: &mut World) -> Result<Vec<Box<dyn Action>>, ()>;
}

Instead of returning a simple bool from the execute function, we are going to produce now an actual Rust result. If the execution goes well we will return a Vec of possible further actions (it can be empty of course). Otherwise we are going to return an error.

[ Note that we get an extra coding benefit here - we will be able to use the handy ? syntax in our actions now :) ]

We are also going to add another resource in our action module:


#[derive(Default, Resource)]
pub struct PendingActions(pub Vec<Box<dyn Action>>);

As you probably have guessed, it is going to temporarily hold all the result-actions - until the next tick event.

We need to modify our action processing system to push those pending actions into the resource:


pub fn process_action_queue(world: &mut World) {
    if process_pending_actions(world) { return }

    let Some(mut queue) = world.get_resource_mut::<ActorQueue>() else { return };
    let Some(entity) = queue.0.pop_front() else {
        world.send_event(ActionsCompleteEvent);
        return;
    };
    let Some(mut actor) = world.get_mut::<Actor>(entity) else { return };
    // clear the Actor vec
    let mut possible_actions = actor.0.drain(..).collect::<Vec<_>>();
    // highest score first
    possible_actions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());

    let mut success = false;
    for action in possible_actions{
        if let Ok(result) = action.0.execute(world) {
            if let Some(mut pending) = world.get_resource_mut::<PendingActions>() {
                pending.0 = result
            }
            success = true;
            break;
        }
    }
    if !success && world.get::<Player>(entity).is_some() {
        world.send_event(InvalidPlayerActionEvent);
        return;
    }
    world.send_event(NextActorEvent);
}

You might also notice that in the first line of the system I am calling a separate function that would process those pending actions. I've decided to keep it outside of the system just to make the code a bit cleaner.

The function is going to return true if there has been at least one valid pending action in the resource. If this is would be the case, then we will not process the next actor. We will just handle those pending actions during this tick.

The process_pending_actions function is rather straightforward:


fn process_pending_actions(world: &mut World) -> bool {
    // returns true if at least one pending action has been processed
    // take action objects without holding the mutable reference to the world
    let pending = match world.get_resource_mut::<PendingActions>() {
        Some(mut res) => res.0.drain(..).collect::<Vec<_>>(),
        _ => return false
    };
    let mut next = Vec::new();
    let mut success = false;
    for action in pending {
        if let Ok(result) = action.execute(world) {
            next.extend(result);
            success = true;
        }
    }
    // if there are any new actions assign them back to the resource
    // should be safe to unwrap as we confirmed the resource at the beginning
    let mut res = world.get_resource_mut::<PendingActions>().unwrap();
    res.0 = next;
    success
}

Do notice however, that executed pending actions can result in yet another generation of actions to be performed during the next tick. We have to collect them and push back into the PendingActions resource.
Now, we can finish our melee action so it actually generates some damage:


impl Action for MeleeHitAction {
    fn execute(&self, world: &mut World) -> Result<Vec<Box<dyn Action>>, ()> {
        let attacker_position = world.get::<Position>(self.attacker).ok_or(())?;
        if attacker_position.v.manhattan(self.target) > 1 { return Err(()) };
        let target_entities = world.query_filtered::<(Entity, &Position), With<Health>>()
            .iter(world)
            .filter(|(_, p)| p.v == self.target)
            .collect::<Vec<_>>();
        if target_entities.len() == 0 { return Err(()) };
        let result = target_entities.iter()
            .map(|e| Box::new(DamageAction(e.0, self.damage)) as Box<dyn Action>)
            .collect::<Vec<_>>();
        Ok(result)
    }
}

The only missing piece left is the damage action itself:


pub struct DamageAction(pub Entity, pub u32);
impl Action for DamageAction {
    fn execute(&self, world: &mut World) -> Result<Vec<Box<dyn Action>>, ()> {
        let Some(mut health) = world.get_mut::<Health>(self.0) else { return Err(()) };
        health.value = health.value.saturating_sub(self.1);
        if health.value == 0 {
            // the unit is killed
            world.despawn(self.0);
        }
        Ok(Vec::new())
    }
}

This implementation is simple: we just subtract the damage value from the Health component. If it goes down to 0 we despawn the entity. Later on. we could move the kill to another separate action if needed, but for now let's not complicate it more than necessary :) (it could be helpful though if we'd want to have systems preventing the kill - like life saving amulets and such).

Now, if we run the game and let the NPCs to catch the player, it should get damaged and finally killed.

(actually before that we have to update slightly the WalkAction so it matches the new execute function signature. If you have problems with that - just check the repo)

That is it for now. Next time we are going to start working on the card system - so the player can fight back :)

← Bevy roguelike tutorial / devlog part 4 - NPC units Bevy roguelike tutorial / devlog part 6 - player cards→