Bonus: making our code reusable

In this lesson we’ll focus more on organizing content and managing projects through reuse. We’ll look at these advanced topics:

Refactoring the level generator

So far we crammed up everything into BasicDungeon.gd, but we can do better to organize our code. We’ll push the dungeon generator algorithm into a separate class with reusability in mind.

Our first class library

Let’s first do the easy stuff. Create Utils.gd with the following content:

class_name Utils


static func lessv_x(v1: Vector2, v2: Vector2) -> bool:
    return v1.x < v2.x


static func lessv_y(v1: Vector2, v2: Vector2) -> bool:
    return v1.y < v2.y


static func index_to_xy(width: int, index: int) -> Vector2:
    return Vector2(index % width, index / width)

Note that we have also added the new function: index_to_xy(). This converts a 1-dimensional index value to a 2-dimensional vector given a grid width. It’s used to convert two nested for loops into one.

Delete the functions with the same names from BasicDungeon.gd and find and replace self with Utils the following line from _add_room():

poly_partial.sort_custom(self, "_lessv_x" if is_even else "_lessv_y")

with

poly_partial.sort_custom(Utils, "lessv_x" if is_even else "lessv_y")

If you run the project now you shouldn’t see any difference.

Let’s investigate what happened here:

Note that we can’t call non-static functions from static functions and we can’t directly access state variables on the class.

Creating these sort of library classes is beneficial if we want to share reusable functions between projects. The above functions are useful enough to consider doing this.

Abstracting room data

Our algorithm uses a somewhat complex _add_room() function which could use some simplification. To do that we’ll implement two classes: Room and RoomOrganic which uses inheritance to reuse functionality from Room.

Create the Room.gd file with the following content:

class_name Room


var position := Vector2.ZERO setget _no_op, get_position
var end := Vector2.ZERO setget _no_op, get_end
var center := Vector2.ZERO setget _no_op, get_center
var rect: Rect2

var _rect_area: float
var _iter_index: int


func _init(rect: Rect2) -> void:
    update(rect)


func _iter_init(_arg) -> bool:
    _iter_index = 0
    return _iter_is_running()


func _iter_next(_arg) -> bool:
    _iter_index += 1
    return _iter_is_running()


func _iter_get(_arg) -> Vector2:
    var offset := BasicDungeonUtils.index_to_xy(rect.size.x, _iter_index)
    return rect.position + offset


func update(rect: Rect2) -> void:
    self.rect = rect.abs()
    _rect_area = rect.get_area()


func intersects(room: Room) -> bool:
    return rect.intersects(room.rect)


func get_position() -> Vector2:
    return rect.position


func get_end() -> Vector2:
    return rect.end


func get_center() -> Vector2:
    return 0.5 * (rect.position + rect.end)


func _iter_is_running() -> bool:
    return _iter_index < _rect_area


func _no_op(val) -> void:
    pass

We have created a wrapper class around Rect2 to give us some quality of life improvements in the generator algorithm.

We register the class globally with class_name Room, but in this case we want to create new room objects so the functions aren’t static. It’s a regular object, but it does employ advanced features.

We declare and initialize position, end and center variables with Vector2.ZERO and declaring setters and getters for them. The syntax is:

var name[[: Type] [= initial_value]] [setget [setter][, getter]]

where setter and getter are functions defined in the class and everything in [...] is optional. For example the following are all valid:

var name setget only_setter
var name setget , only_getter # notice the comma
var name := 0 setget setter, getter
var name: int = 0 setget setter, getter

We finish the variable declarations with:

var rect: Rect2

var _rect_area: float
var _iter_index: int

To pass in parameters on creation time of an object we need to declare and implement the constructor function _init(). In this case we have:

func _init(rect: Rect2) -> void:
    update(rect)

We can then create an object with Room.new(rect) in some other part of our code. We list update() here because it’s relevant to our class functionality:

func update(rect: Rect2) -> void:
    self.rect = rect.abs()
    _rect_area = rect.get_area()

When assigning rect to the state variable self.rect we make sure rect.width and rect.height are positive numbers using Rect2.abs(). We then store the area of the rectangle in the pseudo-private _rect_area class variable since we use it in the custom iterator functions. This is so we don’t calculate rect.get_area() each time Godot invokes the iterator.

Custom iterators

A custom iterator class implements the following functions:

func _iter_init(_arg: Array) -> bool:
    # ...


func _iter_next(_arg: Array) -> bool:
    # ...


func _iter_get(_arg) -> Vector2:
    # ...

Our implementations are like this:

func _iter_init(_arg) -> bool:
    _iter_index = 0
    return _iter_is_running()


func _iter_next(_arg) -> bool:
    _iter_index += 1
    return _iter_is_running()


func _iter_get(_arg) -> Vector2:
    var offset := Utils.index_to_xy(rect.size.x, _iter_index)
    return rect.position + offset


func _iter_is_running() -> bool:
    return _iter_index < _rect_area

Note that the _arg parameter is used internally by Godot and we don’t have much use for it, but it’s mandatory so we have to include it in the declaration.

_iter_init() initializes the value for the iterator as the function name implies. This happens every time for ... in room is first encountered. _iter_next() is called on each subsequent iteration while the loop runs. This is where we advance the internal iterator state. Both of these functions return a boolean value instructing Godot to either continue or terminate the for loop. Iteration continues until _iter_next() returns false, assuming _iter_init() returned true.

Note that _iter_is_running() is just an extra custom function that isn’t required for the custom iterator to be implemented.

_iter_get() is the function that supplies Godot with the actual iterator value within the for loop. Here, we use Utils.index_to_xy() to get a Vector2 based on the 1-dimensional _iter_index value.

Now we can use the class like this:

var room = Room.new(rect)
for point in room:
    do_something_with(point)

where point is a Vector2 coordinate with integer positions (Vector2(2, 1), Vector2(3, 4) etc.) that spans the entire rectangular area.

intersects() delegates the work to the underlying rect state variable.

func intersects(room: Room) -> bool:
    return rect.intersects(room.rect)

Setters and getters

Let’s review our implementation for the setters and getters.

func get_position() -> Vector2:
    return rect.position


func get_end() -> Vector2:
    return rect.end


func get_center() -> Vector2:
    return 0.5 * (rect.position + rect.end)


func _no_op(val) -> void:
    pass

A quick explanation is that:

var room := Room.new(Rect2(0, 0, 10 10))


var center = room.center
# is the same as:
var center := room.get_center()


room.center = Vector2(5, 6)
# is the same as:
room.set_center(Vector2(5, 6))

Each time we access a variable defined with getters, for example:

var center := room.center

Godot calls the specified getter behind the scenes, get_center() in this case. We have also specified the _no_op() setter so that whenever we use assign a value to one of the variables:

room.position = Vector2(...)

no assignment actually takes place as Godot calls the given function which does nothing in this case. This is a way to protect our variables from unwanted outside assignments.

Note that we haven’t used type annotation on _no_op()’s parameter. This is so it can be used with any type of variable, not just Vector2, as in our example here.

We’d use setters for example when we might want to emit a signal that a variable has changed, but there are many other uses for them. In the case of room.center variable, we calculate it from rect.position and rect.end internally as Rect2 doesn’t provide this member itself.

Organic room via inheritance

With the core functionality defined in Room.gd, we inherit and expand upon it in RoomOrganic.gd:

class_name RoomOrganic
extends Room


const FACTOR := 1.0 / 8.0

var _data: Array
var _data_size: int


func _init(rect: Rect2).(rect) -> void:
    pass


func _iter_get(_arg) -> Vector2:
    return _data[_iter_index]


func update(rect: Rect2) -> void:
    .update(rect)
    var rng := RandomNumberGenerator.new()
    rng.randomize()

    var unit := FACTOR * rect.size
    var order := [
        rect.grow_individual(-unit.x, 0, -unit.x, unit.y - rect.size.y),
        rect.grow_individual(unit.x - rect.size.x, -unit.y, 0, -unit.y),
        rect.grow_individual(-unit.x, unit.y - rect.size.y, -unit.x, 0),
        rect.grow_individual(0, -unit.y, unit.x - rect.size.x, -unit.y)
    ]
    var poly = []
    for index in range(order.size()):
        var rect_current: Rect2 = order[index]
        var is_even := index % 2 == 0
        var poly_partial := []
        for r in range(rng.randi_range(1, 2)):
            poly_partial.push_back(Vector2(
                rng.randf_range(rect_current.position.x, rect_current.end.x),
                rng.randf_range(rect_current.position.y, rect_current.end.y)
            ))
        poly_partial.sort_custom(Utils, "lessv_x" if is_even else "lessv_y")
        if index > 1:
            poly_partial.invert()
        poly += poly_partial

    _data = []
    for x in range(rect.position.x, rect.end.x):
        for y in range(rect.position.y, rect.end.y):
            var point := Vector2(x, y)
            if Geometry.is_point_in_polygon(point, poly):
                _data.push_back(point)
    _data_size = _data.size()


func _iter_is_running() -> bool:
    return _iter_index < _data_size

We start by registering this class with the name RoomOrganic and inheriting from Room:

class_name RoomOrganic
extends Room

Since we rely on custom iterators to go over the contained points within a polygon, we can’t iterate over the entire rectangular area any more. For this we define the following internal variables:

var _data: Array
var _data_size: int

Let’s understand _init() syntax:

func _init(rect: Rect2).(rect) -> void:
    pass

_init(param1: int, param2: Vector2).(param2) is the way to tell Godot the following: I want to define a constructor that accepts two parameters: param1 and param2, but I also want you to call the constructor on the parent class sending param2 to it.

Note that even if _init() doesn’t have any implementation in this RoomOrganic class, we still have to declare the function with the pass declaration. The following is invalid:

func _init(rect: Rect2) -> void:
    update(rect)

because we inherit from a class that needs a parameter to be passed in on construction: Room.new(rect). We are forced to declare: func _init(rect: Rect2).(rect) -> void. We also don’t need to explicitly call update() here since the parent class already does this.

We overwrite _iter_get() and _iter_is_running() to reflect the new data on the polygonal room type:

func _iter_get(_arg) -> Vector2:
    return _data[_iter_index]


func _iter_is_running() -> bool:
    return _iter_index < _data_size

The update() function is the implementation of for organic rooms from BasicDungeon’s _add_room():

func update(rect: Rect2) -> void:
    .update(rect)
    var rng := RandomNumberGenerator.new()
    rng.randomize()

    var unit := FACTOR * rect.size
    var order := [
        rect.grow_individual(-unit.x, 0, -unit.x, unit.y - rect.size.y),
        rect.grow_individual(unit.x - rect.size.x, -unit.y, 0, -unit.y),
        rect.grow_individual(-unit.x, unit.y - rect.size.y, -unit.x, 0),
        rect.grow_individual(0, -unit.y, unit.x - rect.size.x, -unit.y)
    ]
    var poly = []
    for index in range(order.size()):
        var rect_current: Rect2 = order[index]
        var is_even := index % 2 == 0
        var poly_partial := []
        for r in range(rng.randi_range(1, 2)):
            poly_partial.push_back(Vector2(
                rng.randf_range(rect_current.position.x, rect_current.end.x),
                rng.randf_range(rect_current.position.y, rect_current.end.y)
            ))
        poly_partial.sort_custom(Utils, "lessv_x" if is_even else "lessv_y")
        if index > 1:
            poly_partial.invert()
        poly += poly_partial

    _data = []
    for x in range(rect.position.x, rect.end.x):
        for y in range(rect.position.y, rect.end.y):
            var point := Vector2(x, y)
            if Geometry.is_point_in_polygon(point, poly):
                _data.push_back(point)
    _data_size = _data.size()

The differences are:

This concludes our custom room implementations for rectangular and organic types.

Using the room classes

Now we’re ready to simplify the dungeon generator a bit. Let’s start with the simplest modification. Replace _add_room() with:

func _add_room(data: Dictionary, rooms: Array, room: Room) -> void:
    rooms.push_back(room)
    for point in room:
        data[point] = null

We take full advantage of our new classes here:

But if you try to run the project right now it won’t work just yet. We still need to replace all Rect2 type declarations with Room declarations and do a few other small changes:

func _add_connection(rng: RandomNumberGenerator, data: Dictionary, room1: Room, room2: Room) -> void:
    if rng.randi_range(0, 1) == 0:
        _add_corridor(data, room1.center.x, room2.center.x, room1.center.y, Vector2.AXIS_X)
        _add_corridor(data, room1.center.y, room2.center.y, room2.center.x, Vector2.AXIS_Y)
    else:
        _add_corridor(data, room1.center.y, room2.center.y, room1.center.x, Vector2.AXIS_Y)
        _add_corridor(data, room1.center.x, room2.center.x, room2.center.y, Vector2.AXIS_X)


func _get_random_room(level_size: Vector2, rooms_size: Vector2, rng: RandomNumberGenerator) -> Room:
    var width := rng.randi_range(rooms_size.x, rooms_size.y)
    var height := rng.randi_range(rooms_size.x, rooms_size.y)
    var x := rng.randi_range(0, level_size.x - width - 1)
    var y := rng.randi_range(0, level_size.y - height - 1)
    var rect := Rect2(x, y, width, height)
    return Room.new(rect) if rng.randi_range(0, 1) == 0 else Organic.new(rect)

Now we should be able to run the project without any problem.

We didn’t simplify the algorithm a hole lot, but we did learn a few tricks.

A reusable dungeon generator

As a final exercise let’s separate the dungeon generator algorithm from the rendering algorithm.

Create Generator.gd with the following content:

class_name Generator


static func generate(level_size: Vector2, rooms_size: Vector2, rooms_max: int) -> Array:
    var rng := RandomNumberGenerator.new()
    rng.randomize()

    var data := {}
    var rooms := []
    for r in range(rooms_max):
        var room := _get_random_room(level_size, rooms_size, rng)
        if _intersects(rooms, room):
            continue

        _add_room(data, rooms, room)
        if rooms.size() > 1:
            var room_previous: Room = rooms[-2]
            _add_connection(rng, data, room_previous, room)
    return data.keys()


static func _get_random_room(level_size: Vector2, rooms_size: Vector2, rng: RandomNumberGenerator) -> Room:
    var width := rng.randi_range(rooms_size.x, rooms_size.y)
    var height := rng.randi_range(rooms_size.x, rooms_size.y)
    var x := rng.randi_range(0, level_size.x - width - 1)
    var y := rng.randi_range(0, level_size.y - height - 1)
    var rect := Rect2(x, y, width, height)
    return Room.new(rect) if rng.randi_range(0, 1) == 0 else RoomOrganic.new(rect)


static func _add_room(data: Dictionary, rooms: Array, room: Room) -> void:
    rooms.push_back(room)
    for point in room:
        data[point] = null


static func _add_connection(rng: RandomNumberGenerator, data: Dictionary, room1: Room, room2: Room) -> void:
    if rng.randi_range(0, 1) == 0:
        _add_corridor(data, room1.center.x, room2.center.x, room1.center.y, Vector2.AXIS_X)
        _add_corridor(data, room1.center.y, room2.center.y, room2.center.x, Vector2.AXIS_Y)
    else:
        _add_corridor(data, room1.center.y, room2.center.y, room1.center.x, Vector2.AXIS_Y)
        _add_corridor(data, room1.center.x, room2.center.x, room2.center.y, Vector2.AXIS_X)


static func _add_corridor(data: Dictionary, start: int, end: int, constant: int, axis: int) -> void:
    for t in range(min(start, end), max(start, end) + 1):
        var point := Vector2.ZERO
        match axis:
            Vector2.AXIS_X: point = Vector2(t, constant)
            Vector2.AXIS_Y: point = Vector2(constant, t)
        data[point] = null


static func _intersects(rooms: Array, room: Room) -> bool:
    var out := false
    for room_other in rooms:
        if room.intersects(room_other):
            out = true
            break
    return out

And update _generator() from BasicDungeon.gd with:

func _generate() -> void:
    level.clear()
    for vector in Generator.generate(level_size, rooms_size, rooms_max):
        level.set_cellv(vector, 0)

All we did here is to group our dungeon algorithm-related functions under a class called Generator using static functions. This way we could create different algorithms and call them depending on our needs.

Since we can store the registered class itself into variables we can do something like:

var algorithm := Generator
for vector in algorithm.generate(level_size, room_size, rooms_max):
    # ...

With this we conclude our tutorial on reusability and advanced GDScript features.