Fat Old Yeti

Fat Old Yeti

Being a blog of thoughts and tutorials from a hobby game developer.

08 Feb 2021

Roguelike Tutorial 5

Roguelike in Go - Part 5 (Rooms)

All the code for this tutorial can be found here.

So, now that we have a player, and you can move the player, the map is a bit boring. In this and the next tutorial, we will create the map itself. There are several ways to generate maps procedurally, and eventually we will implement a few of them, but let’s start with the easiest one… We start out with a screen filled with solid walls. We will carve out our map from this.

To do this, we need to change our CreateTiles method in level.go.

//createTiles creates a map of all walls as a baseline for carving out a level.
func (level *Level) createTiles() []MapTile {
	gd := NewGameData()
	tiles := make([]MapTile, gd.ScreenHeight*gd.ScreenWidth)
	index := 0
	for x := 0; x < gd.ScreenWidth; x++ {
		for y := 0; y < gd.ScreenHeight; y++ {
			index = level.GetIndexFromXY(x, y)
			wall, _, err := ebitenutil.NewImageFromFile("assets/wall.png")
			if err != nil {
				log.Fatal(err)
			}
			tile := MapTile{
				PixelX: x * gd.TileWidth,
				PixelY: y * gd.TileHeight,
				Blocked: true,
				Image: wall,
			}
			tiles[index] = tile
		}
	}
	return tiles
}

Well that just got a lot more simple. Now instead of detecting if the tile was around the edges, we are just making every tile a wall and marking it as blocked.

Of course, this makes it difficult for our player, as the player will spawn into solid rock. Poor player. We should fix this by carving out some rooms.

First, we will add a new method to dice.go. For now, it’s pretty brute-force, but it will serve its purpose.

//Returns a number between the two numbers inclusive.
func GetRandomBetween(low int, high int) int {
	var randy int = -1
	for {
		randy = GetDiceRoll(high)
		if randy >= low {
			break
		}
	}
	return randy
}

For this algorithm, all rooms will be rectangular. Given that, we should probably make a rect type to meet our needs.

Create a new file and name is rect.go. Open it up and add the following.

package main

type Rect struct {
	X1 int
	X2 int
	Y1 int
	Y2 int
}

This defines the top left and bottom right points of the rectangle, which is enough to infer all other points inside it. It should be obvious that I want to make a constructor function for this.

func NewRect(x int, y int, width int, height int) Rect {
	return Rect{
		X1: x,
		Y1: y,
		X2: x + width,
		Y2: y + height,
	}
}

Two very useful things to know about a rectangle are its center point and whether or not it intersects with another rectangle. Let’s implement those two functions and put them onto the rect type.

func (r *Rect) Center() (int, int) {
	centerX := (r.X1 + r.X2) / 2
	centerY := (r.Y1 + r.Y2) / 2
	return centerX, centerY
}

func (r *Rect) Intersect(other Rect) bool {
	return (r.X1 <= other.X2 && r.X2 >= other.X1 && r.Y1 <= other.Y1 && r.Y2 >= other.Y1)
}

This gives us what we need to implement the CreateRoom function so we can carve out a room. Open level.go and add this to our level struct:

	Rooms []Rect

This gives us a place to hold our rooms once we create them. Speaking of creating rooms, we will need to add this function to the file also:

func (level *Level) createRoom(room Rect) {
	for y := room.Y1 + 1; y < room.Y2; y++ {
		for x := room.X1 + 1; x < room.X2; x++ {
			index := level.GetIndexFromXY(x, y)
			level.Tiles[index].Blocked = false
			floor, _, err := ebitenutil.NewImageFromFile("assets/floor.png")
			if err != nil {
				log.Fatal(err)
			}
			level.Tiles[index].Image = floor
		}
	}
}

This function simply takes a given Rect and turns all tiles inside that Rect into floor tiles (additionally changing the blocked property to false). Now we need to define some reasonable parameters for our level.

  • The minimum size of the room in tiles: Let’s say 6, meaning the smallest room can be 3 x 2. The maximum size of the room in tiles:
  • Let’s say 10 on this one. The maximum number of rooms to create:
  • Let’s use 30.

These numbers can be tweaked to your own liking. I just personally like the maps they create when I settled on them. Feel free to experiment and see what suits you the most. Next we loop through our carving routine a number of times equal to our Max Rooms (so we cannot get more than this, but may end up with less) and see if we can add a room. We won’t allow rooms to intersect one another, so we check that the new room doesn’t step on an existing one. If all is good, we create it.

//GenerateLevelTiles creates a new Dungeon Level Map.
func (level *Level) GenerateLevelTiles() {
	MIN_SIZE := 6
	MAX_SIZE := 10
	MAX_ROOMS := 30

	gd := NewGameData()
	tiles := level.createTiles()
	level.Tiles = tiles

	for idx := 0; idx < MAX_ROOMS; idx++ {
		w := GetRandomBetween(MIN_SIZE, MAX_SIZE)
		h := GetRandomBetween(MIN_SIZE, MAX_SIZE)
		x := GetDiceRoll(gd.ScreenWidth-w-1) - 1
		y := GetDiceRoll(gd.ScreenHeight-h-1) - 1

		new_room := NewRect(x, y, w, h)
		okToAdd := true
		for _, otherRoom := range level.Rooms {
			if new_room.Intersect(otherRoom) {
				okToAdd = false
				break
			}
		}
		if okToAdd {
			level.createRoom(new_room)
			level.Rooms = append(level.Rooms, new_room)
		}
	}
}

Once that is in place, we need to actually use it. The level constructor function seems like a great place to apply this change:

func NewLevel() Level {
	l := Level{}
	rooms := make([]Rect, 0)
	l.Rooms = rooms
	l.GenerateLevelTiles()
	return l
}

Now instead of simply creating solid rock, we have created a series of rooms embedded in solid rock. Of course, this has a couple flaws. Firstly, there are no corridors to connect the rooms, making this a pretty useless level. Don’t worry, we will get to that on the next tutorial. The other flaw is, the player may spawn in solid rock still, which would be rather uncomfortable. Let’s address that.

The first step is to change the world.go file. In the InitializeWorld function, change the signature to add the starting level.

func  InitializeWorld(startingLevel  Level) (*ecs.Manager, map[string]ecs.Tag)

This will allow us to access the level and know where to safely put our player. Now, in that same function, right before you create the player entity, add the following:

//Get First Room
startingRoom := startingLevel.Rooms[0]
x, y := startingRoom.Center()

This gets us the coordinates for a tile in the center of the first room created. That assures us that the player will spawn in an empty square, as is as deterministic as it gets in a procedurally generated map.

Now, look at the entity creation for the player. Where we add the position component (as 40 and 25) replace it with this:

AddComponent(position, &Position{
	X: x,
	Y: y,
})

Now the player will spawn in the appropriate place. However, we have one more change to make (if you tried to compile, you will have seen it). We changed the signature of InitializeWorld, so we need to go into main.go and change where it’s called to add the parameter.

func NewGame() *Game {
	g := &Game{}
	g.Map = NewGameMap()
	world, tags := InitializeWorld(g.Map.CurrentLevel)
	g.WorldTags = tags
	g.World = world
	return g
}

Note that we also moved the call to InitializeWorld under where the game map is created (otherwise we don’t have a map or current level).

At this point, you now have rooms and the player will spawn in the middle of a room. The player can move, but the room walls will stop them. Running it will look something like this (remember that it’s random, so the room placement and size will vary):

rooms

As always, if you have any questions, feel free to email me at fatoldyeti@gmail.com or @idiotcoder on the gophers slack.