Site icon The Walrus Game Project

Project Delve Part 2: Unit Movement

Free Movement

Today things start to get more exciting — instead of seeing just a static screen with no stimulating action, now we’re going to add movement! (I imagine you are all gasping in awe.)

So because I’m a visual guy and like to get something good looking that I can see out there quickly, I’m going to start this part by creating a new scene object in Godot and adding a cute little character sprite to it. I’m also going to instance the TileMap scene we created last time within this new scene, which I’m calling MovementDemo.

I’m dragging this sprite fella from the “frames” directory. If you copied the code from the repository during the last post then you won’t have this directory (it was much too large to upload responsibly). However, if you run the crop.py script, “frames” will be generated from the spritesheet in the 0x72_DungeonTileset directory.

Because I know that I want to control multiple things about a unit from a central place, I’m also going to create an empty Node2D node and use it as the parent for the sprite I just dragged into the scene. I named the new parent node Character for reference.

Now let’s write some code! Let’s create a new script called CharacterController and assign it to the Character node, then dive into the code editor.

My first order of business is to figure out how to move the character! (I’m still fairly new at this, remember?) Fortunately when you create a script through the Godot editor it will be generated with a template that helps newbies like me. The _process() function seems like the thing I’m looking for, since it runs each frame. For now I’m just going to test out basic movement through the arrow keys on my keyboard.

func _process(delta: float):
    var movement := Vector2.ZERO
    if Input.is_key_pressed(KEY_RIGHT):
        movement += Vector2.RIGHT
    if Input.is_key_pressed(KEY_LEFT):
        movement += Vector2.LEFT
    if Input.is_key_pressed(KEY_UP):
        movement += Vector2.UP
    if Input.is_key_pressed(KEY_DOWN):
        movement += Vector2.DOWN

    # The reason to multiply movement by delta is so that we
    # can get a frame-independent movement speed. This is common
    # practice for probably 99.9% of all games.
    position += movement * delta

Now if we go back to the editor and run the scene we’ll notice that our little fella is indeed moving when the arrow keys are pressed, but veeeeeery slowly. (Must be all that armor.) Now I could go back into the script and modify the final line of code there to this:

position += movement * delta * 100

but that would mean I would need to open up the script every time I want to tweak the character’s speed, which could potentially be quite often. And what if I want to have multiple characters use this same script for movement, but have different speeds? Nope, this calls for a different approach.

In Unity this could be achieved through declaring a public member variable (or a serialized private one, for all you secretive people out there), which would allow you to edit the variable in the editor and on a per-instance basis. Godot provides a similar functionality through their export keyword. Let’s add an exported variable in the CharacterController called character_speed, and set the initial value to 100.

export(float) var character_speed = 100

Now when we check in the editor we’ll see the variable show up in the inspector panel and we’re able to modify it from there!

Finally, we need to use it in the script for the value to actually mean anything. Let’s modify that final line of movement code again, but replace the magic number with our new variable.

position += movement * delta * character_speed

Now when we run the scene the character moves much more quickly! Hooray for progress! However, we didn’t set up a grid layout last time for nothing. Ideally we want the units only to move in orthogonal and 45° diagonal directions. That leads us to the next section, pathfinding.


Pathfinding

I started by researching the A* (pronounced A-star) algorithm for pathfinding. I remembered reading an article a while ago when I was going about writing my first pathfinding algorithm. It helped a lot for me to wrap my head around the concept initially, and I still find it’s a helpful reference to have when I inevitably have questions about it. As a matter of fact, that blog was one of the inspirations for creating this blog! But I digress…

While researching I discovered that Godot actually already has an A* implementation, which is awesome! Now, I’m a guy that likes to do things from scratch… but I’ve already implemented a pathfinding algorithm in one of my past projects, and I’m also a fan of not reinventing the wheel. So for this project we’re going to utilize the tools available!

In order to use Godot’s AStar class I needed to figure out how it works. Eventually after watching a few videos and reading some code and documentation, I was able to boil it down to the basic steps you need to follow in order to use it correctly.

  1. Populate the AStar object with all of the points you want to be able to traverse.
  2. Tell the AStar object, for each point registered in the previous step, which other points the current point is connected to.
    • This also brings a fun use case where you could theoretically have two points that are visually pretty far apart. If you tell AStar that they’re adjacent, then you have effectively created a grid-wormhole! It’s an idea that sounds pretty fun to me, so I might try to see how to work it into the game later down the line.
  3. Ask the AStar object for a path from point A to point B!

When you break it down to those three steps it becomes much easier to work with.

So with this knowledge I was able to write a Pathfinder script that takes a TileMap object and can give you an optimal path from one tile to another!


Contained Movement

We now have a basic movement script and a working A* pathfinding algorithm, all that’s left is to put them together! Let’s go back into the CharacterController class and get a reference to the Pathfinder script that’s sharing a scene with our cute lil’ guy.

var pathfinder: Pathfinder

func _ready():
    # The way to get a sibling node is to first call
    # get_parent(), then get_node() with the desired node path
    pathfinder = get_parent().get_node("TileMap/Pathfinder")

Now we can create a function that will use the pathfinder.find_path() method and send our character a-runnin’!

func move_to(loc: Vector2):
    var path = pathfinder.find_path(position, loc)

Let’s also modify the original GridLayout class to direct the character whenever a tile is clicked.

var character

func _ready():
    character = get_parent().get_node("Character")


func _input(event):
    if event.is_class("InputEventMouseButton") and (event as InputEventMouseButton).is_pressed():
        _on_grid_click(event)


func _on_grid_click(event: InputEventMouseButton):
    if not character:
        return
    character.move_to(event.position)

Now everything is set up for when we press “play” on our scene! …Everything except the actual following of the path. This presents our next challenge because we don’t want our character to move from their starting position directly to the final position on the path — that completely defeats the purpose of having the path there in the first place! But how can we move the character from each point to the next point, sequentially, until arriving at the target? Enter the next cool thing about Godot:

The “yield” keyword

If you’re familiar with Unity you’ve probably heard about coroutines. It’s the Unity way to execute a function in parallel with the main game loop; essentially a non-blocking function. In Godot, every function can be a coroutine without doing anything more than adding the yield keyword inside your function! Essentially, you can pause the function execution until a certain condition is met, without blocking the main game loop thread. In our case this is a perfect solution to the problem of moving to certain points sequentially. We simply need to set a target point, yield execution until we reach that target point, then check if there are any more points left in the path. If there are, we set the next point in the path as our target point and yield execution until that point is reached. If there are no more points in the path, then we have reached our goal!

In practice, this is how our move_to() function is going to look:

var current_destination
signal _arrived_at_path_point

func move_to(loc: Vector2):
    var path = pathfinder.find_path(position, loc)
    _follow_path(path)

func _follow_path(path: PoolVector2Array):
    while not path.empty():
        current_destination = path[0]

        # We want to remove the first item in the path so that
        # when the path is empty we can be sure we're at the
        # final destination
        path.remove(0)

        yield(self, "_arrived_at_path_point")

        current_destination = null

The way we’re using yield here is we’re waiting until we get the signal _arrived_at_path_point emitted from ourself (self). Signals are a beast we can cover another day, but in short they’re events anybody can subscribe to and listen for.

The final code we need to modify in our CharacterController script is the _process() function. Currently it holds the logic listening for keyboard input. We’re going to change that so that the character only moves when it has a current_destination to walk to.

func _process(delta: float):
    if current_destination:
        position = position.move_toward(current_destination, delta * character_speed)

        # The Vector2.is_equal_approx() method is desirable in this situation
        # because it's a bad idea to compare floating point values due to the
        # nature of how computers store those values. This just checks if they're
        # approximately equal, and if so returns true.
        if position.is_equal_approx(current_destination):
            # This triggers the signal we were waiting for in the yield statement
            emit_signal("_arrived_at_path_point")

Pulling it All Together

Whew! That was a lot! But we did it, and if we play the scene now we can see that it’s working!

Originally I had also planned on adding different tile types like water tiles and obstacles, but this post has already gone on for quite a while so I think that will have to wait for a follow-up post.

If you really want to see how the pathfinding works when it’s not just a square grid you can remove some tiles from the TileMap. When calculating the path, the Pathfinder script does not take into account missing tile spaces.

All of the code up until this point in the project can be found here. Until next time!

Exit mobile version