The Dungeon’s Rooms

Let’s design the dungeon’s rooms. We will use the RigidBody2D type for that, as we want to use the physics engine to spread our rooms around the map.

Create a new scene and click on Other Node in the Scene docker. Add a RigidBody2D node as the root node and name it Room. Next, add a CollisionShape2D node as a child of it. It’s required by RigidBody2D to detect collisions. You should have the following:

With CollisionShape2D selected, add a RectangleShape2D resource to the Shape property in the Inspector. Make sure to toggle on Resource > Local To Scene as well.

If we don’t turn on Local To Scene, we’ll get the same collision shape for all rooms. We don’t want that because we want our rooms to have random sizes.

By default, all resources are shared between scenes and objects. When we change a property on a resource, the change propagates to all shared instances. Local to Scene makes the resource unique for each scene, which means they’re no longer shared, and changes to one resource won’t propagate to other scenes.

Gotchas of using physics

When using physics for procedural generation, you must be aware of potential unexpected behaviors and rough edges of the engine. It can be frustrating for beginners and intermediate developers alike. We’re going to point out potential problems as we encounter them.

Rigid bodies have multiple modes. In the default Rigid mode, Godot applies angular forces, allowing the body to rotate. In Character mode, this doesn’t happen, which means the body will always remain upright. For our algorithm, we want to use RigidBody2D in Character mode.

In the Rigid mode, rigid bodies go to sleep if collisions aren’t detected after a moment, but not in Character mode. This is a known issue that causes confusion, and we have to deal with it in our code.

We want the node to go to sleep after a while, as it indicates the room reached its final position in the generation process. We have to manually check for the absence of collisions and find a way to keep it in place.

Before moving on, select the Room node and set the Mode to Character in the Inspector.

The Room class

In the Room script, we’re going to:

  1. Place the object in a circle following a random distribution.
  2. Detect and emit a signal when there are no collisions.
  3. Implement iter_*() functions to turn the object into a custom iterator, as we did in the first chapter. This will make our life easier in the main script when saving room data for tile manipulation.

Add a script to the Room root node and replace the default code with:

class_name Room
extends RigidBody2D


# Used to test that the body didn't move over consecutive frames.
const CONSECUTIVE_MAX_EQUALITIES := 10

# Radius of the circle in which we randomly place the room.
export var radius := 600
# Interval to generate a random width and height for the room, in tiles.
export var room_size := Vector2(2, 9)

var size := Vector2.ZERO

var _level: TileMap = null
var _rng: RandomNumberGenerator = null
var _previous_xform := Transform2D()
var _consecutive_equalities := 0

var _area: float = 0.0
var _iter_index: int = 0

onready var collision_shape: CollisionShape2D = $CollisionShape2D

We register this class globally as Room, using class_name. Then, we define the constant CONSECUTIVE_MAX_EQUALITIES to test for a stable body position. It will become clear when we get to the meat of the functions.

We have a couple of exported variables for easy access from the Inspector. The radius is the radius of the circle in which we generate a random point, in pixels. The room_size is in TileMap coordinates and defines the interval in which we generate a random number for the room size, for both X and Y axis.

size stores the current room size at run-time. Next, we have a few pseudo-private variables. We store _level, passed in from the main dungeon generator script. We need it to convert to and from the TileMap coordinate system. _rng is again provided by the main script, and that’s why these variables are initialized with null.

We use the next two variables, _previous_xform and _consecutive_equalities, to test when the room gets in a stable position. Like CONSECUTIVE_MAX_EQUALITIES, we’ll get to details once we look at the functions.

We use the last two pseudo-private variables _area and _iter_index to implement the custom iterator functions, _iter_*(), as we did in the first chapter of this tutorial series.

We also store a reference to the CollisionShape2D to easily access it later.

We then move to the functions, beginning with:

func setup(rng: RandomNumberGenerator, level: TileMap) -> void:
    _rng = rng
    _level = level

We use setup() to pass in and store the relevant variables from the main generator script. We use the same RandomNumberGenerator instead of defining a separate one here.

If we wanted to save the dungeon, we’d keep one number, the seed of the RandomNumberGenerator instead of multiple seeds. We’d use independent random number generators. Doing so makes it easier to store the level and debug the generator.

func _ready() -> void:
    position = Utils.get_rng_point_in_circle(_rng, radius)

    var w: int = _rng.randi_range(room_size.x, room_size.y)
    var h: int = _rng.randi_range(room_size.x, room_size.y)
    _area = w * h

    size = Vector2(w, h)
    collision_shape.shape.extents = _level.map_to_world(size) / 2

In _ready() we first randomly assign a position to the room, within a circle, using our utility function get_rng_point_in_circle().

Next, we generate the room’s width and height using RandomNumberGenerator.randi_range(), in TileMap coordinates. We compute and store the room area for later use. We also store the size of the room as we use it in multiple places. We could just use collision_shape.shape.extents instead, but size is more concise.

RectangleShape2D.extents is half the size of width and height.

The meat of our class is this next virtual function:

func _integrate_forces(state: Physics2DDirectBodyState) -> void:
    if mode == RigidBody2D.MODE_STATIC:
        return

    if Utils.is_approx_equal(_previous_xform.origin, state.transform.origin):
        _consecutive_equalities += 1

    if _consecutive_equalities > CONSECUTIVE_MAX_EQUALITIES:
        set_deferred("mode", RigidBody2D.MODE_STATIC)
        call_deferred("emit_signal", "sleeping_state_changed")
    _previous_xform = state.transform

Godot runs the physics simulation on a thread separate from the main one. RigidBody2D._integrate_forces() lets us safely manipulate the physics state.

It’s not safe to access and manipulate the physics state of a RigidBody2D in Node._physics_process() or any other function with the exception of RigidBody2D._integrate_forces().

Our implementation of _integrate_forces() works as follows:

  1. We check if mode is set to RigidBody2D.MODE_STATIC. If so, then we don’t do anything, just exit the function.
  2. We check for approximate equality between the previous position of the room and the current physics state position. This is done by accessing the origin property on the Transform2D of the current physics state. If the check succeeds, we increase _consecutive_equalities by 1.
  3. We check if _consecutive_equalities is greater than CONSECUTIVE_MAX_EQUALITIES. If that’s the case, then we have enough checks to deem this room as stable enough and change its mode to RigidBody2D.MODE_STATIC - remember step 1. We also emit the built-in RigidBody2D sleeping_state_changed signal at this point.
  4. We save the current physics state Transform2D into _previous_xform which is how it becomes the previous state in the next function call.

Implementing _integrate_forces() wouldn’t be necessary if Godot treated RigidBody2D the same in both rigid and character modes. A future update may address this issue.

Implementing a custom iterator

The next few functions that implement the custom iterator behavior are a mirror of what we went over in the first chapter of the tutorial series.

In Godot, an iterator is a class that implements the following virtual methods:

# Initializes the iterator. You can use it to initialize the iterator's index.
# Called at the start of a `for` loop.
# Return `true` if you want to continue iterating.
func _iter_init(_arg: Array) -> bool:
    # ...


# Called once for every iteration of a `for` loop.
# Use it to advance the iterator's state.
# Return `true` if you want to continue iterating.
func _iter_next(_arg: Array) -> bool:
    # ...


# Returns the value the iterator produces.
func _iter_get(_arg) -> Vector2:
    # ...

They allow us to iterate over rooms like so:

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

We’re going to code our iterator to return all the tiles a room overlaps. Doing so allows us to draw each room in our final tilemap.

# Initializes the iterator's index.
func _iter_init(_arg) -> bool:
    _iter_index = 0
    return _iter_is_running()


# Increases the iterator's index by one.
func _iter_next(_arg) -> bool:
    _iter_index += 1
    return _iter_is_running()


# Returns the coordinates of a tile in the `_level` tilemap that our room overlaps.
# Running over the entire loop yields all the tiles we should fill
# to draw the complete room.
func _iter_get(_arg) -> Vector2:
    var width := size.x
    var offset := Utils.index_to_xy(width, _iter_index)
    return _level.world_to_map(position) - size / 2 + offset


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

And this concludes our implementation of the Room class. Next time we’ll cover the main script and scene of the generator.

References

Below, you will find the complete Room.gd class for reference.

class_name Room
extends RigidBody2D


# Used to test that the body didn't move over consecutive frames.
const CONSECUTIVE_MAX_EQUALITIES := 10

# Radius of the circle in which we randomly place the room.
export var radius := 600
# Interval to generate a random width and height for the room, in tiles.
export var room_size := Vector2(2, 9)

var size := Vector2.ZERO

var _level: TileMap = null
var _rng: RandomNumberGenerator = null
var _previous_xform := Transform2D()
var _consecutive_equalities := 0

var _area: float = 0.0
var _iter_index: int = 0

onready var collision_shape: CollisionShape2D = $CollisionShape2D


func setup(rng: RandomNumberGenerator, level: TileMap) -> void:
    _rng = rng
    _level = level


func _ready() -> void:
    position = Utils.get_rng_point_in_circle(_rng, radius)

    var w: int = _rng.randi_range(room_size.x, room_size.y)
    var h: int = _rng.randi_range(room_size.x, room_size.y)
    _area = w * h

    size = Vector2(w, h)
    collision_shape.shape.extents = _level.map_to_world(size) / 2


func _integrate_forces(state: Physics2DDirectBodyState) -> void:
    if mode == RigidBody2D.MODE_STATIC:
        return

    if Utils.is_approx_equal(_previous_xform.origin, state.transform.origin):
        _consecutive_equalities += 1

    if _consecutive_equalities > CONSECUTIVE_MAX_EQUALITIES:
        set_deferred("mode", RigidBody2D.MODE_STATIC)
        call_deferred("emit_signal", "sleeping_state_changed")
    _previous_xform = state.transform

# Initializes the iterator's index.
func _iter_init(_arg) -> bool:
    _iter_index = 0
    return _iter_is_running()


# Increases the iterator's index by one.
func _iter_next(_arg) -> bool:
    _iter_index += 1
    return _iter_is_running()

# Returns the coordinates of a tile in the `_level` tilemap that our room overlaps.
# Running over the entire loop yields all the tiles we should fill
# to draw the complete room.
func _iter_get(_arg) -> Vector2:
    var width := size.x
    var offset := Utils.index_to_xy(width, _iter_index)
    return _level.world_to_map(position) - size / 2 + offset


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