Procedural Generation in Godot - Part 8: Dungeons (part 3)

Tags: godot gamedev tutorial procgen

In 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: grass Stone tile: stone

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:

alt alt

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

alt

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.

Download the code for this lesson

Comments