In this part, we’re going to set up the base of our algorithm:
Add a new script to the RandomWalker node and save. Open the script in the Scripting workspace.
Let’s start by defining the class’ properties: signals, constants, and variables.
We’re going to use a signal named path_completed
to split the level generation code into two:
path_completed
to move to the next step.extends Node2D signal path_completed
We want to control how frequently the random walker moves into a given direction. In our example, it should move left, right or down, but not up. Define a STEP
constant as an array from which the walker can pick a random direction.
const STEP := [Vector2.LEFT, Vector2.LEFT, Vector2.RIGHT, Vector2.RIGHT, Vector2.DOWN]
As you can see, moving left or right has double the frequency compared to moving down. By doing this, we end up with a level that has a valid path which is generally more horizontal than vertical.
Then, export a variable to instance the room’s scene. That way, a designer in your team could use a unique scene to generate different types of levels from the Inspector.
export (PackedScene) var Rooms := preload("Rooms.tscn")
You might recall from the previous lessons that we have to use _notification()
instead of _ready()
to set-up the rooms node. This is the reason why. Instead of attaching the scene into the RandomWalker’s scene tree, we pre-load it and instantiate it from the code. We’re going to do that in a second.
Next, add an exported variable grid_size
to control the level’s size.
export var grid_size := Vector2(8, 6)
Declare the _rooms
variable. This is where where we’ll store a Rooms scene instance. Note that all developer-defined variables and methods starting with underscore are pseudo-private by convention.
var _rooms: Node2D = null
We do that after adding the grid_size
so we can group all our exported variables.
Procedural levels rely on generating random numbers. Godot has a RandomNumberGenerator
dedicated for that. Create an instance of that type. It will drive the algorithm.
var _rng := RandomNumberGenerator.new()
Add _state
and _horizontal_chance
variables. _state
is where our algorithm stores the level data it generates and _horizontal_chance
is the chance the random walker has to move horizontally rather than vertically. It depends on STEP
.
var _state := {} var _horizontal_chance := 0.0
Finally, we recommend caching references to the nodes you access in the scene. In our case, Camera2D, Timer, and Level.
onready var camera: Camera2D = $Camera2D onready var timer: Timer = $Timer onready var level: TileMap = $Level
There are two reasons we recommend to do so:
get_node()
or its shorthand $
is cheap, calling the function all the time in your projects adds up, especially if you do so in loops.You can learn more in our GDScript programming guidelines.
Here is how your code should look so far:
extends Node2D signal path_completed const STEP := [Vector2.LEFT, Vector2.LEFT, Vector2.RIGHT, Vector2.RIGHT, Vector2.DOWN] export (PackedScene) var Rooms := preload("Rooms.tscn") export var grid_size := Vector2(8, 6) var _rooms: Node2D = null var _rng := RandomNumberGenerator.new() var _state := {} var _horizontal_chance := 0.0 onready var camera: Camera2D = $Camera2D onready var timer: Timer = $Timer onready var level: TileMap = $Level
Next, let’s get things started in the _ready()
function.
Randomize the RandomNumberGenerator
’s seed by calling _rng.randomize()
. Otherwise, we’d end up with the same level every time we run the algorithm.
Note that using a specific seed is a good way to debug your procedural generation.
Don’t forget to initialize _rooms
with Rooms.instance()
.
Next we use STEP
to calculate the random walker chance to move horizontally (as opposed to downwards).
_horizontal_chance = 1.0 - STEP.count(Vector2.DOWN) / float(STEP.size())
The chance to move horizontally is the complementary of the chance to move downwards. We calculate it this way because there are multiple vectors to move horizontally as opposed to the one moving downwards. The downwards chance is the number of times Vector2.DOWN
is found in STEP
divided by the size of STEP
. We convert STEP.size()
to float here because, usually in programming, division between two integers results in an integer value. Our calculation requires a real number so we manually cast the integer to a float.
Finally, add calls to _setup_camera()
and _generate_level()
which we’re going to create next.
func _ready() -> void: _rng.randomize() _rooms = Rooms.instance() _horizontal_chance = 1.0 - STEP.count(Vector2.DOWN) / float(STEP.size()) _setup_camera() _generate_level()
In this next function, we set-up the camera
so that we can visualize the entire level.
func _setup_camera() -> void: var world_size := _grid_to_world(grid_size) camera.position = world_size / 2 var ratio := world_size / OS.window_size var zoom_max := max(ratio.x, ratio.y) + 1 camera.zoom = Vector2(zoom_max, zoom_max)
First we calculate the span of the level size in pixels, using _grid_to_world()
, and store it in world_size
. We’ll cover this function in part 09.
To place the camera
in the middle of the level we assign world_size / 2
to camera.position
.
We also calculate the zoom level to show the entire level at once. Camera2D
’s zoom is counter-intuitive in that larger values make you zoom out, and smaller values make you zoom in. Rather than the zooming level, the Zoom property controls the scale of the camera’s bounds.
To calculate the zoom value, we first calculate the ratio of the world to the game window’s size (set in Project Settings… > Display > Window).
var ratio := world_size / OS.window_size
We need to fit the longer side of the world into view, so we need to take the largest of the components in ratio
. That way, we get a single zoom value instead of having to zoom on two axes separately. We also add 1
to this value, to give us a margin so that the level won’t touch the window boundaries.
var zoom_max := max(ratio.x, ratio.y) + 1
Finally, we can assign that zoom to the camera’s zoom property which makes our camera encompass the level when starting the project.
camera.zoom = Vector2(zoom_max, zoom_max)
Moving on to the main function of our generator, _generate_level()
. At this point, none of the functions it calls exist in our class. We are going to add them and break them down one by one.
Here is the complete function:
func _generate_level() -> void: _reset() _update_start_position() while _state.offset.y < grid_size.y: _update_room_type() _update_next_position() _update_down_counter() _place_walls() _place_path_rooms() _place_side_rooms()
We often start with a skeleton-like the one above working iteratively: we first think of the program’s structure, set its foundations, and then implement the details. Of course, when prototyping, the function might be different. But you can see how we break the generation into two clear steps.
We first construct the level conceptually and store this data in _state
.
_reset() _update_start_position() while _state.offset.y < grid_size.y: _pick_room_type() _update_next_position() _update_down_counter()
The while loop breaks when the random walker tries to go beyond grid_size.y
, that is to say when _state.offset.y < grid_size.y
becomes false
. The _state.offset
property is a Vector2
which holds the walker’s current position on the grid. We update the vector with _update_next_position()
, as we’ll see in the next part.
In the next phase, we build the map that the player sees:
_place_walls() _place_path_rooms() _place_side_rooms()
That is our algorithm’s foundation and core loop. In the next lesson, we will dive into the details by looking at the program’s functions.