There’s still more to do in RandomWalker.gd
.
Let’s calculate the level’s state. This is the underlying data the algorithm is going to use to draw the final level.
We’re going to generate the entire map conceptually with the following functions.
Let’s take a look at each of the state-related functions we went over in the previous part. This part of the _generate_level()
function is where we do the bulk of the work:
_reset() _update_start_position() while _state.offset.y < grid_size.y: _update_room_type() _update_next_position() _update_down_counter()
In this lesson, we’re going to cover the code up to _update_room_type()
.
As a reminder, generating the base data first and drawing the level later has advantages:
To initialize our level generator, we start with _reset()
, which reinitializes our _state
variable.
func _reset() -> void: _state = { "random_index": -1, "offset": Vector2.ZERO, "delta": Vector2.ZERO, "down_counter": 0, "path": [], "empty_cells": {} } for x in range(grid_size.x): for y in range(grid_size.y): _state.empty_cells[Vector2(x, y)] = 0
In this function, we first initialize our _state
dictionary.
_state = { "random_index": -1, # Random index to pick a direction from the STEP constant array from last lesson "offset": Vector2.ZERO, # Current position on the grid "delta": Vector2.ZERO, # Direction to increment the offset key above "down_counter": 0, # The number of times the walker moved down without interruption "path": [], # The level's unobstructed path "empty_cells": {} # Coordinates of the cells we haven't populated yet }
The offset
key above stores the coordinates of the last generated random location on the path. The delta
value is the direction towards which to move on the next step.
The interesting part is the empty_cells
key. Each key in this dictionary holds the coordinates of an unused cell. The values associated with the keys aren’t important since we use the keys themselves to track these positions.
In that case, why did we use a dictionary here and not an array? For performance and simplicity. We’re going to erase the positions as we use them. Finding and erasing random keys in a dictionary is a constant-time operation while doing so in an array depends on the array’s size.
The state.path
array represents the guaranteed path the player can follow to reach the end of the level. We’re going to fill it with values of the form: {"offset": Vector2, "type": rooms.Type}
. These dictionaries tell us which room type we should place on any given cell.
We fill the empty_cells
key with all the cells in the grid using a nested for
loop:
for x in range(grid_size.x): for y in range(grid_size.y): _state.empty_cells[Vector2(x, y)] = 0
Let’s move on to _update_start_position()
. We generate a random position on the grid and update _state.offset
, where _state.offset.x
is a random number between 0
and grid_size.x - 1
, inclusive. We set _state.offset.y
to 0
to start on the grid’s top row.
func _update_start_position() -> void: var x := _rng.randi_range(0, grid_size.x - 1) _state.offset = Vector2(x, 0)
With our _state
updated, we can move on to the generator’s main loop and generate the first room.
In the first step of the loop, we add the selected grid position to the _state.path
with _update_room_type()
. We’ll use this function to generate all room types as we move along the path.
func _update_room_type() -> void: if not _state.path.empty(): var last: Dictionary = _state.path.back() if last.type in _rooms.BOTTOM_CLOSED and _state.delta.is_equal_approx(Vector2.DOWN): var index := _rng.randi_range(0, _rooms.BOTTOM_OPENED.size() - 1) var type: int = ( _rooms.BOTTOM_OPENED[index] if _state.down_counter < 2 else _rooms.Type.LRTB ) _state.path[-1].type = type var type: int = ( _rooms.Type.LRT if _state.delta.is_equal_approx(Vector2.DOWN) else _rng.randi_range(1, _rooms.Type.size() - 1) ) _state.empty_cells.erase(_state.offset) _state.path.push_back({"offset": _state.offset, "type": type})
If _state.path
isn’t empty, it means we have already generated some steps for the valid path. When the random walker goes downwards, we need to guarantee that the previous room has a bottom opening. It has to be of type Type.LRB
or Type.LRTB
.
If the walker moves downwards twice in a row, we need to guarantee that the previous room is of type Type.LRTB
, that is to say, with both top and bottom openings. That’s the purpose of the first if
check.
To do that, we use _state.down_counter
which is a counter that keeps track of how many consecutive times the random walker moves downwards in a row.
if not _state.path.empty(): var last: Dictionary = _state.path.back() if last.type in rooms.BOTTOM_CLOSED and _state.delta.is_equal_approx(Vector2.DOWN): var index := rng.randi_range(0, rooms.BOTTOM_OPENED.size() - 1) var type: int = ( rooms.BOTTOM_OPENED[index] if _state.down_counter < 2 else rooms.Type.LRTB ) _state.path[-1].type = type
Once we took care of this special case, we select a random room type for the current position. If we have a downward movement we use Type.LRT
. Otherwise, we could end up blocking the path. If there’s a bottom opening and the walker goes down, in a side-scrolling game, the player could fall into a pit and be unable to move back up. If we’re not moving down on the grid, we use any type other than Type.SIDE
. To do so, we select a random number between 1
and _rooms.Type.size() - 1
. We start with 1
rather than 0
to skip the Side node from the Rooms scene.
var type: int = ( rooms.Type.LRT if _state.delta.is_equal_approx(Vector2.DOWN) else rng.randi_range(1, rooms.Type.size() - 1) )
Since we are picking a position on the valid path, we need to delete it from the empty_cells
key of our _state
dictionary. We finally append the new room to the level’s main path.
_state.empty_cells.erase(_state.offset) _state.path.push_back({"offset": _state.offset, "type": type})
Note that we use is_equal_approx()
method to compare vectors: _state.delta.is_equal_approx(Vector2.DOWN)
. Using the exact equality operator ==
isn’t safe for floats, as they are not precise values. They accumulate slight errors over the execution of your program. The is_equal_approx()
method is here when you want to know if float-based values are roughly equal. Apart from the global is_equal_approx()
function that works on floats, many built-in classes implement it as a method.