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.
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.
In the Room script, we’re going to:
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:
mode
is set to RigidBody2D.MODE_STATIC
. If so, then we don’t do anything, just exit the function.Transform2D
of the current physics state. If the check succeeds, we increase _consecutive_equalities
by 1
._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.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.
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.
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