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:
;
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 *;
use crate Action;
>);
// there can be only a single occupier piece on the same tile
;
// movement behaviour for non-player pieces
;
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..iter.any ;
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:
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:
start
/end
vector positionstiles
- a collection of valid tile coordinates on our board - so we know where we can try to moveblockers
- a collection of coordinates that contains elements that would prevent the unit from moving - such as closed doors, or entities with theOccupier
component :)
Let's try now to change the plan_walk
system to match our modified Actor
component:
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:
There are two main changes here.
- we take a vector of actions (tuples with scores actually) and sort it based on their rank.
- we try to execute those actions in a sequence - until we find a valid one or run out of the possibilities.
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!;
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:
The action is slightly more complex that the movement one. It has three parameters:
- the attacker entity
- attack's target position
- damage value
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:
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:
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).
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:
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:
;
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:
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:
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:
The only missing piece left is the damage action itself:
;
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 :)