Project Delve Part 2.5: Grid Obstacles

I couldn’t just leave the last post alone without finishing up what I had started with grid navigation, so here we are with part 2.5!

Part of the strategy of Descent (or almost any turn-based game on a grid) is unit positioning. You need to know where you’ll be able to move to and what it will take to get you there. If the grid is just a flat layout (like we had before) then calculating that is pretty straightforward: just count the number of tiles from your current position and factor in how many movement points you have, then boom! Hard math is hard.

Of course, there’s nothing wrong with simplicity. But if we can add just enough variation to mix up the basic formula without making it over-complicated, then we have tactical gameplay. But enough with the preamble, let’s get into the code!


Updating The Scene

The first thing to figure out is: what is the best way to differentiate between different tile types? How can we tell a water tile from a wall tile or a pit tile? At first I wanted to be able to figure it out programmatically with some sort of TileMap parser, so that there would need to be zero manual configuration. However, in the process of researching how TileMaps and TileSets work in Godot I learned that it would be difficult to implement that in a streamlined way. So in the interest of moving this project along I decided to go with a different approach.

Instead of drawing all tiles onto the same TileMap node, we’re going to make multiple TileMap nodes that will overlap one another. We can either create a new scene to work from or modify the existing TileMap scene. The first thing we want to do is to create a new root node, which can just be a generic Node2D. This new root node is going to be the one with the GridLayout script we had before, which needs to change from extending TileMap to extend Node2D.

Now we’re going to add multiple TileMap children to the root node, one for each of our different tile types. (Floor, Obstacle, Water, Lava, and Pit) I also added an extra TileMap with some decoration tiles, just for fun. Each TileMap should be on a different Z-index to give a layered effect, with the base map being at 0. (Also, make sure the “Z as relative” checkbox is checked so that the index will be considered in relation to the Z-index of the root node.)

I also renamed the root node from TileMap to Dungeon, and the script from GridLayout to Dungeon.

Now comes the interesting part where we get to lay out the map however we want! The only tricky part is to make sure to draw the appropriate tiles on the correct “layer” (TileMap), or else the pathfinding navigation will be messed up.

When you’re finished you might have something looking like this.

I kept the same TileSet for each TileMap, making it easy to re-use the same assets.

Updating The Code

With the scene changes here, our current scripts won’t work as expected. The main script we need to update is going to be dungeon.gd (previously grid_layout.gd). Firstly, like I mentioned before, we don’t want it extending TileMap anymore, since it’s going to be living on a regular Node2D. Secondly, we want to have a reference to all of the different child TileMaps under this object, for which we can use the onready and $ keywords. This is just a shortcut to initializing variables when you declare them, rather than doing

myVar = get_node("PathToNode")

in the _ready() function. So the start of our modified script should look like this now.

extends Node2D

class_name Dungeon

# The $ in front of the node name is simply the path to the
# child node from this node. i.e. $Floors == get_node("Floors")
onready var floors = $Floors
onready var obstacles = $Obstacles
onready var water = $Water
onready var lava = $Lava
onready var pits = $Pits
onready var _pathfinder = $Pathfinder

Now let’s head over to our Pathfinder script and add a few new script variables. Previously this script only held a reference to the TileMap. Instead of giving it a reference to all the new TileMaps we just added though, we’re going to keep things simple and only get a reference to the positions of notable tiles, namely the obstacles and water/hazard tiles.

We’re going to add the following items to the script.

# This will be a Dictionary of int -> PoolVector2Array
var _weighted_tiles = {}
var _obstacles: PoolVector2Array

func set_obstacles(obstacles: PoolVector2Array):
    _obstacles = obstacles

func set_weighted_tiles(tiles: PoolVector2Array, weight: int):
    if _weighted_tiles.has(weight):
        _weighted_tiles[weight].append_array(tiles)
    else:
        _weighted_tiles[weight] = tiles

and inside the _ready() function of our Dungeon script we’re going to call these methods.

# We're also going to expose these in the editor so we can
# change them at will, if we ever want to.
export(int) var water_weight = 2
export(int) var lava_weight = 3
export(int) var pit_weight = 4

func _ready():
    _pathfinder.set_obstacles(obstacles.get_used_cells())
    _pathfinder.set_weighted_tiles(water.get_used_cells(), water_weight)
    _pathfinder.set_weighted_tiles(lava.get_used_cells(), lava_weight)
    _pathfinder.set_weighted_tiles(pits.get_used_cells(), pit_weight)
    _pathfinder.set_tilemap(floors)

The reason we’re calling set_tilemap(floors) last is because we want to populate the AStar2D object only after we’ve set up the other stuff.

Finally we need to alter the Pathfinder script again slightly in order to take into account these new properties. In the _add_traversable_cells() function when calling a_star.add_point(), we need to add a third argument to set the weight of the point.

a_star.add_point(point_index, point, _get_point_weight(point))

The _get_point_weight() function is very simple, basically we just check if the point is one of our special registered tile points and if so we return the weight it was registered with. If the point isn’t in the _weighted_tiles dictionary then we can just return 1, the default weight value.

func _get_point_weight(point: Vector2):
    for weight in _weighted_tiles.keys():
        var tiles = _weighted_tiles[weight]
        if point in tiles:
            return weight
    return 1

Now we can test it all out in our MovementDemo scene! When we press play and click somewhere on the map, we can see that our little character takes into account the weighted values of the grid, prioritizing a lower-cost path.

Notice how in the left gif the character moves straight to the selected point, since the weight of all the basic tiles is 1. In the right gif the character takes into account the weight of the water tiles and finds the lowest-cost path, while still considering water tiles as “traversable.”

Path costs visualized

As always, you can find the code up to this point here.

Leave a Reply

Up ↑