Grogue: A Roguelike Tutorial in Go (Part 4)

thecal714

Sean Callaway

Posted on March 12, 2024

Grogue: A Roguelike Tutorial in Go (Part 4)

Happy New Year and welcome back to part 4 of my Rougelike tutorial in Go! (EDIT: It's been 2024 for a while now, as I've been busy and wasn't able to get this published when I initially intended.)

We now have a dungeon that we can move around, but we're not really exploring it if we can see it all from start. We should implement a "field of view" for the player to allow only a limited range around them to be seen.

This can be a rather complicated thing to code, but luckily norendren had already created a library that will do the hard work for us.

Let's start by adding the following import to level.go:

    "github.com/norendren/go-fov/fov"
Enter fullscreen mode Exit fullscreen mode

Then add an *fov.View to the Level structure.

    PlayerView *fov.View
Enter fullscreen mode Exit fullscreen mode

We also need to instantiate this View, so in NewLevel() add the following:

    l.PlayerView = fov.New()
Enter fullscreen mode Exit fullscreen mode

Save the file and run go mod tidy to pull down the new library.

The next thing to do is to modify our Level's Draw() to only render what the player can see. We'll do this by wrapping the four lines inside the nested loops in an if statement, as seen below.

// Draw the current level to 'screen'.
func (level *Level) Draw(screen *ebiten.Image) {
    gd := NewGameData()
    for x := 0; x < gd.ScreenWidth; x++ {
        for y := 0; y < gd.ScreenHeight; y++ {  // NEW
            if level.PlayerView.IsVisible(x, y) {
                tile := level.Tiles[GetIndexFromCoords(x, y)]
                op := &ebiten.DrawImageOptions{}
                op.GeoM.Translate(float64(tile.PixelX), float64(tile.PixelY))
                screen.DrawImage(tile.Image, op)
            } // NEW
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

For this to work, we need to tell the event system to calculate the player's field of view. Add the new line to the if !tile.Blocked {} in HandleInput() in event.go:

    if !tile.Blocked {
        g.Player.X += dx
        g.Player.Y += dy
        g.CurrentLevel.PlayerView.Compute(g.CurrentLevel, g.Player.X, g.Player.Y, 8)  // NEW
    }
Enter fullscreen mode Exit fullscreen mode

At this time, you may notice that your editor (or the go CLI, if you tried to run) complains about g.CurrentLevel in the call to Compute().

./event.go:27:37: cannot use g.CurrentLevel (variable of type *Level) as fov.GridMap value in argument to g.CurrentLevel.PlayerView.Compute: *Level does not implement fov.GridMap (missing method InBounds)

This library needs us to add a few functions to Level{}, so let's go back to level.go and add them now.

// Determine if tile coordinates are on the screen.
func (level Level) InBounds(x int, y int) bool {
    gd := NewGameData()
    if x < 0 || x > gd.ScreenWidth || y < 0 || y > gd.ScreenHeight {
        return false
    }
    return true
}

// Determines if a tile at a given coordinate can be seen through.
func (level Level) IsOpaque(x int, y int) bool {
    return level.Tiles[GetIndexFromCoords(x, y)].Opaque
}
Enter fullscreen mode Exit fullscreen mode

Now if we run the game, it should look something like this.

FOV Active

Drawing the Past

It would be cool if we showed the map tiles that we've already seen (though not the monsters that may have moved into those rooms). In order to implement such a change, we should add a flag to our MapTile{} to indicate whether or not it has been seen.

    Seen    bool
Enter fullscreen mode Exit fullscreen mode

This should be set to false by default in NewTile().

    tile := MapTile{
        PixelX:  x,
        PixelY:  y,
        Blocked: blocked,
        Opaque:  opaque,
        Seen:    false,   // NEW
        Image:   image,
    }
Enter fullscreen mode Exit fullscreen mode

We'll want to render these seen-but-out-of-sight tiles in a different color, so we'll use the colorm portion of the Ebitengine. Add the following line to your imports in level.go:

    "github.com/hajimehoshi/ebiten/v2/colorm"
Enter fullscreen mode Exit fullscreen mode

Refactor the inside of the nested for loops in Draw() again.

            idx := GetIndexFromCoords(x, y)
            tile := level.Tiles[idx]
            if level.PlayerView.IsVisible(x, y) {
                op := &ebiten.DrawImageOptions{}
                op.GeoM.Translate(float64(tile.PixelX), float64(tile.PixelY))
                screen.DrawImage(tile.Image, op)
                level.Tiles[idx].Seen = true
            } else if tile.Seen {
                op := &colorm.DrawImageOptions{}
                var colorM colorm.ColorM
                op.GeoM.Translate(float64(tile.PixelX), float64(tile.PixelY))
                colorM.Translate(0, 0, 50, 0.75)
                colorm.DrawImage(screen, tile.Image, colorM, op)
            }
Enter fullscreen mode Exit fullscreen mode

We move the assignment of the tile variable outside of the if statement, as we need it in both conditions. The visible condition should look the same, except that we set the tile's Seen flag to true.

The else if part is more interesting. We use the colorm package to do the GeoM transformation (placing it in the right part of the screen), to perform a color matrix transformation (tinting it blue), and to draw the tinted image.

Run the game and you'll see something like this after moving around a bit.

Explored rooms are drawn

Slowing Down

With FOV done, we should solve the main issue we currently have: the player moves too fast. Instead of moving the player on each call to Game.Update(), let's ensure some time has passed first.

In main.go add the following to our Game struct:

    TickCount    int
Enter fullscreen mode Exit fullscreen mode

and set it to zero in NewGame():

    g.TickCount = 0
Enter fullscreen mode Exit fullscreen mode

Now, modify Game.Update() to increment TickCount and only call HandleInput() when TickCount is greater than five.

func (g *Game) Update() error {
    g.TickCount++

    if g.TickCount > 5 {
        HandleInput(g)
        g.TickCount = 0
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

When you run the game now, the player should move at a much more reasonable rate.

You can view the complete source code here and if you have any questions, please feel free to ask them in the comments.

💖 💪 🙅 🚩
thecal714
Sean Callaway

Posted on March 12, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related