In Part 1, we create the dungeon as a large, empty room with walls around the edge. In this part, we'll modify our dungeon generation code to start by filling the entire map with walls and then carving out rooms and connecting them with tunnels.
Start by creating a structure we'll use to create our rooms. Add the following code to level.go
:
type RectangularRoom struct {
X1 int
Y1 int
X2 int
Y2 int
}
// Create a new RectangularRoom structure.
func NewRectangularRoom(x int, y int, width int, height int) RectangularRoom {
return RectangularRoom{
X1: x,
Y1: y,
X2: x + width,
Y2: y + height,
}
}
The constructor takes the x and y coordinates of the top-level corner and computes the bottom right corner based on the width and height parameters.
In order to create tunnels between rooms, we'll also need a function to calculate the center of the room.
// Returns the tile coordinates of the center of the RectangularRoom.
func (r *RectangularRoom) Center() (int, int) {
centerX := (r.X1 + r.X2) / 2
centerY := (r.Y1 + r.Y2) / 2
return centerX, centerY
}
To ensure that our rooms are surrounded by at least one layer of wall tile, we should create a function that returns the interior of the room:
// Returns the tile coordinates of the interior of the RectangularRoom.
func (r *RectangularRoom) Interior() (int, int, int, int) {
return r.X1 + 1, r.X2 - 1, r.Y1 + 1, r.Y2 - 1
}
Using this, we can modify our createTiles()
function, but we also want to keep track of our rooms, so we should add a slice of Rooms to our Level structure first.
type Level struct {
Tiles []MapTile
Rooms []RectangularRoom // NEW
}
Then let's refactor createTiles()
to make a pair of rooms. (Don't worry, we'll get to procedural generation later.)
func (level *Level) createTiles() {
gd := NewGameData()
tiles := make([]MapTile, gd.ScreenHeight*gd.ScreenWidth)
// Fill with wall tiles
for x := 0; x < gd.ScreenWidth; x++ {
for y := 0; y < gd.ScreenHeight; y++ {
idx := GetIndexFromCoords(x, y)
wall, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileWall)
if err != nil {
log.Fatal(err)
}
tiles[idx] = wall
}
}
level.Tiles = tiles
room1 := NewRectangularRoom(25, 15, 10, 15)
room2 := NewRectangularRoom(40, 15, 10, 15)
level.Rooms = append(level.Rooms, room1, room2)
for _, room := range level.Rooms {
x1, x2, y1, y2 := room.Interior()
for x := x1; x <= x2; x++ {
for y := y1; y <= y2; y++ {
idx := GetIndexFromCoords(x, y)
floor, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileFloor)
if err != nil {
log.Fatal(err)
}
level.Tiles[idx] = floor
}
}
}
}
Our first nested loops now fill the entire level with wall tiles. We then create new rooms and add them to the level's list of rooms. Finally, we iterate through all of the level's rooms and carve out their interiors by making them floor tiles.
Unfortunately, our player isn't in a room anymore, so let's modify NewGame()
in main.go
to put the player in the center of the first room by changing the line where we create the player.
startX, startY := g.CurrentLevel.Rooms[0].Center()
player, err := NewEntity(startX, startY, "player")
Run the game and you'll find the player in the first of two rooms.
Tunneling Along
That's cool and all, but the player is trapped in the first room. That just won't do. We need to create tunnels between rooms.
Let's start be creating a pair of private helper functions that will create vertical and horizontal tunnels.
// Create a vertical tunnel.
func (level *Level) createVerticalTunnel(y1 int, y2 int, x int) {
gd := NewGameData()
for y := min(y1, y2); y < max(y1, y2)+1; y++ {
idx := GetIndexFromCoords(x, y)
if idx > 0 && idx < gd.ScreenHeight*gd.ScreenWidth {
floor, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileFloor)
if err != nil {
log.Fatal(err)
}
level.Tiles[idx] = floor
}
}
}
// Create a horizontal tunnel.
func (level *Level) createHorizontalTunnel(x1 int, x2 int, y int) {
gd := NewGameData()
for x := min(x1, x2); x < max(x1, x2)+1; x++ {
idx := GetIndexFromCoords(x, y)
if idx > 0 && idx < gd.ScreenHeight*gd.ScreenWidth {
floor, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileFloor)
if err != nil {
log.Fatal(err)
}
level.Tiles[idx] = floor
}
}
}
createVerticalTunnel()
takes three arguments: a starting position on the Y-axis, an ending position on the Y-axis, and the X-coordinate. It then converts all tiles from x, y1
to x, y2
into floor tiles. createHorizontalTunnel()
does the same thing, but on the X-axis instead of the Y.
We'll use both of these to tunnel from one room to another.
// Tunnel from this first room to second room.
func (level *Level) tunnelBetween(first *RectangularRoom, second *RectangularRoom) {
startX, startY := first.Center()
endX, endY := second.Center()
if rand.Intn(2) == 0 {
// Tunnel horizontally, then vertically
level.createHorizontalTunnel(startX, endX, startY)
level.createVerticalTunnel(startY, endY, endX)
} else {
// Tunnel vertically, then horizontally
level.createVerticalTunnel(startY, endY, startX)
level.createHorizontalTunnel(startX, endX, endY)
}
}
This private function takes two RectangularRooms as arguments and tunnels from one to the next. It generates a random number (don't forget to add "math/rand"
to your list of imports!) and uses that to determine whether to first tunnel vertically or horizontally. It then utilizes those helper functions to do the actual tunneling.
We can now use this function inside createTiles()
to connect the two rooms we made.
// Carve out rooms
for roomNum, room := range level.Rooms { // NEW
x1, x2, y1, y2 := room.Interior()
for x := x1; x <= x2; x++ {
for y := y1; y <= y2; y++ {
idx := GetIndexFromCoords(x, y)
floor, err := NewTile(x*gd.TileWidth, y*gd.TileHeight, TileFloor)
if err != nil {
log.Fatal(err)
}
level.Tiles[idx] = floor
}
}
if roomNum > 0 { // NEW
level.tunnelBetween(&level.Rooms[roomNum-1], &level.Rooms[roomNum]) // NEW
} // NEW
}
We changed the start of the for loop from for _, room
to for roomNum, room
because we now need to know which room we're working on. After the room is carved, we then tunnel from this room, to the previous room (if there is a previous room).
If you run the game now, it should look like this.
More Rooms
Now that our room and tunnel functions work, it's time to move on to the actual dungeon generation. It'll be fairly simple: place rooms one at a time, make sure they don't overlap, then connect them with tunnels.
To do that, we'll need a function to determine if two rooms overlap.
// Determines if this room intersects with otherRoom.
func (r *RectangularRoom) IntersectsWith(otherRoom RectangularRoom) bool {
return r.X1 <= otherRoom.X2 && r.X2 >= otherRoom.X1 && r.Y1 <= otherRoom.Y2 && r.Y2 >= otherRoom.Y1
}
We'll need a few more variables in GameData
to determine the minimum and maximum size of the rooms as well as the maximum number of rooms one floor can have.
type GameData struct {
ScreenWidth int
ScreenHeight int
TileWidth int
TileHeight int
MaxRoomSize int // NEW
MinRoomSize int // NEW
MaxRooms int // NEW
}
// Creates a new instance of the static game data.
func NewGameData() GameData {
gd := GameData{
ScreenWidth: 80,
ScreenHeight: 50,
TileWidth: 16,
TileHeight: 16,
MaxRoomSize: 10, // NEW
MinRoomSize: 6, // NEW
MaxRooms: 30, // NEW
}
return gd
}
With these variables in place, modify createTiles()
replacing
room1 := NewRectangularRoom(25, 15, 10, 15)
room2 := NewRectangularRoom(40, 15, 10, 15)
level.Rooms = append(level.Rooms, room1, room2)
with the following:
for i := 0; i <= gd.MaxRooms; i++ {
// generate width and height as random numbers between gd.MinRoomSize and gd.MaxRoomSize
width := rand.Intn(gd.MaxRoomSize-gd.MinRoomSize+1) + gd.MinRoomSize
height := rand.Intn(gd.MaxRoomSize-gd.MinRoomSize+1) + gd.MinRoomSize
xPos := rand.Intn(gd.ScreenWidth - width)
yPos := rand.Intn(gd.ScreenHeight - height)
newRoom := NewRectangularRoom(xPos, yPos, width, height)
isOkay := true
for _, room := range level.Rooms {
// check through all existing rooms to ensure newRoom doesn't intersect
if newRoom.IntersectsWith(room) {
isOkay = false
break
}
}
if isOkay {
level.Rooms = append(level.Rooms, newRoom)
}
}
This one is a bit complicated, so let's break it apart.
width := rand.Intn(gd.MaxRoomSize-gd.MinRoomSize+1) + gd.MinRoomSize
height := rand.Intn(gd.MaxRoomSize-gd.MinRoomSize+1) + gd.MinRoomSize
This uses rand.Intn()
to generate random widths and heights between gd.MinRoomSize
(if rand.Intn()
returns 0) and gd.MaxRoomSize
(if rand.Intn()
returns it's maximum value, which is gd.MaxRoomSize-gd.MinRoomSize in this case).
xPos := rand.Intn(gd.ScreenWidth - width)
yPos := rand.Intn(gd.ScreenHeight - height)
We then do a similar thing to grab the x- and y-coordinates of the top-left corner of the room, but ensure that our room doesn't extend off the edge of the map.
newRoom := NewRectangularRoom(xPos, yPos, width, height)
isOkay := true
for _, room := range level.Rooms {
// check through all existing rooms to ensure newRoom doesn't intersect
if newRoom.IntersectsWith(room) {
isOkay = false
break
}
}
After creating a room with the new random specifications, we loop through all of the previously placed rooms and see if this new room intersects with them. If it does, we flag this room as not okay and stop the loop.
if isOkay {
level.Rooms = append(level.Rooms, newRoom)
}
If we made it through the whole list without finding an intersection, we then add the room to the level's list of rooms.
Not too bad, right?
If you run the game now, you should see something similar to the following (but not the same, because it's random).
That's it! We have a functioning dungeon generation algorithm.
You can view the complete source code here and if you have any questions, please feel free to ask them in the comments.
Top comments (2)
As a fellow roguelike lover, I am loving this series! Please keep it up!
Glad to hear you're enjoying it. Part 4 is being finished up right now.