© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2023
C. PittProcedural Generation in Godothttps://doi.org/10.1007/978-1-4842-8795-8_11

11. Paths and Path Followers

Christopher Pitt1  
(1)
Durbanville, South Africa
 

We’ve discussed moving the player with keyboard and mouse input. Depending on the game, these might allow for too much freedom. What if we want to make experiences that are more “on rails”?

In this chapter, we’re going to explore the ins and outs of path-based movement. We’ll create a basic path follower, followed by more complex chain pathing.

Defining Paths

Let’s create a new experiment, called PathsExperiment, and set it as the experiment that loads on PlayScreen. We’ll populate it with a Path2D node, a PathFollow2D node, and a ColorRect node for visibility:

A screenshot of the scene page has an option for path 2 D highlighted. The right side has an output image for the paths experiment.

Setting up to follow paths

You can draw the path in the same way that you’d draw a collision polygon. Select the Path2D node and click the Add Point button to start drawing path points.

If you were to create a PathFollow2D node, not as a child of a Path2D node, you’d see an error. This is because PathFollow2D nodes only work as children of a Path2D node.

PathFollow2D nodes have a Progress property, which represents the distance along the path that the follower has moved. It starts at zero, but as you increase that value, you’ll see the follower move along the path.

They also have a Progress Ratio property that represents the distance along the path they have travelled, but as a value between 0 and 1. As you change one of these sliders, the the other will change to match.

As you can imagine, it’s possible to tween the progress values.

Moving Along the Path

Imagine we want to allow the player to click somewhere near the path and have the player character move to the nearest point along the path.

To do this, we’d need to first work out what the nearest point is. We’d need to be able to tell whether it was “forward” or “backward” path movement. Then, we could animate the movement until the player character was around the path point.

Let’s figure out the first bit:

This is from experiments/paths_experiment.gd
extends GameExperiment
@onready var _path := %Path2d as Path2D
var debug_points := []
func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseButton:
        if event.is_pressed():
            var nearest_point = get_nearest_point(event.position)
            debug_points.append(nearest_point)
            await get_tree().create_timer(5.0).timeout
            debug_points.erase(nearest_point)
func _process(delta: float) -> void:
    queue_redraw()
func get_nearest_point(target : Vector2) -> Vector2:
    return _path.curve.get_closest_point(target)
func _draw() -> void:
    for point in debug_points:
        draw_circle(point - global_position, 10, Color.BLACK)

I’ve chosen to draw little black dots at the nearest point along the path. When the player clicks on the game screen, we loop through the points of the path. These are not the ones that we used to draw the path, but rather the calculated points in between.

Notice we’re using scene unique names for the Path2D and PathFolow2D nodes.

We add these to an array, and after a five-second delay, we remove them again. Adding _process and _draw methods allows us to draw the dots. The drawing canvas is cleared every frame, so we don’t need to do that ourselves. Queue_redraw is a built-in function that forces Godot to call _draw so that our dots show up.

The results are quite pleasing:

A screenshot of the experiments page has a few black dots, a line in a zig-zag pattern, and a square box.

Drawing dots at the closest points to our clicks

The dots are offset a bit, but that’s to do with transformations, and it’s not a huge deal. Now that we can get the nearest point and actually see it, we can move toward it. Let’s define a speed variable and put the movement code in the _process method:

This is from experiments/paths_experiment.gd
extends GameExperiment
@onready var _path := %Path2d as Path2D
@onready var _path_follow := %PathFollow2d as PathFollow2D
var debug_points := []
var nearest_point : Vector2
var speed := 200
func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseButton:
        if event.is_pressed():
            var point = get_nearest_point(event.position)
            nearest_point = point
            debug_points.append(point)
            await get_tree().create_timer(5.0).timeout
            debug_points.erase(point)
func move_to_point(target : Vector2, delta : float) -> void:
    var points := _path.curve.get_baked_points()
    var target_i : int
    var current_i : int
    for i in range(points.size()):
        if points[i].distance_to(target) < 5:
            target_i = i
        if points[i].distance_to(_path_follow.position) < 5:
            current_i = i
    if abs(current_i - target_i) > 5:
        if target_i < current_i:
            _path_follow.progress -= delta * speed
        else:
            _path_follow.progress += delta * speed
func get_nearest_point(target : Vector2) -> Vector2:
    return _path.curve.get_closest_point(target)
func _process(delta: float) -> void:
    queue_redraw()
    move_to_point(nearest_point, delta)
func _draw() -> void:
    for point in debug_points:
        draw_circle(point - global_position, 10, Color.BLACK)
The main change is the addition of the move_to_point method, which uses the latest nearest_position as a target to move toward. We loop through the path’s points until we find
  1. 1.

    The current follower’s point index

     
  2. 2.

    The target point’s index

     

Knowing these, we can tell if the follower needs to move forward or backward. We can then increase or decrease the follower’s offset with delta and speed.

Launch the game and click around. It’s wonderful to see the follower try to get as close to your click as possible while sticking to the path.

Moving Between Paths

Following a single path is already cool, but I want to take things a step further. Imagine we want to build maps out of many different paths. In order for a follower to move throughout the whole level, we’d need to allow them to switch between different paths.

A representation of the restaurant game has four rectangular blocks, a red dot for customer, a yellow dot for waiter, and a few directional arrows.

Moving along multiple paths

In this situation, we’d need to be able to link paths together and decide when we want to switch to a new path. The simplest way to link them together would be to create a Path2D subclass that has a property for “connected paths”:

A screenshot of a window has tabs for the scene, connected path 2 D, file system, and paths experiment highlighted. The right side has a few lines of code for the connected path 2 D.

Path2D subclass

We can replace our existing Path2D node with one of these and add a couple more. They have a new Connected Paths property that we can use to create the associations between them. If you have trouble drawing separate paths for each ConnectedPath2D node, remove all the points and make the Curve properties unique to each ConnectedPath2D.

A screenshot of a window has the option for connected path 2 D 2 highlighted. The middle panel has an image for the paths experiment. The right side has the inspector tab, and connected path 2 D G D highlighted.

Connecting paths together

Now, our code needs to change. It’s going to be simpler to delete all the code in experiments/paths_experiment.gd and start over.
  1. 1.

    Instead of finding the closest point on a single known path, we need to look for the closest point on the closest path.

     
  2. 2.

    We can calculate which paths to take and how long to travel along them to get to that point.

     

Let’s add a method to address the first task:

This is from experiments/paths_experiment.gd
extends GameExperiment
func get_nearest_path(target : Vector2) -> ConnectedPath2D:
    var nearest : ConnectedPath2D
    var distance : float
    for node in get_children():
        if not node is ConnectedPath2D:
            continue
        var point = node.curve.get_closest_point(target)
        var point_distance = point.distance_to(target)
        if not distance or point_distance < distance:
            nearest = node
            distance = point_distance
    return nearest

This new method looks through all the ConnectedPath2D nodes in the scene and finds the one with a point that is closest to the click. This gives us the target path we want to travel to, so we need to work out a way to get onto that path.

Next, we need to find a list of points that will take the PathFollow2D node from the path it is on to the path closest to the click target.

This happens in a few steps:
  1. 1.

    We look through each of the connected_paths set for the starting path.

     
  2. 2.

    If one of them is the end (we’re right next to the path we want to be on), then we get the coordinates between start and end.

     
  3. 3.

    If not, we add start and end nodes to the sequence array so that they are part of the journey the PathFollow2D node will take.

     
  4. 4.

    For each of the sequences (there can already be multiple if the starting path was connected to multiple other paths), we get the last element and loop through its connected paths.

     
  5. 5.

    If we get a connection that is already part of the sequence we’re inspecting, we ignore it. This is to prevent us from going backward.

     
  6. 6.

    We carry on with this process until we find the path closest to our target.

     
This is from experiments/paths_experiment.gd
func get_waypoints(start : ConnectedPath2D, end : ConnectedPath2D) -> Array:
    var sequences := []
    for connected in start.connected_paths.map(func(p): return start.get_node(p)):
        var pair = [
            start,
            connected,
        ]
        if connected == end:
            return add_coordinates_to_waypoints(pair)
        sequences.push_back(pair)
    while sequences.size() > 0:
        var sequence = sequences.pop_front()
        var last = sequence[sequence.size() - 1]
        for connected in last.connected_paths.map(func(p): return last.get_node(p)):
            if sequence.has(connected):
                continue
            var appended = sequence + [connected]
            if connected == end:
                return add_coordinates_to_waypoints(appended)
            sequences.push_back(appended)
    return []

add_coordinates_to_waypoints adds metadata to each waypoint, or step in the journey:

This is from experiments/paths_experiment.gd
func add_coordinates_to_waypoints(route: Array) -> Array:
    var entries := {}
    for path in get_children().filter(func(node): return node is ConnectedPath2D):
        for connected in path.connected_paths.map(func(connected): return path.get_node(connected)):
            var nearest_path_point : Vector2
            var nearest_connected_point : Vector2
            var nearest_distance : float
            for path_point in path.curve.get_baked_points():
                for connected_point in connected.curve.get_baked_points():
                    var distance = path_point.distance_to(connected_point)
                    if not nearest_distance or distance < nearest_distance:
                        nearest_path_point = path_point
                        nearest_connected_point = connected_point
                        nearest_distance = distance
            entries[str(path.get_instance_id()) + "-" + str(connected.get_instance_id())] = {
                "leave": nearest_path_point,
                "enter": nearest_connected_point,
            }
    var new_waypoints := []
    for i in route.size():
        var current = route[i]
        var waypoint = {
            "node": current,
        }
        if i > 0:
            var previous = route[i - 1]
            var key = str(previous.get_instance_id()) + "-" + str(current.get_instance_id())
            new_waypoints[i - 1].leave = entries[key].leave
            waypoint.enter = entries[key].enter
        new_waypoints.push_back(waypoint)
    return new_waypoints
This code creates four loops to generate a lookup table of all the closest join points for each direction. entries will contain data resembling
  • "path1-path2": { enter: Vector2(1, 1), leave : Vector2(1, 2) }

  • "path1-path3": { enter: Vector2(3, 1), leave : Vector2(3, 2) }

  • "path2-path1": { enter: Vector2(1, 2), leave : Vector2(1, 1) }

  • "path3-path1": { enter: Vector2(3, 2), leave : Vector2(3, 1) }

leave is the position at which the follower leaves the path that it is on, and enter is the position at which it enters the new path. We can use this to plot the course from where the follower is to where it needs to be, via jumps between paths.

This transforms a simple array of steps into an array of instructions:
  1. 1.

    From the current path 1

     
  2. 2.

    Go to position x on path 1

     
  3. 3.

    Join onto path 2

     
  4. 4.

    Go to position y on path 2

     
  5. 5.

    Join onto path 3

     
  6. 6.

    And so on

     

All that’s left to do is create a new move_to_point method so that it moves to the point nearest that last click (if we’re on the right path) or moves to the exit and exits the current path:

This is from experiments/paths_experiment.gd
@onready var _path_follow := %PathFollow2d as PathFollow2D
var nearest_path: ConnectedPath2D
var nearest_point: Vector2
var waypoints: Array
var speed := 200
func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseButton:
        if event.is_pressed():
            nearest_path = get_nearest_path(get_local_mouse_position())
            nearest_point = get_nearest_point(nearest_path, get_local_mouse_position())
            waypoints = get_waypoints(_path_follow.get_parent(), nearest_path)
func get_nearest_point(nearest_path: ConnectedPath2D, target : Vector2) -> Vector2:
    return nearest_path.curve.get_closest_point(target)
func _process(delta: float) -> void:
    move_to_point(delta)
func move_to_point(delta : float) -> void:
    var current_path = _path_follow.get_parent()
    var target_i : int
    var current_i : int
    var points = current_path.curve.get_baked_points()
    var target : Vector2
    if waypoints.size() < 1 or current_path == waypoints.back().node:
        target = nearest_point
    else:
        target = waypoints.filter(func(w): return w.node == current_path).front().leave
    for i in range(points.size()):
        if points[i].distance_to(target) < 5:
            target_i = i
        if points[i].distance_to(_path_follow.position) < 5:
            current_i = i
    if abs(target_i - current_i) > 3:
        if target_i < current_i:
            _path_follow.progress -= delta * speed
        else:
            _path_follow.progress += delta * speed
    elif waypoints.size() > 0 and current_path != waypoints.back().node:
        for i in waypoints.size():
            if waypoints[i].node == current_path:
                var next_path = waypoints[i + 1].node
                current_path.remove_child(_path_follow)
                next_path.add_child(_path_follow)
                move_to_offset_position(waypoints[i + 1].enter)
func move_to_offset_position(target : Vector2) -> void:
    while target.distance_to(_path_follow.position) > 5:
        _path_follow.progress -= 1

As mentioned, if we’re on the right path, then the target position is the curve point nearest the click. If not, we actually want to move toward where we exit the current path and enter the next path.

When the follower is near enough to exit the current path, we switch the follower over to the new path and change its progress until it is close to where it can to enter the new path.

Summary

In this chapter, we took a deep dive into how to set up Path2D nodes and their companion PathFollow2D nodes. We explored how to animate the movement of a follower on a single path and between paths.

This might seem like a lot of work for little benefit, but it’s a useful trick for the kind of game we’re going to finish this book building. More on that later.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset