enemy AI controller, part #8 of godot-roguelike series
Thu 22 December 2016 2D Godot roguelike Unity3D , 0 comments

disclaimer

Although I have quite a bit of programming experience in high level languages, I may not be qualified to teach you anything about game development as I am learning myself and I probably do not have a sense of how code should be written just yet. So please keep in mind that I am very new at this as well and don’t take it as the definite way to do it. It is only my way

overview

project files

Before you continue make sure you have the project files from the last part as you will need them here if you want to follow along!

This part is probably the most interesting in this series since we’ll be dealing with AI and a few other more advanced concepts. We’ll go through:

  1. being happy that we set up the project the way we did, with FMSs! Yes! And this is no trivial matter, We’ll see later how easy it is to create the functionality for the AI exactly because of this planning!
  2. the dumb AI controller, basically introducing the equivalent IdlePlayer state for the enemy
  3. a bit about coroutines, what they are useful for and how to use them in practice
  4. the game loop, which is built on top of coroutines

overview Fig. 01. AI! Finally. Though a pretty dumb one at best…

Just a quick note about what we need to do in this part, before we dive in:

  • we need to (finally) attach a proper node hierarchy for the enemy AI as well, just like we did with the Player node
  • remove all of the hard-coded transitions and rework them to take AI into account
  • allow AI to attack Player & the other way around
  • build the game loop with coroutines with the AI taking action every second turn

We’ll have to go through a bit of nitty gritty stuff, but at the end we’ll have enemies to play with! So let’s get to it.

proper enemy node hierarchy

We’ll go over some nice few tricks here with this one so pay attention. Open TileSet.tscn if it isn’t already opened.

Now, the enemy AI will have exactly the same structure as the Player node and we already have Area2D properly set-up. The only thing missing are the RayCast2D nodes and the proper group setting which we’ll need in order to distinguish between player and enemy inside of the Actor.gd script.

editable children Fig. 02. Menu button for external scene files.

As you remember, the Player node is actually an external scene which is imported inside the TileSet hierarchy. If you press the button highlighted in Fig. 02 you’ll see a menu and at the very top, if you choose Editable Children, the Player node will expand to include the orange nodes. These are the nodes inside of the external files and we can modify them directly here as we please which is pretty cool! There’s only one catch though, we can’t add nodes under any of the orange nodes, which kind of makes sense, since we’re trying to operate on an external file. So then, we want to somehow copy the RayCast2D nodes from the Player scene to our enemy nodes. We can’t just duplicate them and drag them over to the right places as we did before, because of this limitation.

Luckily we can still do it pretty easily. Let’s start with Enemy1, right click on the node and from the menu choose Merge From Scene. You’ll be presented with a window, press the top right button with the tree dots to bring the file browser up. Navigate to Scenes and double click on Player.tscn. Next double-click on RayCast2DRight and voila, the node appears under Enemy1 all set-up and ready to use. This saves up a bit of time since everything (well almost everything) on the RayCast2D node is already set, like the name, Cast To property, etc. Now do the same for the remaining RayCast2D nodes. Do this for Enemy2 as well. Refer to Fig.03 to see this done visually for Enemy1. Unfortunately there’s no way there’s no way to do merge multiple nodes at the same time so this has to be done one by one.

merge from scene Fig. 03. One way of copying the RayCast2D nodes from the Player scene.

Next, add Enemy1 and Enemy2 to group enemy, just like Player was added to the group player. If you remember, this is done by selecting the appropriate node and going to the Node tab (near the Inspector tab) selecting the Groups button and adding the appropriate group with Add. I won’t go over this again, well, more than this.

Finally we need to set appropriate physics groups for the RayCast2D to interact correctly with the other elements, such as obstacles, player etc.:

  1. first select the Area2D nodes under Enemy1 and Enemy2 (with CTRL + Click) and in the Inspector make sure the Collision Layers are set only on the third layer. If they’re not, set them to this third layer
  2. select all of the RayCast2D nodes under both Enemy1 and Enemey2 and tick layers 1, 2 and 3 in the Layer Mask property from the Inspector

The AI needs to be able to interact with the player, obstacles, outer walls but also among themselves. This is a problem because the RayCast2D objects interact with physical layer 3 (as we set it up in step 2. above), but the Area2D of the enemies is also under physical layer 3. So what this means is that the RayCast2D objects under Enemy1 will interact with the Area2D under the same Enemy1 node. This is unwanted, and luckily it’s very simple to fix since we can assign (from code) collision exceptions for the RayCasts2D nodes as we’ll see later when we get to the code.

Let’s do one final thing before moving on to the code. Right click on Enemy1, select Save Branch As Scene from the menu and place it under the Scenes folder. You’ll notice that Enemy1s hierarchy has disappeared and that a new scene was created: Enemy1.tscn. Do the same for Enemy2 to keep things neat. But with this step, you’ll notice that the group information on these nodes has also disappeared! Time to readd them ;)

coding the AI and game loop

We’ll have to make a bunch of changes here and there because all in all this is pretty much what we want to achieve:

  1. have the AI enemies actually move around the map on their own
  2. characters (AI + Player) can attack (this includes bushes too) and be attacked
  3. create a tun base mechanism

Let’s start by adding in the brain for the AI. This will be done in the IdleEnemy state, just like, the player would think while inside of the IdlePlayer state, so will the AI do in IdleEnemy.

Open Game.tsc scene if it isn’t opened yet, and under the States node create a new node of type Node and call it IdleEnemy. You should know the drill by now :)

Now attach a script to this new node, save it under Scripts/States/IdleEnemy.gd and add the following in it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
extends 'Base.gd'

const EPSILON = 1e-8

func enter(entity):
    var delta_xy
    var player = entity.get_node('../Player')
    var player_pos = player.get_pos()
    var self_pos = entity.get_pos()
    if abs(self_pos.x - player_pos.x) < EPSILON:
        if self_pos.y < player_pos.y:
            delta_xy = entity.UNIT_DOWN
        else:
            delta_xy = entity.UNIT_UP
    else:
        if self_pos.x < player_pos.x:
            delta_xy = entity.UNIT_RIGHT
        else:
            delta_xy = entity.UNIT_LEFT
    var state = self.__parent.get_node('Moving')
    state.delta_xy = delta_xy
    entity.transition_to(state)

We’ll need to use a very tiny number for our floating point comparisons. That’s what EPSILON is for.

It isn’t a very good idea to compare floating point numbers (say a and b) with a == b. Instead we should be using abs(a - b) < EPSILON, where EPSILON is a very tiny number. The reason being that you might expect a and b to be equal, but due to very very slightly rounding errors in computations, you might end up with very very small differences in the numbers and the == test would fail. The computer has limited memory after all and we can’t have infinite precision so that’s the reason this is important. And don’t underestimate this! It can make a pretty huge difference when you’re least expecting!

Let’s go over the code, it’s pretty simple (I’ll just go over the important bits):

  1. line 7: get the Player node, as you remember, the Player is at the same level as the AI, so we basically have to go one step back, to the parent node (the .. part) and then go back down to the Player node
  2. line 10: verify if the Player and AI is aligned on the X axis and if yes, then depending on the Y position, store the unit move inside of delta_xy. Remember that UNIT_* are unit Vector2 that point in certain directions, relative us, the developers. See Scripts/Actors.gd for a reminder
  3. line 15: in the else branch, the Player and AI are misaligned on the X axis so we’ll store the move on the X direction.

So the above AI will always favor the movement on X first and then on Y, if it gets aligned with the Player. This is a pretty dumb AI, it doesn’t care about obstacles or anything of this kind, but it does the trick for this simple mini-game. It’s beyond the scope of this tutorial to go into more advanced AI concepts.

correcting all of the states

Moving on, we’ll need to apply some changes to each of our states because for now, every time a turn is finished, the state goes back to IdlePlayer even though we need the AI to perform tasks. In a way this will simplify things a bit.

Let’s start off with Chopping.gd. Open up the script and modify the update() function to:

func update(entity, delta_time):
    self.__time += delta_time
    if self.__time >= self.__time_total:
        if entity.is_in_group('player'):
            entity.energy -= self.energy_cost
        entity.transition_to(self.__parent.get_node('Inactive'))

Pretty neat hm? We don’t need that state_name variable since we’re just transitioning to the Inactive state once the action is done.

That’s it for this state, let’s move on to Inactive as this is super simple as well. In the enter() function, just append the following:

entity.play('idle')
entity.emit_signal('turn_end')

When moving to the Idle state we’ll have to reset the animation to the idle animation. Otherwise the sprites will be stuck in some weird positions, say for example after Chopping, they will be stuck in the last frame of the chopping animation. This means that the entity.play('idle') from the IdlePlayer state (inside of enter() function) can be removed, it’s harmless so you don’t have to worry about it, but I like being thorough so I’ll remove it since it’s already taken care of in the Inactive state now.

The emit_signal part will be covered later when we go over the game loop and the Actor.gd modifications to take the AI into account.

Finally there’s the Moving state. Let’s start with the easy one, modify update() like so:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func update(entity, delta_time):
    var delta_xy_px = self.delta_xy * self.__board.tile_collection.tile_size
    var pos = self.__pos_start.linear_interpolate(self.__pos_start + delta_xy_px, self.__time_elapsed/self.duration)
    if self.__time_elapsed > self.duration:
        pos = self.__pos_start + delta_xy_px
        if entity.is_in_group('player'):
            entity.energy -= self.energy_cost
        entity.transition_to(self.__parent.get_node('Inactive'))
    self.__time_elapsed += delta_time
    entity.set_pos(pos)

This is a very simple modification, just like before we’re just removing the state_name variable since we won’t need it any more and just transition to the Inactive state once the turn is over. Now, the enter() function becomes this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func enter(entity):
    entity.set_process_input(false)
    entity.set_fixed_process(true)
    self.__time_elapsed = 0
    self.__pos_start = entity.get_pos()
    if entity.ray_casts[delta_xy].is_colliding():
        var collider = entity.ray_casts[delta_xy].get_collider()
        var state_name
        if (entity.is_in_group('player') and (collider.is_in_group('obstacle') or collider.parent.is_in_group('enemy'))) or \
           (entity.is_in_group('enemy') and (collider.is_in_group('obstacle') or collider.parent.is_in_group('player'))):
            collider.take_damage(entity.damage)
            state_name = 'Chopping'
        elif entity.is_in_group('player'):
            state_name = 'IdlePlayer'
        elif entity.is_in_group('enemy'):
            self.delta_xy = Vector2(0, 0)
        if state_name:
            entity.transition_to(self.__parent.get_node(state_name))
    else:
        self.__sample_player.play('footsteps%02d' % int(rand_range(1, 3)))

First off, there’s the if statement on line 9. This is more complex because there are more specific conditions to be met in order to transition to the Chopping state. There are a few cases:

  1. if entity is the Player and if the tile we want to move to is occupied by an obstacle or an enemy then move to Chopping
  2. if entity is the enemy AI and if the tile it wants to move to is occupied by an obstacle or the Player then move to Chopping
  3. if the above fail to resolve truthfully, but the entity is indeed the Player, then transition back to IdlePlayer because this means the Player wanted to (by mistake or not) move on a tile that is occupied by an outer wall. That’s the only plausible case here, considering that one of the RayCast2D nodes is colliding
  4. on the other hand if entity is an enemy AI, then, instead of moving back to IdleEnemy, we just instruct it to move Vector2(0, 0), that is a distance of 0. This will happen if for some reason the AI wants to move through the outer walls

Finally, because there is one case where we don’t set state_name, we’ll have to check if it was indeed set (the if statement on line 17) and if so then transition to that state, otherwise just perform the movement as normal.

making Actor.gd play nice with the AI

So far so good. We set up all of the states and before moving on to the game loop we’ll have to modify Actor.gd so it works as the AI as well.

First of all, set the Script property from the Inspector to point to Actor.gd for both Enemy1 and Enemy2 nodes, in the TileSet scene. Once that is done, let’s modify Actor.gd like so:

  1. at the very top (just after extends AnimatedSprite) add: signal turn_end. This is the way to declare custom signal in Godot. Later, to emit the signal, we’ll just use emit_signal('turn_end') and anyone listening to it will be able to perform necessary actions. It’s super simple! For more informations check the docs on signals
  2. change the onready var __state = self.__states.get_node('IdlePlayer') line to just var __state. We’ll set the state at the beginning of each turn so no need to hard code the IdlePlayer. We’ll also have to take into account the AI so it can’t just be IdlePlayer any longer
  3. add a new variable like so: onready var __area = self.get_node('Area2D'). We’ll need it for the next part, adding colliding exceptions with the RayCast2D. Remember that the RayCast2D nodes will detect collisions with areas under self for the AI, so we’ll need to exclude these areas
  4. in the _ready() function we’ll cycle through all of the RayCast2D nodes under Player, Enemey1 and Enemy2 and add collision exceptions with Area2D under self. The new _ready() function looks like so (also note that we’re setting the energy between scenes only for the Player, all of the enemies will have fixed starting energy):

    func _ready():
        if self.is_in_group('player') and Globals.has('player_energy'):
            self.energy = Globals.get('player_energy')
        for key in self.ray_casts:
            self.ray_casts[key].add_exception(self.__area)
    
  5. finally we’ll need a new function, let’s call it activate():

    func activate():
        if self.is_in_group('player'):
            print(self.energy)
        var state_name = 'Idle%s' % self.get_groups()[0].capitalize()
        self.transition_to(self.__states.get_node(state_name))
    

This last activate() function will be called at the beginning of each turn for each character, be it Player or AI. It’s very simple, it does nothing but set the Idle* (where * is either Player or Enemy) state depending on the type of the character. We’ll also print the Player energy at the beginning of the turn just for some feedback while we develop. The var state_name line :). I’m not going to tell you. You figure it out! :D Remember that we went a bit through string formatting in the previous post. Check the docs, experiment in Godot and figure it out! Have fun!

That’s it for the Actor.gd script. Let’s move on to the good stuff, the game loop.

lies, it’s all lies!

:D I lied. We need to finish modifying bits and pieces first. Open up Obstacle.gd… yes, because it inherits from Area2D.gd which needs to be modified and Obstacle.gd is also affected. Not a big deal though. The only modification that needs to be done is to remove the onready var __parent = self.get_node('..') line at the top and replace all occurrences of __parent with parent. Just use the Search > Replace.. functionality from the text editor.

This needs to be done because of our conventions and the fact that we’ll need to access the parent of Area2D under the characters. To see this, let’s go over the Area2D.gd modifications. I’m just going to dump the whole script here since it’s small and we’d jump from place to place otherwise:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
extends Area2D

export var scale = 1.0
onready var parent = self.get_node('..')
onready var __board = self.get_node('/root/Game/Board')
onready var __actors = self.__board.get_node('Actors')
onready var __sample_player = self.get_node('/root/Game/SamplePlayer')

func take_damage(damage):
    self.__sample_player.play('enemy%02d' % int(rand_range(1, 3)))
    self.parent.energy -= damage
    if self.parent.is_in_group('enemy') and self.parent.energy <= 0:
        self.parent.queue_free()

func _ready():
    self.__set_shape()

func __set_shape():
    var dimension = self.__board.tile_collection.tile_size * 0.5
    self.set_pos(dimension)
    self.get_node('CollisionShape2D') \
        .get_shape() \
        .set_extents(dimension * scale)

So _ready() and __set_shape() are exactly the same. The new parts are at the top and the new take_damage() function. Let’s quickly go over the take_damage() function:

  1. line 10: first we play a feedback sound, here I just used the enemy01.wav & enemy02.wav sounds for all characters, AI and Player just to keep things simpler
  2. line 11: next the damage is registered
  3. line 12-13: and we of course want to remove the node if it’s the AI and if it’s energy level reaches 0. We don’t want to remove the Player node, just the AI, because it would be weird to have the Player just simply vanish

That’s it, it’s very simple! With that said, we have to perform a very simple modifications to the SamplePlayer node from the Game scene. Go find it and modify Polyphony in the Inspector panel from 1 to 2! This is needed in order to play the enemy01/enemy02 sounds at the same time with the chopping sound! Otherwise only the chopping sound will be played. This is basically the number of playable simultaneous sounds.

Now… that game loop.

setting up a proper Board

As you probably remember, we’re dumping all of the tiles under the Board node, the floors, walls,obstacles, characters, everything. We’ll need a way to keep track of the characters and only the characters in order to loop through them. Some more modifications! This time to Board.gd :). Bit first, create a new Node2D node under the Board node in the Game scene and name it Actors. It’s important to be of type Node2D because of a simple problem: all of the objects, tiles, walls etc. are placed after the game starts so they will be drawn after (on top of) the characters, should they be placed under this new Actors node. To solve this, we’ll need a Node2D specific property. Find the Z property in the Inspector panel and set it to 2. This is basically the depth level (on the Z axis) at which a node will be drawn, the draw order if you will. Nodes are drawn from lower Z to higher Z so a node with a higher Z will be drawn on top of nodes with a lower Z.

Alright, Board.gd! Here are the modifications:

  1. at the top get this new Actors node: onready var actors = self.get_node('Actors')
  2. next we’ll need to modify the functions that are adding the tiles so that they use this Actors node. So, find __add_tile() function and modify it to:
func __add_tile(tile, parent, xy):
    var tile_dup = tile.duplicate()
    tile_dup.set_pos((self.perim_thickness + Vector2(1, 1) + xy) * self.tile_collection.tile_size)
    parent.add_child(tile_dup)

Note the new parent argument in the function call and the modification to parent.add_child(tile_dup) instead of self.add_child(tile_dup).

  1. because we modified __add_tile()s signature we’ll need to find it and do appropriate modifications. Find the line self.__add_tile(...) line inside of the __add_other_tiles() function and modify it to: self.__add_tile(tile, parent, xy)
  2. also modify the __add_other_tiles() function call to: func __add_other_tiles(tile_set, parent, count=Vector2(1, 1))
  3. there’s one other __add_tile() under __make_grid() function. Modify it to: self.__add_tile(tile, self, Vector2(x, y))
  4. finally this is the new make_board() function with the appropriate calls to __add_tile() and __add_other_tiles():
func make_board():
    randomize()
    self.__grid = self.__make_grid()
    self.__add_base_tiles()
    self.__add_other_tiles(self.tile_collection.item.obstacles, self, count_obstacles)
    self.__add_other_tiles(self.tile_collection.item.items, self, count_items)
    self.__add_other_tiles(self.tile_collection.item.enemies, self.actors, count_enemies)
    self.__add_tile(self.tile_collection.item.exit, self, Vector2(self.inner_grid_size.x, -1))
    self.__add_tile(self.tile_collection.item.player, self.actors, Vector2(-1, self.inner_grid_size.y))

Note that we’re adding all of the items under self (that is the Board), except for the characters, which we add under self.actors, the newly Board/Actors created node!

finally, no more lies… the game loop!

Before moving on to the nuts and bolts of the game loop, let’s talk a bit about coroutines: in short, coroutines are a way to suspend the execution of a function (breaking out of it) but having giving you the ability to jump back in it at the point where it was interrupted (at the location of the yield keyword). Why is this useful? Coroutines in Godot are explained nicely and simply in the documentation. And for all purposes and intents that’s pretty much all you need to know about them:

  1. they suspend the execution of a function
  2. they can resume the function

Here, we’ll go through a practical example of how to use them in order to create a turn based system. Open up Game.gd, we’ll need to add a bit of functionality to it:

  1. at the top add these two lines:

    var __enemy_turn_skip = false
    onready var __actors = self.get_node('Board/Actors')
    

    They’re pretty self explanatory, in short we’ll need a boolean variable to keep track of every other turn in order for the AI to skip it. This will give an edge to the player, it’s always nice to help a little bit the player isn’t it? And the AI here is also based on zombies, right? They should be slower than the player :). Let’s move on

  2. in _ready() add at the very end: self.start(). Next we’ll write this new start() function which is the meat of the turn-based system.

  3. the actual game loop is this:
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    func start():
        while true:
            var actors = self.__actors.get_children()
            actors.invert()
            for actor in actors:
              if weakref(actor).get_ref() \
                 and (actor.is_in_group("player") \
                      or (actor.is_in_group("enemy") and not self.__enemy_turn_skip)):
                    actor.activate()
                    yield(actor, 'turn_end')
            self.__enemy_turn_skip = not self.__enemy_turn_skip
    

Let’s go over this start() function:

  • line 2: see here? we’re starting with an infinite loop, but down the line we suspend this function execution with the yield statement and it will patientely wait until the turn ends for that particular character! It doesn’t just run in a loop aimlesly, we’re using coroutines to wait for the turn_end signal to fire, after it gets registered it we move on to the next character
  • line 3: this is where we get the children from the new Board/Actors node. That’s why we separated them from the rest of the tiles under the Board node: for easy access and manipulation
  • line 4: because we’re adding the player at the very end of the make_board() function we need to invert the array in order for him to start first! This is one way of doing it, a more effiecient way is to just have the player placed before the enemies in the board. The get_children() function returns an array of nodes in the order found in the scene tree
  • line 5: iterate over all characters
  • lines 6-8: alright, here’s the catch! We added the ability to attack the AI and to remove it from the board (with queue_free). This means that some nodes might be removed while we’re still in the for loop. So there’s a chance that a node, although removed from the board, is accessed in this loop. What to do? Well, unfortunately Godot doesn’t have a proper way of verifying if a node is removed from the scene tree, so the workaround that I found is to use weakref. weakref creates a weak reference to a node (as the name implies) and you then access the node using the get_ref() method on this object. If the node has been removed from the scene tree, then this get_ref() method returns null which evaluates to false so this is the way I discovered to check if a node is still present in the scene tree or not. Maybe some of you have a better idea? If so please let me know in the comment section below. Moving on, the other check is basically for skipping every other turn. If actor is in group player then we just activate it and move on, but if it’s the enemy then we do an additional check on __enemy_turn_skip, if this is false then the turn begins, if not then this node is skipped. It’s pretty simple really. And at the end of every outer loop (the game loop - while true), we invert __enemy_turn_skip
  • line 9: here we activate the current actor. Refer to the the section on AI for details on the activate() function. All it does really is change state from Inactive to IdlePlayer or IdleEnemy depending on the type of actor
  • line 10: this is where the magic happens. The function is suspended at this location and it patiently awaits for the turn_end signal to fire. When it does it just moves on like nothing happened, activating the next actor or jumping to tne next turn if all actors have been exhausted
  • line 11: finally, at the end of every turn we inver the __enemy_turn_skip boolean variable so that the enemy AI skips every second turn

Finally I want to add that it’s your job as the game developer to play with the Energy and Damage variables for the Player & enemey AI in order to get a nice play balance! Since we have these variables exported to the Inspector this is super easy, just open up the TileSet scene, select the appropriate nodes and start playing with the values in the Inspector. This goes for every other hard coded value in our game!

closing remarks

This section of the tutorial series has been very technical with all sorts of refactoring and bits and places to modify. Hopefuly it was still informative enough and interesting! This is also part of the game development process: coming up with solutions and building on top of old code to add functionality. Some times it takes a bit of work, modifications having to be made in many places, even for small projects such as this one.

I hope you saw that taking the time to plan all of this even for a little bit, before starting to work, makes a huge difference. Consider what would have happened if we would have dumped all of the functionality inside of the _input() function instead of building on top of FSMs. It would have been horrible to update!

Next time we’ll wrap everything up by adding a simple UI and by terminating the game when the player runs out of energy!

As always, let me know if you spot any mistakes, if you like it, if you have any other suggestions to improve the tutorial! Thanks a bunch, hope you enjoyed it so far!

project files

Get the zipped finished project for this part with annotated scripts and see if you could follow along! Or if just want to consult them.