Procedural Generation in Godot - Part 8: Dungeons (part 3)
Tue, Dec 18, 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 wrap up the procedurally generated dungeon by making it into a TileMap that we can explore.
You can watch a video version of this lesson here:
Generating a TileMap
Now that we’ve generated the rooms and connected them with a path, there’s one more step to having a dungeon to explore. We will use Godot’s TileMap node to create the walls. In a nutshell, we’ll fill the entire map with walls, carve out the locations of our rooms, and then carve paths between them according to our minumum spanning tree.
The tileset we’ll be using is a simple one with two tiles:
Grass tile: Stone tile:
Add a TileMap
node to the Main scene and set its Cell/Size to (32, 32)
. Add
the res://assets/tiles.tres
TileSet resource to the Tile Set property (you
can find this in the project download at the end of this document).
Since we still have the visualization running of our room generation, we’ll
manually trigger building the TileMap by pressing <Tab>
. Add this to _input()
:
if event.is_action_pressed('ui_focus_next'):
make_map()
And then we’ll define the make_map()
function:
func make_map():
# Creates a TileMap from the generated rooms & path
# find_start_room()
# find_end_room()
Map.clear()
# Fill TileMap with walls and carve out empty spaces
var full_rect = Rect2()
for room in $Rooms.get_children():
var r = Rect2(room.position-room.size,
room.get_node("CollisionShape2D").shape.extents*2)
full_rect = full_rect.merge(r)
var topleft = Map.world_to_map(full_rect.position)
var bottomright = Map.world_to_map(full_rect.end)
for x in range(topleft.x, bottomright.x):
for y in range(topleft.y, bottomright.y):
Map.set_cell(x, y, 1)
# Carve rooms and corridors
var corridors = [] # One corridor per connection
for room in $Rooms.get_children():
var s = (room.size / tile_size).floor()
var pos = Map.world_to_map(room.position)
var ul = (room.position/tile_size).floor() - s
for x in range(2, s.x * 2-1):
for y in range(2, s.y * 2-1):
Map.set_cell(ul.x+x, ul.y+y, 0)
# Carve corridors
var p = path.get_closest_point(Vector3(room.position.x,
room.position.y, 0))
for conn in path.get_point_connections(p):
if not conn in corridors:
var start = Map.world_to_map(Vector2(path.get_point_position(p).x, path.get_point_position(p).y))
var end = Map.world_to_map(Vector2(path.get_point_position(conn).x, path.get_point_position(conn).y))
carve_path(start, end)
corridors.append(p)
In the first part, we’re finding the rectangle that encloses the entire map,
using the Rect2
method merge()
. Then we fill the entire TileMap with stone
tiles.
Note: For very large and/or sparse maps, this may take a bit of time. In those cases you may prefer to start with a pre-generated “solid” map and only do the “carving” for your created rooms.
The next step is to carve the rooms and corridors. We loop through the rooms
and convert their coordinates to map space (world_to_map()
). Note we’re carving
out the room a bit smaller than the room’s actual size. This is so that adjacent
rooms will still have walls between them, rather than merging into a single large
room.
Carving corridors
To make the corridors, we check the mst path
and carve a path for each
connection the current room has. So that we don’t carve the same path twice, we
keep a list (corridors
) of the ones we’ve already done.
Finally, we need to call the carve_path()
function to actually make the
connection:
func carve_path(pos1, pos2):
# Carves a path between two points
var x_diff = sign(pos2.x - pos1.x)
var y_diff = sign(pos2.y - pos1.y)
if x_diff == 0: x_diff = pow(-1.0, randi() % 2)
if y_diff == 0: y_diff = pow(-1.0, randi() % 2)
# Carve either x/y or y/x
var x_y = pos1
var y_x = pos2
if (randi() % 2) > 0:
x_y = pos2
y_x = pos1
for x in range(pos1.x, pos2.x, x_diff):
Map.set_cell(x, x_y.y, 0)
Map.set_cell(x, x_y.y+y_diff, 0) # widen the corridor
for y in range(pos1.y, pos2.y, y_diff):
Map.set_cell(y_x.x, y, 0)
Map.set_cell(y_x.x+x_diff, y, 0) # widen the corridor
There are a few important things going on in this function. First, we calculate
the direction of the x
and y
difference in the start and end positions. If
it’s 0
, we pick a random direction. This is because we’re going to carve
corridors that are 2 tiles wide.
Second, we need to randomize which of two options we choose when carving the path:
Adding a Player
As the final step, we’re going to add a player-controlled character that can
walk around the map. Character.tscn
and Character.gd
are a generic top-down
KinematicBody2D character with an attached camera, which we’ll instance when
the dungeon is complete.
Add the following new variables to the main script:
var Player = preload("res://Character.tscn")
var start_room = null
var end_room = null
var play_mode = false
var player = null
Uncomment find_start_room()
and find_end_room()
from the start of make_map()
and add the following functions:
func find_start_room():
var min_x = INF
for room in $Rooms.get_children():
if room.position.x < min_x:
start_room = room
min_x = room.position.x
func find_end_room():
var max_x = -INF
for room in $Rooms.get_children():
if room.position.x > max_x:
end_room = room
max_x = room.position.x
These functions pick the leftmost room as the start_room
and the rightmost
as the end_room
. By making them separate functions, you can change the
criteria as you choose.
As with the map generation, we’re going to manually choose “play mode” by
pressing a key (<esc>
in this case). Make the following changes to _input()
:
func _input(event):
# Spacebar restarts and creates a new set of rooms
if event.is_action_pressed('ui_select'):
if play_mode:
player.queue_free()
play_mode = false
Map.clear()
for n in $Rooms.get_children():
n.queue_free()
path = null
start_room = null
end_room = null
make_rooms()
# Tab generates the TileMap
if event.is_action_pressed('ui_focus_next'):
make_map()
# Esc spawns a player to explore the map
if event.is_action_pressed('ui_cancel'):
player = Player.instance()
add_child(player)
player.position = start_room.position
play_mode = true
Conclusion
That wraps up our randomly generated dungeon tutorial. We used the physics engine to distribute the rooms, learned how to use a minimum spanning tree to connect them together, and turned the whole thing into a TileMap that can be used in a game.
Obviously this dungeon is very plain and uninteresting. Decorating the dungeon and populating it with treasure, monsters, and other features is beyond the scope of this demo (but might make a great future one!).
Please comment below with your questions and suggestions.