The attacks we've implemented in the last part are not exactly visible. Unless a unit is killed and it disappears from the board. So, this time we are going to fix this by adding some more animations to the game.
The entire code for this part can be found here: https://github.com/maciekglowka/hike_deck/tree/part_07
When animating the movement, we have been checking every unit's board position each frame If it didn't match the world Transform
we updated the latter. When it comes to the attacks, there is really no specific unit state that would determine that an action has just happened. Even if there is an effect, it would apply to a different entity than the attacker.
That's why we need to find a different way to communicate the executed attack to the graphics module (and possibly other future actions). Again, I think events are a neat way to do it. They allow for quite a low level of code coupling and most importantly, can have multiple subscribers. If in the future we will add some more bells and whistles to the game, they can utilize the very same events - without any need for alteration of the logic part. This could include:
Let's start then by creating a generic event struct that can hold all kinds of out action trait objects. I've put it in the actions/mod.rs
file (don't forget to register the event):
pub struct ActionExecutedEvent(pub Box<dyn Action>);
Now, we'd like this event to be send every time a valid action is completed. So far we had two places in our code where we executed the actions (the process_pending_actions
and the process_action_queue
systems). That obviously caused some slight unwanted code duplication. Now if we wanted to emit the event after each action, we would have to add this logic in those two places again. Let's not do that and instead make a tiny refactor of our action processing functions.
// in actions/systems.rs
fn execute_action(action: Box<dyn super::Action>, world: &mut World) -> bool {
if let Ok(result) = action.execute(world) {
if let Some(mut pending) = world.get_resource_mut::<PendingActions>() {
pending.0.extend(result);
}
world.send_event(ActionExecutedEvent(action));
return true;
}
false
}
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 success = false;
for action in pending {
success = success || execute_action(action, world);
}
success
}
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 {
// this can mean that the current actor
// has been removed from the world since creating the queue
// cue the next one
world.send_event(NextActorEvent);
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{
success = success || execute_action(action.0, world);
if success { break }
}
if !success && world.get::<Player>(entity).is_some() {
world.send_event(InvalidPlayerActionEvent);
return;
}
world.send_event(NextActorEvent);
}
We've added yet another helper function - the execute_action
, that does exactly what it's name says. It also tries to handle the action's result (as there was some duplication here as well). Namely extends the pending_actions
resource vec, if necessary. We have already also added our newly created event to the function as well.
Thanks to that our process_pending_actions
got much cleaner. Now it basically just grabs the action list and executes them in a loop.
The main process_action_queue
function is a bit more complex as it still handles picking the next actor from the queue. Also performs a rollback if the player's action is not valid. (maybe it should be split into separate functions, we'll see later on) I have also fixed a tiny potential bug, happening when the picked actor had been despawned from the world (eg. killed). We emit now a NextActor
event in such a case - which makes the queue processing behave correctly. (before that, the planning phase of the next unit was not properly executed).
There is a number of ways we can indicate that the melee attack has happened. One of them is simply to show a back-and-forth movement towards the target. We are going to do just that. It obviously has some similarities with our walk animation and it wouldn't be too bad to combine logic of the two into some kind of shared behaviour.
I say, they can be both seen as a path animation, where the animated object has to visit a specified number of nodes. In case of the walk there is only one - the final position, in case of the attack there will be two of them. I think it should make sense in the long run and potentially can be even extended in the future for things like shooting projectiles etc. (where the projectile is an object moving along a path :)
Since we already have the walk animation somehow implemented, let's first try to change that - to match our more generic approach. We are obviously going to need some kind of a component to hold the animation path (I've added it in a new graphics/components
submodule):
// graphics/components.rs
use bevy::prelude::*;
use std::collections::VecDeque;
#[derive(Component)]
pub struct PathAnimator(pub VecDeque<Vec3>);
Now let's add a system that reacts on the ActionExecutedEvent
and inserts our new component on the walking entity.
// in graphics/pieces.rs
pub fn walk_animation(
mut commands: Commands,
mut ev_action: EventReader<ActionExecutedEvent>,
mut ev_wait: EventWriter<super::GraphicsWaitEvent>
) {
for ev in ev_action.iter() {
let action = ev.0.as_any();
if let Some(action) = action.downcast_ref::<WalkAction>() {
let target = super::get_world_vec(action.1, PIECE_Z);
commands.entity(action.0)
.insert(PathAnimator(VecDeque::from([target])));
ev_wait.send(super::GraphicsWaitEvent);
}
}
}
Now, you can probably see that something truly unholy is happening here. as_any
, downcast_ref
what is all that?
Well, in order to animate the action we need to extract it's parameters from the event - and we chose to use a rather generic event for all our possible actions. That means we do not immediately know what kind of action we are processing. It can be a walk, but also digging a hole in the dungeon wall is possible (if we implement such a thing). We have to match the boxed trait object to a more concrete action struct. And it is not something that Rust directly allows to my knowledge :)
The most Rusty thing to do would be to use an enum here (as the event parameter). However this would mean that for each action type we would have to create a separate enum variant. Also, when sending the event, we would have to decide somehow which exact variant to use (probably through some kind of mapping). I am not saying this would be an incorrect solution - surely it has some benefits. But as it would involve some extra boiler-plate code I am trying something else here.
Rust's standard library introduces an Any
trait that allows some basic dynamic-typing workflows. It is usually not a recommended approach, but since we are using it only for read-only event sharing in a closed environment I hope it will be ok in our case :) [see more on that here: https://doc.rust-lang.org/std/any/index.html]
We won't avoid extra code completely though. We need to add an additional method to the Action
trait, that will allow us to cast the actions into Any
. And since the compile-time knowledge of the exact struct size is needed here, I was not able to get away with a default implementation within the trait definition. So, we'll also have to add an extra line per each action impl. (if you know how to use the default in such case - please let me know:):
// action trait
pub trait Action: Send + Sync {
fn execute(&self, world: &mut World) -> Result<Vec<Box<dyn Action>>, ()>;
fn as_any(&self) -> &dyn Any;
}
// action impl
impl Action for WalkAction {
fn execute(&self, world: &mut World) -> Result<Vec<Box<dyn Action>>, ()> {
// cut!
}
fn as_any(&self) -> &dyn std::any::Any { self }
}
Let's go back to our walk_animation
system then. The logic goes as follows: we iterate through all the current action events. If we match any that contains a walk action we extract it's parameters. Based on the board target position (Vector2Int) we create a Bevy world target (Vec3) and then spawn our animator component on the entity. The component contains a single element vec as it's path.
(Note also that I use here an unmentioned get_world_vec
helper function. I won't describe it as it's completely straight-forward. You can check the repo - it's in the graphics/mod.rs
).
Lastly we send a GraphicsWaitEvent
so our tick system will not progress the game logic this frame.
Now we can replace the update_piece_position
with a more generic system that would handle all our path animations:
pub fn path_animator_update(
mut commands: Commands,
mut query: Query<(Entity, &mut PathAnimator, &mut Transform)>,
time: Res<Time>,
mut ev_wait: EventWriter<super::GraphicsWaitEvent>
) {
for (entity, mut animator, mut transform) in query.iter_mut() {
if animator.0.len() == 0 {
// this entity has completed it's animation
commands.entity(entity).remove::<PathAnimator>();
continue;
}
ev_wait.send(super::GraphicsWaitEvent);
let target = *animator.0.get(0).unwrap();
let d = (target - transform.translation).length();
if d > POSITION_TOLERANCE {
transform.translation = transform.translation.lerp(
target,
PIECE_SPEED * time.delta_seconds()
);
} else {
// the entity is at the desired path position
transform.translation = target;
animator.0.pop_front();
}
}
}
It is not too different from it's predecessor. The main change is obviously the processing of the path, node by node. We also remove the PathAnimator
component from it's entity - when the path is exhausted. Otherwise I think it's nothing we have not seen so far.
Finally we should register our new graphics systems in our app. Before we do that let's just fix one more issue. In our approach to a turn-based game it's crucial that we execute systems in a rather strict order. For example, if our tick
logic is fired before the animation handlers, it might not know that it should wait with the game progression (there will be no event in the world yet). Similarly, if we want to animate the actions, all the graphics
module processing should ideally happen after the action logic.
Therefore it would be a good idea to apply somewhat more rigid structure for our TurnUpdate
scheduling. Let's then create a system set that would contain all our major stages:
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
pub enum TurnSet {
Logic,
Animation,
Tick
}
Since it's scope is rather global, I am going to put it inside of the top-level states.rs
file.
Let's configure it's order:
app.configure_sets(
(TurnSet::Logic, TurnSet::Animation, TurnSet::Tick)
.chain()
.in_set(OnUpdate(GameState::TurnUpdate))
)
I've used the plugin of the manager
module for this configuration - as it is generally meant to control the game flow.
We could maybe name the set stages differently. Like Animation
could actually be called Presentation
or something - as it might also involve playing sound effects and so. For now I am going to keep it this way though, as I think it's more understandable.
Now we can finally register our animation systems within the set:
impl Plugin for GraphicsPlugin {
fn build(&self, app: &mut App) {
app.add_event::<GraphicsWaitEvent>()
.add_startup_system(assets::load_assets)
.add_systems(
(
pieces::walk_animation,
pieces::path_animator_update,
).in_set(TurnSet::Animation)
)
.add_system(pieces::spawn_piece_renderer)
.add_system(tiles::spawn_tile_renderer);
}
}
Do the same with the tick
in the manager
module (consult the repo if needed).
Since we already use system sets in the actions
module it's enough to nest those in the appropriate TurnSet
stage:
// in actions/mod.rs
.configure_sets(
(ActionSet::Planning, ActionSet::Late)
.in_set(TurnSet::Logic)
)
Now after all this hard work, we can run our game again....and nothing new is going to happen (if all goes well that is!). The units should still move as they did. However implementing the attack animations should be a breeze now :)
Since most of the solutions we created above are pretty generic, it should actually be enough to just create a system that would intercept a MeleeHitAction
and spawn appropriate PathAnimator
on the entity:
pub fn melee_animation(
mut commands: Commands,
query: Query<&Position>,
mut ev_action: EventReader<ActionExecutedEvent>,
mut ev_wait: EventWriter<super::GraphicsWaitEvent>
) {
for ev in ev_action.iter() {
let action = ev.0.as_any();
if let Some(action) = action.downcast_ref::<MeleeHitAction>() {
let Ok(base_position) = query.get(action.attacker) else { continue };
let base = super::get_world_position(base_position, PIECE_Z);
let target = 0.5 * (base + super::get_world_vec(action.target, PIECE_Z));
commands.entity(action.attacker)
.insert(PathAnimator(VecDeque::from([target, base])));
ev_wait.send(super::GraphicsWaitEvent);
}
}
}
What we do here is very similar to the walk_animation
. The only difference is actually a two-node path. You might notice that for a swifter animation I've decided the units would go only halfway into the opponents tile.
If we register the system we can play the game again and finally see the attacks!
That's it for today's part. See you next time :)