Sean Callaway
Posted on March 12, 2024
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"
Then add an *fov.View
to the Level structure.
PlayerView *fov.View
We also need to instantiate this View, so in NewLevel()
add the following:
l.PlayerView = fov.New()
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
}
}
}
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
}
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
}
Now if we run the game, it should look something like this.
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
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,
}
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"
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)
}
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.
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
and set it to zero in NewGame()
:
g.TickCount = 0
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
}
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.
Posted on March 12, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.