In this lesson we’ll focus more on organizing content and managing projects through reuse. We’ll look at these advanced topics:
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.
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:
Utils.gd
script file and moved a few functions from BasicDungeon.gd
into it.class_name Utils
at the very top of Utils.gd
. This instructs Godot to register the class in the global namespace. This allows us to call on it directly without having to load it beforehand with preload()
or load()
functions or VIA autoload project settings.Utils
class functions with static
. What this means is that we can call on them directly with Utils.index_to_xy()
instead of first having to instantiate a new Utils
object with Utils.new()
.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.
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.
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)
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.
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:
.update(rect)
which calls the update()
function on the parent class. Note the .
(dot) character at the beginning of the function call._data
pseudo-variable which is used in our custom iterator function._data.size()
in _data_size
pseudo-variable so whenever Godot calls upon the iterator the size doesn’t have to be calculated each time.This concludes our custom room implementations for rectangular and organic types.
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:
Room
and RoomOrganic
classes.RoomOrganic
is a sub-class of Room
.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:
_add_room()
call: _add_room(rng, data, rooms, room)
with _add_room(data, rooms, room)
since we don’t need to pass the RandomNumberGenerator
any longer.: Rect2
with : Room
instead. You can do this with Ctrl r._add_connection()
and _get_random_room()
with: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.
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.