In this section, we will add simple AI-controlled enemies to the game.
These enemies will patrol their platform randomly until the player comes nearby. When that happens, they will chase the player to inflict damage on collision. If the player escapes, they will go back to patrolling.
Fair warning: this section is the hardest part of the tutorial and will require the combined use of every skill we've learned so far: flow graphs, state graphs, super units, transitions, macros, custom events and variables. Make sure you understand every previous section before starting, because now that the concepts have been introduced, we will go a bit faster than usual.
When we're done, the final set of graphs will look like this:
Take a short break, stretch your legs, and when you're ready, let's get started!
The first thing we'll do is create a root state machine on our enemy. It will have two states:
Alive: When the enemy is alive most of the logic happens, like patrolling, chasing and damaging. For the first time, this state will be a super state, meaning it will itself be contain a state graph. Previously, we only used flow states, where the child graph was a flow graph.
Dead: When the enemy is killed, it should slowly spin downwards then disappear. This will be a simple flow state that we will implement in the next section, when we add projectiles to kill enemies.
To create the root state machine:
Open the Level3 scene
Select the Enemy object
Add a new State Machine component
Create a new macro for it called Enemy
Apply the changes to the prefab
In the root state machine:
Delete the default Start state, because it's a flow state and we need a super state
Create a new Super State, open it and change its title to Alive
Right click the Alive state and choose Toggle Start
Create a new Flow State, open it and change its title to Dead
Add a transition from Alive to Dead.
At this point, root state graph should look like this:
Inside the Alive state graph, we will need three child states:
Patrol: When the player is away and the enemy is patrolling the platform randomly. This will be a Super State, because the patrol will include different states as well. Does this feel like inception already? :)
Chase: When the player is nearby, the enemy chases it. This will be a regular flow state.
Damage: In parallel to patrol and chase, the enemy can inflict damage when colliding with the player. This will be a regular flow state as well.
Parallel here means that there will be two "systems" of states in the alive state graph that will run at the same time: one for movement (Patrol or Chase), and one for damage (just one state). To do that, we only have to define multiple start states: a powerful feature unique to Bolt. Our two start states will be Patrol and Damage.
Add a super state called Patrol
Add a flow state called Chase
Add a flow state called Damage
Toggle start on Patrol
Toggle start on Damage
Add transitions back and forth between Patrol and Chase
Inflicting damage to the player is really easy now that we have already created all the health system in the previous section. As a reminder, we added a Damage custom event to the PlayerHealth state graph that took one argument: the amount of damage inflicted.
Open the Damage state.
Now, all we have to do is use our On Collision With macro to trigger that event and inflict 1 heart of damage:
That's it! If you test your game now, the enemy should inflict damage to the player, which in turn should become temporarily invulnerable:
Notice how keeping our graphs DRY and organized made this very simple: the collision code is fully handled on its own, and the player is responsible for its own health and damage system. The enemy, a separate entity, only has to trigger a single event. This is an example of how nesting and events in Bolt allow you to create a robust game architecture.
Let's take a moment to plan ahead: we know our enemy has to walk in multiple places. It has to walk left and right when patrolling a platform, and walk towards a player when chasing. That's 3 places already we're we'll need a walking graph. At this point, you should see where this is going: we'll keep everything DRY and create a reusable macro! Create a new flow macro asset called EnemyWalk. This graph will be similar to movement on the player, but not exactly the same.
If it is equal to zero, the enemy should stay idle
Note that this direction isn't a speed: for example, if the direction is -5, the enemy should go left exactly as fast as if the direction had been -1. To do that, we will first normalize the direction before multiplying it by the speed. Normalizing keeps the sign (+ / -), but with an value of 1, unless the value was zero, in which case it leaves it as is. In practice, it means we calculate movement like this:
Notice we use a Speed object variable again. Because we want the player to be a bit faster than the enemies, we will add a speed variable with a value of 2 to our enemy game object and apply the changes to the prefab:
Like we did for the player earlier, we'll flip the enemy sprite by setting the X axis of the scale equal to the direction, but only if the direction isn't zero. If it is zero, meaning the enemy is idle, we'll skip flipping altogether and keep the last scale.
You'll notice that this time the Equal unit takes numbers and allows for an inline value for B. This is because Numeric is checked in the graph inspector for the unit:
It works is exactly the same as using the following nodes, but a bit more conveniently:
Sometimes, the enemy should randomly change its mind about where it's going. It might be idle and decide to walk, or change direction, or stop walking. We will call this the "change mind" transition, and we'll create a single reusable macro to handle all of them.
Create a new flow macro called EnemyChangeMind, and set it up like this:
Random Range is located under Unity Engine > Codebase > Random
Wait For Seconds is located under Time
Trigger State Transition is located under Nesting
This simple transition randomly waits between 1 and 3 seconds to trigger. That's it!
Remember to give it a title so it's easier to understand what it does:
Next step: add it to the graph. An enemy can change its mind from any state of a patrol to any other, so we need back and forth transitions between each state in our triangle:
Select each of these transitions
Set its source to Macro
Choose the EnemyChangeMind macro
The inspector for each transition should then look like this:
And the state graph should look like this:
If you test now, the enemy should start patrolling randomly... but it won't stop when it reaches the edge of its platform!
Let's fix that by adding another type of transition when the enemy reaches the edge of its platform.
To determine whether the enemy has reached the end of its platform and should switch direction, we'll use a technique similar to how we detected whether the player was grounded earlier: we will use a downwards circle cast. However, this time, we'll add a small offset in the direction of the enemy, so that our circle cast predicts ahead of time if the end of the platform will be reached:
If that ground check with an offset returns false, we'll know that the enemy soon won't be grounded, and therefore has reached the edge of its platform.
5.2.1 Add parameters to the ground check
Since we already have a GroundCheck macro, we will only modify it to add some parameters. Open the GroundCheck macro we created earlier for the player controller:
Add a new Input unit with three parameters:
Offset (Vector 2, default 0,0): the offset to add to the origin of the circle cast from the object's position
Radius (float, default 0.3): the width of the circle cast
Distance (float, default 1.1): the distance to check downwards
We could leave radius and distance at constants, but while we're at it, why not turn them into arguments to make our macro really flexible?
Then, connect the new input ports to the circle cast node, using an Add to apply the offset:
5.2.2 Create a reach edge transition macro
Create a new flow macro called EnemyReachEdge for our transition. Set it up so that the transition triggers if the enemy is not grounded:
Then, we'll need to calculate the offset parameter so that it points in the direction the enemy is facing. We can use the X axis of the scale to know that, because we flip the enemy in our movement code. We then multiply it by 0.5 units forward, which is a small offset of about half the enemy's width.
Then, just connect the offset vector to the offset port of our super unit:
5.2.2 Add the reach edge transitions to the patrol state
Finally, we just need to add this transition to our patrol graph. Logically, an enemy should only ever reach an edge while walking either left or right, and when it does, it should immediately go in the opposite direction. Therefore, we'll only add our new transition back and forth between the Walk Left and Walk Right states:
If you test your game now, the enemy should properly avoid falling off the platform:
Now that our patrol state is complete, we'll implement the Chase state:
When chasing, the enemy should walk towards the player. Thanks to our EnemyWalk macro, this is fairly easy: we only need to calculate the direction from the enemy to the player and pass it to the super unit.
Using simple vector maths, we subtract Player Position - Enemy Position to get our direction vector, and take the X component from it. Because the enemy walk macro takes care of normalizing the value to +1 / -1, we can pass that directly: