Procedural Generation in Godot - Part 6: Dungeons
Tue, Dec 4, 2018In this series, we’ll explore the applications of procedural generation to game development. While we’ll be using Godot 3.0 as our platform, much of the concepts and algorithms related to this subject are universal, and you can apply them to whatever platform you may be working on.
In this part, we’ll look at a technique for generating random dungeon maps.
You can watch a video version of this lesson here:
Introduction
For this demo, we want to randomly generate a dungeon - a series of rooms connected by corridors - that the player can explore.
There are many different ways to approach this. Some popular methods use maze generation techniques like we have discussed previously. However, for this demo, I wanted to try a different approach. In this algorithm, we take advantage of Godot’s built-in physics engine to create the map.
Broadly speaking, we’ll break this up into three steps:
- Generate the rooms
- Find a “path” connecting the rooms
- Create a TileMap to use in game
You can download the starting project which contains the minimal art assets we’ll use for this demo.
Generating Rooms
Because we’re going to use the physics engine to generate the basic layout of the dungeon, we’ll need to do a little bit of setup first. Let’s start by defining what we mean by a “room”.
Room object
A “room” in this context is a rectangular space, typically large enough for the
player to walk around in and/or contain objects of interest like treasures or
mobs. We’ll use a RigidBody2D to represent a room (remember, we’re going to be
using physics here). Create a new scene with a RigidBody2D named “Room” and a CollisionShape2D child (but don’t add a shape to it yet). Attach a script to the
Room.
extends RigidBody2D
var size
func make_room(_pos, _size):
position = _pos
size = _size
var s = RectangleShape2D.new()
s.extents = size
$CollisionShape2D.shape = s
In this script, we have a size
property, which we will be able to randomize.
It’s then used in make_room()
to generate a RectangleShape2D
for collision.
Make sure to set Default Gravity to 0
in the “Physics/2d” section of Project
Settings. Also, in the Inspector, set the Mode of the Room
to “Character”.
This will ensure that the rooms can’t rotate.
Main scene
Now, let’s make a “Main” scene using a Node2D
. Give it a Node
child called
“Rooms”, which will act as a container. Attach a script:
extends Node2D
var Room = preload("res://Room.tscn")
var tile_size = 32 # size of a tile in the TileMap
var num_rooms = 50 # number of rooms to generate
var min_size = 4 # minimum room size (in tiles)
var max_size = 10 # maximum room size (in tiles)
Here are our main input properties for the dungeon.
In _ready()
we need to initialize the random number generator and then call
our function to create the rooms:
func _ready():
randomize()
make_rooms()
The make_rooms()
function is going to use our parameters to create the
randomly sized rooms. For now, we’ll put them all at (0, 0)
:
func make_rooms():
for i in range(num_rooms):
var pos = Vector2(0, 0)
var r = Room.instance()
var w = min_size + randi() % (max_size - min_size)
var h = min_size + randi() % (max_size - min_size)
r.make_room(pos, Vector2(w, h) * tile_size)
$Rooms.add_child(r)
Visualization
If you run the scene, you won’t be able to see anything on the screen. Try turning on “Visible Collision Shapes” from the “Debug” menu.
We can see the shapes, but they’re much larger than the screen. Add a Camera2D
to the scene. Change its Zoom to (10, 10)
and Current to “On”. Now you
should be able to see most of the rooms as they sort themselves out.
It would be more convenient to draw the outlines rather than using the debug option (so turn that back off). Add this code to draw our room outlines:
func _draw():
for room in $Rooms.get_children():
draw_rect(Rect2(room.position - room.size, room.size*2),
Color(32, 228, 0), false)
func _process(delta):
update()
Let’s also add a way to redraw without starting the program all over again:
func _input(event):
if event.is_action_pressed('ui_select'):
for n in $Rooms.get_children():
n.queue_free()
make_rooms()
Now you can press the spacebar to generate a new set of rooms:
Room Adjustments
Now we can make a couple of adjustments to how the rooms are being generated. First, since they’re all starting from the same place you may end up with a “tall” or “wide” dungeon. That may be fine with you, but in some games, it may make more sense if the player travels horizontally more than vertically. Here’s how we can influence that:
Add this variable to Main
:
var hspread = 400 # horizontal spread
Then, in make_rooms()
change the position code to this:
var pos = Vector2(rand_range(-hspread, hspread), 0)
The larger you make this value, the more horizontally spread out the rooms will be.
Secondly, you may notice that it takes some time for the rooms to stop moving as
they settle into their final locations. We can influence that by adding the
following line to the make_room()
function in Room.gd
:
s.custom_solver_bias = 0.75
Culling rooms
Finally, we want to be able to remove some rooms to make the dungeon more or less “sparse”. For this, we’ll add another input parameter:
var cull = 0.4 # chance to cull room
And update make_rooms()
like so:
func make_rooms():
for i in range(num_rooms):
var pos = Vector2(rand_range(-hspread, hspread), 0)
var r = Room.instance()
var w = min_size + randi() % (max_size - min_size)
var h = min_size + randi() % (max_size - min_size)
r.make_room(pos, Vector2(w, h) * tile_size)
$Rooms.add_child(r)
# wait for movement to stop
yield(get_tree().create_timer(1.1), 'timeout')
# cull rooms
for room in $Rooms.get_children():
if randf() < cull:
room.queue_free()
else:
room.mode = RigidBody2D.MODE_STATIC
Note the use of yield()
here. We need to wait until the physics engine has
finished separating the rooms before we start removing them. The ones that are
left can now be set to MODE_STATIC
so they will no longer move at all.
Conclusion
We’ll stop here, as this is getting a bit long-winded - remember we said there were 3 main parts? In the next installment, we’ll look at how to connect the rooms together.
Please comment below with your questions and suggestions.