This content originally appeared on DEV Community and was authored by Chig Beef
First question, what is Hovertank One? Remember DOOM, Quake, and Wolfenstein. Hovertank One was before them all, created by id software. This is probably going to be the only 3D game in this series. Even then, this game is not truly 3D, but will appear to be so.
If you don’t know the challenge already, it’s to make it in one day. Before we start coding, and start that timer, we fully design a spec. This way we don’t feel cheated for forgetting something at the end. If our application adheres to our concrete design list, we’ve made Hovertank One!
It’s been a while, and I felt this would be a big one to do as a comeback.
I’ll be using Golang and Ebitengine. I’ll also be using my own UI code. All other code will be written within on day, and hopefully you can do the same. Choose your own language, game engine, whatever, the idea of the code should be the same.
From the User’s Perspective
First, we’re in a different perspective, so we need to implement that. The world is built on a grid. There are enemies that you can shoot, and that will chase you down. There are civilians that you can save. The walls can have different colors, and the floor and ceiling must be shaded. Walls facing one way are darker that walls facing the other way. The player can move forwards and backwards, and turns with the keyboard as well. There will be three phases, game, win, and lose. Win will occur once the player saves all civilians. Lose will occur if an enemy destroys the player.
A minimap (useful for debugging).
Shortlist
- The ceiling (or sky) is black
- The floor is grey
- The world is grid based
- Lose screen
- Win screen
- Reset logic
- Colored walls
- Different shading depending on wall direction
- Sprite drawing
- Enemy logic
- Shooting logic
- Player movement
- Civilians and civilian logic
- Minimap
- Collision
First Steps
First up of course, we get our blank window up, looks good.
To start creating our grid, we’re going to define the types of tiles we can make. Here you can see that we have empty space that entities can walk through. We also have colored walls to create variation. The player’s spawn is important to have, as well as spawns for civilians and enemies. These spawn tiles will be replaced with empty tiles once the entity has spawned.
type TileCode int
const (
TC_EMPTY TileCode = iota
TC_WHITE_WALL
TC_RED_WALL
TC_GREEN_WALL
TC_BLUE_WALL
TC_SPAWN
TC_CIVIL
TC_ENEMY
)
I’ve also created a grid using there tile codes. See if you can understand how the layout looks!
var rawGrid = [][]int{
{1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
{2, 5, 2, 0, 0, 0, 0, 0, 0, 0, 1, 0, 6, 0, 6, 1},
{2, 0, 2, 0, 6, 0, 0, 0, 0, 0, 0, 0, 1, 7, 0, 1},
{2, 0, 2, 0, 0, 0, 0, 0, 0, 0, 4, 1, 1, 4, 1, 1},
{1, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 1},
{1, 1, 1, 0, 0, 5, 0, 0, 0, 0, 4, 0, 0, 4, 0, 1},
{1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1},
{1, 1, 0, 0, 0, 6, 0, 0, 0, 0, 4, 1, 1, 4, 0, 1},
{1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1},
{1, 1, 0, 0, 3, 3, 3, 3, 3, 3, 1, 0, 1, 1, 0, 1},
{1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 1},
{1, 1, 0, 0, 1, 6, 7, 0, 0, 0, 0, 0, 1, 1, 0, 1},
{1, 1, 6, 7, 1, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1},
{1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1},
{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1},
{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
}
Now let’s make actual tiles. Since spawn tiles get converted to empty tiles, we don’t have to worry about them when dealing with tile logic.
type Tile struct {
color color.RGBA
solid bool
}
Tiles are extremely simple, and don’t even need to hold their own position. There are cases when it is useful to hold the position, but the indices of the tile in the grid should suffice.
Now we have to load our premade grid into real tiles, by converting integers into tiles and entities. We will skip entities for now, but will return to them soon.
func (g *Game) loadGrid(rawGrid [][]int) {
g.grid = make([][]Tile, len(rawGrid))
for r := range len(rawGrid) {
g.grid[r] = make([]Tile, len(rawGrid[r]))
for c := range len(rawGrid[r]) {
switch TileCode(rawGrid[r][c]) {
case TC_EMPTY:
g.grid[r][c] = Tile{color.RGBA{0, 0, 0, 255}, false}
case TC_WHITE_WALL:
g.grid[r][c] = Tile{color.RGBA{255, 255, 255, 255}, true}
case TC_RED_WALL:
g.grid[r][c] = Tile{color.RGBA{255, 0, 0, 255}, true}
case TC_GREEN_WALL:
g.grid[r][c] = Tile{color.RGBA{0, 255, 0, 255}, true}
case TC_BLUE_WALL:
g.grid[r][c] = Tile{color.RGBA{0, 0, 255, 255}, true}
// Entities
case TC_SPAWN:
g.grid[r][c] = Tile{color.RGBA{0, 0, 0, 255}, false}
case TC_CIVIL:
g.grid[r][c] = Tile{color.RGBA{0, 0, 0, 255}, false}
case TC_ENEMY:
g.grid[r][c] = Tile{color.RGBA{0, 0, 0, 255}, false}
}
}
}
}
Now, we kind of want to be able to see if we’re loading correctly. Because of this, I saw we should start working on the minimap now. Firstly, the minimap will be a seperate image that we later blit to the screen.
g.minimap = ebiten.NewImage(75, 75)
Now, all we need to do is loop over each tile and draw it to this new screen.
func (g *Game) drawMinimap(screen *ebiten.Image) {
// Calculate how big we should make each minimap tile
tileWidth := float32(g.minimap.Bounds().Dx()) / float32(len(g.grid[0]))
tileHeight := float32(g.minimap.Bounds().Dy()) / float32(len(g.grid))
// Draw each tile
for r := range len(g.grid) {
for c := range len(g.grid[r]) {
vector.DrawFilledRect(g.minimap, float32(c)*tileWidth, float32(r)*tileHeight, tileWidth, tileHeight, g.grid[r][c].color, false)
}
}
op := ebiten.DrawImageOptions{}
op.GeoM.Translate(SCREEN_WIDTH-float64(g.minimap.Bounds().Dx()), SCREEN_HEIGHT-float64(g.minimap.Bounds().Dy()))
screen.DrawImage(g.minimap, &op)
}
Looks pretty good!
Now we’re going to get an easy one out of the way by drawing the floor and ceiling.
func (g *Game) drawBounds(screen *ebiten.Image) {
vector.DrawFilledRect(screen, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT/2, color.RGBA{0, 0, 0, 255}, false)
vector.DrawFilledRect(screen, 0, SCREEN_HEIGHT/2, SCREEN_WIDTH, SCREEN_HEIGHT/2, color.RGBA{128, 128, 128, 255}, false)
}
For those thinking in 2D, this just looks like 2 rectangles, which is just silly. Obviously, it’s an infinite grey plane.
Now, if we want to start being able to see the world, we need to place the player in it, so let’s do that.
type Player struct {
x, y float64
w, h float64
angle float64
shotCooldown int
}
And we can spawn a new player using the grid.
case TC_SPAWN:
g.grid[r][c] = Tile{color.RGBA{0, 0, 0, 255}, false}
g.player = &Player{
x: float64(c)+0.5,
y: float64(r)+0.5,
w: 0.5,
h: 0.5,
}
And, to see that this works, let’s draw the player on the minimap as a yellow square.
// Draw the player
vector.DrawFilledRect(
g.minimap,
float32(g.player.x-g.player.w/2)*tileWidth,
float32(g.player.y-g.player.h/2)*tileHeight,
float32(g.player.w)*tileWidth,
float32(g.player.h)*tileHeight,
color.RGBA{255, 255, 0, 255},
false,
)
Now we may as well place the enemies. But first, we must define what an enemy is.
type Enemy struct {
x, y float64
w, h float64
img *ebiten.Image
}
The game will store a list of these enemies, and we just need to populate it in the grid loading code.
case TC_ENEMY:
g.grid[r][c] = Tile{color.RGBA{0, 0, 0, 255}, false}
g.enemies = append(g.enemies, &Enemy{
x: float64(c)+0.5,
y: float64(r)+0.5,
w: 0.5,
h: 0.5,
})
And simply enough, let’s draw a magenta square where the enemies are on the minimap.
Now we just do the same for civillians.
Let’s start the most interesting part of this project, the raycasting. Raycasting is a method to convert a 2D world into what seems to be 3D. The camera (which will be the player) casts rays out across their entire field of view. When a ray hits a wall, we can use the distance it traveled to draw a vertical line on screen. We can also use the color of the wall in our drawing. While going through our code, we’re going to also show what we’re doing on the minimap to make it easy to visualise. First, mathematics, how do we cast rays? Where do we cast rays? So on.
Here is a diagram of our player projecting, and where the screen is in 2D space. One thing we must use is the field of view and width of the screen to get the distance between the player and the screen center. This value will be useful later, but let’s define it.
If you know you trigonometry, you’ll notice that the tangent of half the field of view is equal to half the screen width over our magic value.
Therefore:
tan(FOV/2) = (SCREEN_WIDTH/2) / SCREEN_DIS
Rearranging gives us:
SCREEN_DIS * tan(FOV/2) = (SCREEN_WIDTH/2) // Multiply both sides by SCREEN_DIS
SCREEN_DIS = (SCREEN_WIDTH/2) / tan(FOV/2) // Divide both sides by tan(FOV/2)
Perfect! That’s step one done.
Next, we need to figure out how to cast the rays evenly. You may expect that we’ll cast rays at even degree increments, but this would be an incorrect implementation. The reason for this is because when these rays pass through the “screen”, they don’t evenly line up per pixel. More rays will hit in the center, so when we draw the screen, the center will be stretched out warping our visuals. This way, we need to cast rays in such a way that they land on the screen at even increments.
In this image, we know i
, and SD (SCREEN_DIS). i
will loop over all pixels on the screen, starting from the far left pixel column, to the far right. We just need to calculate a
, and luckily for us, we’ve use similar trigonometry just before.
tan(a) = i / SCREEN_DIS
Rearranging gives us:
a = atan(i / SCREEN_DIS) // Apply inverse tangent to both sides
So now we know the angle of each ray we need to shoot. Of course, we must also add the player’s angle. With each ray, we just get the distance and color of the wall we hit, and draw a line based on this information.
func (g *Game) drawWorld(screen *ebiten.Image) {
const FOV = 90
const HALF_FOV = float64(FOV>>1)
const HALF_SCREEN_WIDTH = float64(SCREEN_WIDTH>>1)
const HALF_SCREEN_HEIGHT = float64(SCREEN_HEIGHT>>1)
screenDis := HALF_SCREEN_WIDTH / math.Tan(HALF_FOV)
for i := -HALF_SCREEN_WIDTH; i < HALF_SCREEN_WIDTH; i++ {
a := math.Atan(float64(i)/screenDis) + g.player.angle
dis, clr := g.castRay(a)
// Didn't hit anything
if dis == -1 {
continue
}
vector.DrawFilledRect(screen, float32(i+HALF_SCREEN_WIDTH), 10, 1, 100, clr, false)
}
}
We’re just drawing a line if we hit anything for now, as we’ll do distance to height calculation later. And with that, we’re ready to cast each ray!
The secret to raycasting is that there are actually 2 rays that get cast. One ray check horizontal grid lines, and the other check vertical. Splitting this logic may seem unnecessary, but the seperation of concerns makes ray travel a simply addition. Let’s start with the horizontal line check. In this check we have to find the starting position of the ray, and the travel distance for each iteration. The starting position of the ray is the first horizontal grid line we would encounter. First, we’re going to find the ratio for moving in the x and the y direction. This tells us that for every step in the y direction, how far we should move in the x.
This ratio makes it very easy to find the starting position of the ray, and the travel distance.
ratio := math.Cos(a) / math.Sin(a)
Now, we need to find the distance in the y coordinate from our player’s position, and the next horizontal line.
iter := 0
if a == 0 || a == math.Pi { // Perfectly parallel
// Don't even try
iter = VIEW_DIS
} else if a > math.Pi { // Looking upwards
ratio := math.Cos(a) / math.Sin(a)
sy := float64(int(g.player.y))
sx := g.player.x + (sy-g.player.y)*ratio
dy := -1.0
dx := dy * ratio
} else { // Looking downwards
ratio := math.Cos(a) / math.Sin(a)
sy := float64(int(g.player.y))+1
sx := g.player.x + (sy-g.player.y)*ratio
dy := 1.0
dx := dy * ratio
}
Hopefully this code above makes sense, but if it doesn’t let’s walk through. iter
will be our counter to see how many times we move the ray forward. If the angle of our ray makes is perfeclty parallel with the horizontal lines, it will never reach a collision point. Because of this, we don’t want to iterate, so we act as if we’ve already exhausted our iterations. Now we have our look up and look down cases. We first calculate the ratio between moving vertically and horizontally. If we’re looking up, we round the player’s y coordinate down, and use the ratio we calculated to get the start x position. Then, we get delta x and y very simply. We will always move up by one tile at a time, so we use -1 for dy, and use ratio to calculate dx. The downward case does the exact same thing, but increases y so that we move downwards. Now we can start iterating.
We want to simply do 3 thing on each loop iteration.
- Are we out of bounds?
- Have we hit a wall?
- Move on.
for iter < VIEW_DIS {
// Out of bounds?
if sx < 0 ||
sy < 0 ||
sy > float64(len(g.grid)) ||
sx > float64(len(g.grid[0])) {
break
}
// Check grid collisions
hit, clr := g.getGridCollision(sx, sy)
if hit {
horColor = clr
horHit = true
disX := sx-g.player.x
disY := sy-g.player.y
horDis = math.Sqrt(disX*disX + disY*disY)
break
}
// Move on
sx += dx
sy += dy
iter++
}
Now, we must do the same check for vertical lines.
// Vertical line check
iter = 0
if a == math.Pi/2 || a == 3*math.Pi/2 { // Perfectly parallel
// Don't even try
iter = VIEW_DIS
} else if a > math.Pi/2 && a < 3*math.Pi/2 { // Looking left
ratio := math.Sin(a) / math.Cos(a)
sx = float64(int(g.player.x))
sy = g.player.y + (sx-g.player.x)*ratio
dx = -1.0
dy = dx * ratio
} else { // Looking right
ratio := math.Sin(a) / math.Cos(a)
sx = float64(int(g.player.x))+1
sy = g.player.y + (sx-g.player.x)*ratio
dx = 1.0
dy = dx * ratio
}
for iter < VIEW_DIS {
// Out of bounds?
if sx < 0 ||
sy < 0 ||
sy > float64(len(g.grid)) ||
sx > float64(len(g.grid[0])) {
break
}
// Check grid collisions
hit, clr := g.getGridCollision(sx, sy)
if hit {
verColor = clr
verHit = true
disX := sx-g.player.x
disY := sy-g.player.y
verDis = math.Sqrt(disX*disX + disY*disY)
break
}
// Move on
sx += dx
sy += dy
iter++
}
Our last step in this function is to make sure we return the correct data. We want to return the nearest collision if we can.
// Get correct data
if !horHit && !verHit {
return -1, color.RGBA{0, 0, 0, 0}
}
if !horHit || verDis < horDis {
return verDis, verColor
}
return horDis, horColor
We’ve got our lines drawing, which means we’re probably doing something right! You may have noticed we use a function called g.getGridCollision
, which we haven’t implemented. Let’s say a ray hits a grid intersection, if that is the case, then there are 4 colors the ray could be. Because of this, we must be careful with how to register this collision. There are 2 main cases for a ray collision.
- The collision is on an intersection between a horizontal and vertical line
- The collision is on either a vertical or horizontal line
func (g *Game) getGridCollision(x, y float64) (bool, color.RGBA) {
horHit := float64(int(y)) == y
verHit := float64(int(x)) == x
var t *Tile
if horHit && verHit {
t = g.getSolidTileAtPos(x-1, y-1)
if t != nil {
return true, t.color
}
t = g.getSolidTileAtPos(x-1, y)
if t != nil {
return true, t.color
}
t = g.getSolidTileAtPos(x, y-1)
if t != nil {
return true, t.color
}
t = g.getSolidTileAtPos(x, y)
if t != nil {
return true, t.color
}
} else if verHit {
t = g.getSolidTileAtPos(x-1, y)
if t != nil {
return true, t.color
}
t = g.getSolidTileAtPos(x, y)
if t != nil {
return true, t.color
}
} else {
t = g.getSolidTileAtPos(x, y-1)
if t != nil {
return true, t.color
}
t = g.getSolidTileAtPos(x, y)
if t != nil {
return true, t.color
}
}
return false, color.RGBA{}
}
func (g *Game) getSolidTileAtPos(x, y float64) (*Tile) {
t := g.getTileAtPos(x, y)
if t == nil {
return nil
}
if !t.solid {
return nil
}
return t
}
func (g *Game) getTileAtPos(x, y float64) (*Tile) {
if x < 0 ||
y < 0 ||
y > float64(len(g.grid)) ||
x > float64(len(g.grid[0])) {
return nil
}
return &g.grid[int(y)][int(x)]
}
Lot of if statements, but that’s okay. What’s good is that it seems to work (for now).
As you can see, we get the colour of the wall we’re looking at (the player is facing east by default). Now we just have to use the distance to the wall to calculate the visual height.
height := float32(WALL_HEIGHT / dis * screenDis )
vector.DrawFilledRect(screen, float32(i+HALF_SCREEN_WIDTH), float32(HALF_SCREEN_HEIGHT)-height/2, 1, height, clr, false)
Very nice! The hardest part is done! I move the player to a more interesting position so that we could see more of what was happening. However, it would be cooler if we could move the player ourselves, so let’s write that now.
func (p *Player) update(g *Game) {
if ebiten.IsKeyPressed(ebiten.KeyA) {
p.angle -= 0.04
}
if ebiten.IsKeyPressed(ebiten.KeyD) {
p.angle += 0.04
}
if ebiten.IsKeyPressed(ebiten.KeyW) {
dx := math.Cos(p.angle) * 0.1
dy := math.Sin(p.angle) * 0.1
p.x += dx
p.y += dy
}
if ebiten.IsKeyPressed(ebiten.KeyS) {
dx := math.Cos(p.angle) * 0.1
dy := math.Sin(p.angle) * 0.1
p.x -= dx
p.y -= dy
}
}
Nice and simple, and from this, I got us this view.
Looks warped? You may think we already fixed the warping beforehand, however, we only fixed one of the warpings. The other warping is due to the distance from the player to the wall. If you were to face a flat wall, you would assume to see a consistent height across the wall, but we don’t. The reason for this is because the rays furthest from the center will have to travel further to hit this wall. Therefore, we need to help out these rays so that our visuals think they hit sooner.
a = g.player.angle - a
dis *= math.Cos(a)
This makes rays further from the center of the screen travel distance shorter.
Let’s check how much we’ve done so far.
[x] The ceiling (or sky) is black
[x] The floor is grey
[x] The world is grid based
[ ] Lose screen
[ ] Win screen
[ ] Reset logic
[x] Coloured walls
[ ] Different shading depending on wall direction
[ ] Sprite drawing
[ ] Enemy logic
[ ] Shooting logic
[x] Player movement
[ ] Civillians and civillian logic
[x] Minimap
[ ] Collision
So looks like we’ve still got quite a bit to do. Don’t fret however, most of what we have left is quite easy.
Shading the walls based on direction is quite simple. If we hit a wall using a vertical ray, make it slightly darker. Let’s implement this.
Here’s a function for darkening colors
func darken(clr color.RGBA, b float64) color.RGBA {
return color.RGBA{
byte(float64(clr.R)*b),
byte(float64(clr.G)*b),
byte(float64(clr.B)*b),
clr.A,
}
}
// The return statement from g.castRay
return verDis, darken(verColor, 0.8)
And this gives us quite a good result with very little effort!
Now for the civilians. Our civilians are going to be angry blobs, because that’s just the art I had. Let’s loop over all civilians, and draw a red vertical line where they are.
func (g *Game) drawEntities(screen *ebiten.Image) {
const FOV = math.Pi/2
const HALF_FOV = float64(FOV/2)
const HALF_SCREEN_WIDTH = float64(SCREEN_WIDTH>>1)
screenDis := HALF_SCREEN_WIDTH / math.Tan(HALF_FOV)
for _, civil := range g.civils {
dx := civil.x - g.player.x
dy := civil.y - g.player.y
dis := math.Sqrt(dx*dx + dy*dy)
// Relative angle to civil
a := math.Acos(dx/dis)
if dy < 0 {
a = -a
}
a -= g.player.angle
if a > HALF_FOV && a < math.Pi*2-HALF_FOV {
// Civil isn't in our view
continue
}
// What pixel column the center of the enemy will be in
i := math.Tan(a) * screenDis + HALF_SCREEN_WIDTH
vector.StrokeLine(screen, float32(i), 0, float32(i), SCREEN_HEIGHT, 5, color.RGBA{255, 0, 0, 255}, false)
}
}
All we’re doing here is getting the angle to the civillian, checking their in our view, then drawing a line where they should end up visually.
I’ve walked around a bit and can confirm that the red lines are appearing as they should. Now let’s draw an image where they should be. The image needs to be sized the same way we did with the walls. We also need the civilians to stand on the ground, which we can do be calculating where the ground will be.
dis *= math.Cos(a)
height := CIVIL_HEIGHT * screenDis / dis
maxHeight := WALL_HEIGHT * screenDis / dis
op := ebiten.DrawImageOptions{}
op.GeoM.Scale(height/float64(imgH), height/float64(imgH))
op.GeoM.Translate(i-float64(height/2), HALF_SCREEN_HEIGHT+maxHeight/2-height)
screen.DrawImage(img, &op)
As you can see, we use maxHeight
to find the floor, then place the civillian on that floor.
Looks good!
Well, at least the one on the right does. The one on the left is in the wall! And so we have to fix the issue of occlusion. We can’t fix occlusion if we can’t remember how far away the walls are, so let’s keep track of it.
// Z buffer definition
zBuffer := [SCREEN_WIDTH]float64{}
// On each ray case
zBuffer[int(i+HALF_SCREEN_WIDTH)] = dis
// Passing info between drawing functions
zBuffer := g.drawWorld(screen)
g.drawEntities(screen, zBuffer)
Now let’s do a simple version of occlusion. If the center of the civil is occluded, we’ll occlude the entire sprite.
// Occlusion
if zBuffer[int(i)] < dis && zBuffer[int(i)] != -1 {
continue
}
This works quite well, and will work for our make in a day case. Now we must be able to save these civilians!
func (p *Player) saveCivils(g *Game) {
for i := 0; i < len(g.civils); i++ {
civil := g.civils[i]
dx := civil.x - p.x
dy := civil.y - p.y
dis := math.Sqrt(dx*dx + dy*dy)
if dis < 1 {
// Save the civil!
g.civils = slices.Delete(g.civils, i, i+1)
i--
}
}
}
if len(g.civils) == 0 {
g.phase = WIN
}
As soon as we get close enough to a civillian, we delete them (…save them). Then in the game’s update we can simply check if there are any civillians left. And this works!
func (g *Game) reset() {
g.phase = PLAY
g.loadGrid(rawGrid)
}
And this takes us straight back into the game again, just like how it started.
Let’s do another progress check
[x] The ceiling (or sky) is black
[x] The floor is grey
[x] The world is grid based
[ ] Lose screen
[x] Win screen
[x] Reset logic
[x] Coloured walls
[x] Different shading depending on wall direction
[x] Sprite drawing
[ ] Enemy logic
[ ] Shooting logic
[x] Player movement
[x] Civillians and civillian logic
[x] Minimap
[ ] Collision
4 left!
Let’s draw those enemies! Side note, I generalized and placed the sprite drawing code in a separate function. This way we don’t have to write all that code again for drawing enemies.
func (g *Game) drawEntities(screen *ebiten.Image, zBuffer [SCREEN_WIDTH]float64) {
const CIVIL_HEIGHT = 0.4
const ENEMY_HEIGHT = 0.8
civilImg := g.vis.GetImage("civil")
for _, civil := range g.civils {
g.drawSprite(screen, civilImg, CIVIL_HEIGHT, civil.x, civil.y, zBuffer)
}
enemyImg := g.vis.GetImage("enemy")
for _, enemy := range g.enemies {
g.drawSprite(screen, enemyImg, ENEMY_HEIGHT, enemy.x, enemy.y, zBuffer)
}
}
See? Made it extremely easy.
We can use similar code used for saving civillians to check if we’ve lost
func (p *Player) checkKilled(g *Game) {
for i := 0; i < len(g.enemies); i++ {
enemy := g.enemies[i]
dx := enemy.x - p.x
dy := enemy.y - p.y
dis := math.Sqrt(dx*dx + dy*dy)
if dis < 0.3 {
// We die!
g.phase = LOSE
}
}
}
Very simple, but we need a defense against this! Now it’s time for shooting logic.
For shooting, we can simply use the ray casting we use for drawing! We just ignore the colour we recieve, but use the distance figure. Using the distance we shoot, and the angle we shoot at, we should easily be able to figure out what enemy we’ve hit.
if p.shotCooldown == 0 {
if ebiten.IsKeyPressed(ebiten.KeySpace) {
p.shotCooldown = FPS
p.shoot(g)
}
} else {
p.shotCooldown--
}
func (p *Player) shoot(g *Game) {
maxDis, _ := g.castRay(p.angle)
if maxDis == -1 {
maxDis = 99999.9
}
const FOV = math.Pi/2
const HALF_FOV = float64(FOV/2)
for i := 0; i < len(g.enemies); i++ {
enemy := g.enemies[i]
dx := enemy.x - g.player.x
dy := enemy.y - g.player.y
dis := math.Sqrt(dx*dx + dy*dy)
if dis > maxDis {
continue
}
// Relative angle to enemy
a := math.Acos(dx/dis)
if dy < 0 {
a = -a
}
a -= g.player.angle
a = boundAngle(a)
if a > HALF_FOV && a < math.Pi*2-HALF_FOV {
// Enemy isn't in our view
continue
}
// Horizontal offset (0 means we hit the enemies center)
offset := math.Abs(math.Tan(a) * dis)
if offset > 0.3 {
continue
}
// We hit the enemy!
g.enemies = slices.Delete(g.enemies, i, i+1)
i--
}
}
This code looks like a mashing of different code we’ve already written. You may notice offset
, which would be used to calculate the pixel column to draw the sprite at. For this, however, it’s used to determine a hit. As you can image, you should hit what is in the center of the screen, therefore, anything close the center is hit.
The final problem on the checklist is enemy logic. I want the enemy to chase the player, and so we’re going to need to use a searching algorithm. We’ll be using dijkstras, as it is relatively simple, and gets us the shortest path. We’ll call this algorithm every update frame on each enemy.
func (e *Enemy) update(g *Game) {
// Try to move to next position
if e.x != e.nextX {
if math.Abs(e.nextX - e.x) < 0.01 {
e.x = e.nextX
} else if e.nextX > e.x {
e.x += 0.01
} else {
e.x -= 0.01
}
return
}
if e.y != e.nextY {
if math.Abs(e.nextY - e.y) < 0.01 {
e.y = e.nextY
} else if e.nextY > e.y {
e.y += 0.01
} else {
e.y -= 0.01
}
return
}
startX := int(e.x)
startY := int(e.y)
endX := int(g.player.x)
endY := int(g.player.y)
type SpaceInfo struct {
visited bool
dis int
parentX, parentY int
}
type Pos struct {
x, y int
parentX, parentY int
}
spaceInfo := make([][]SpaceInfo, len(g.grid))
for r := range len(g.grid) {
spaceInfo[r] = make([]SpaceInfo, len(g.grid[0]))
for c := range len(g.grid) {
spaceInfo[r][c].dis = 99999
}
}
spaceInfo[startY][startX].dis = 0
spaceInfo[startY][startX].parentX = startX
spaceInfo[startY][startX].parentY = startY
queue := []Pos{{startX, startY, startX, startY}}
var pos Pos
for len(queue) != 0 {
pos = queue[0]
queue = queue[1:]
if pos.x < 0 || pos.y < 0 || pos.y >= len(g.grid) || pos.x >= len(g.grid[0]) {
continue
}
if g.grid[pos.y][pos.x].solid {
continue
}
if pos.x == endX && pos.y == endY {
break
}
newDis := getMovementDistance(startX, startY, pos.x, pos.y)
if spaceInfo[pos.y][pos.x].visited {
if spaceInfo[pos.y][pos.x].dis <= newDis {
continue
}
}
spaceInfo[pos.y][pos.x].visited = true
spaceInfo[pos.y][pos.x].dis = newDis
spaceInfo[pos.y][pos.x].parentX = pos.parentX
spaceInfo[pos.y][pos.x].parentY = pos.parentY
queue = append(queue, Pos{pos.x-1, pos.y, pos.x, pos.y})
queue = append(queue, Pos{pos.x+1, pos.y, pos.x, pos.y})
queue = append(queue, Pos{pos.x, pos.y-1, pos.x, pos.y})
queue = append(queue, Pos{pos.x, pos.y+1, pos.x, pos.y})
}
// Couldn't find path
if pos.x != endX || pos.y != endY {
return
}
curTile := spaceInfo[pos.parentY][pos.parentX]
nextTile := spaceInfo[curTile.parentY][curTile.parentX]
for curTile.parentX != startX || curTile.parentY != startY {
pos.x = curTile.parentX
pos.y = curTile.parentY
curTile = nextTile
nextTile = spaceInfo[curTile.parentY][curTile.parentX]
}
// Now we have the position of the next tile, set the enemy there
e.nextX = float64(pos.x)+0.25
e.nextY = float64(pos.y)+0.25
}
Just a bit of code… Either way, it works! Now we have a fairly playable game.
There’s one more thing, collision. Collision should be quite easy, just check the position the player is trying to move to, and check for solid tiles.
// Player update
newX := p.x
newY := p.y
if ebiten.IsKeyPressed(ebiten.KeyW) {
dx := math.Cos(p.angle) * 0.05
dy := math.Sin(p.angle) * 0.05
newX += dx
newY += dy
}
if ebiten.IsKeyPressed(ebiten.KeyS) {
dx := math.Cos(p.angle) * 0.05
dy := math.Sin(p.angle) * 0.05
newX -= dx
newY -= dy
}
if !g.checkCollide(newX, newY, p.w, p.h) {
p.x = newX
p.y = newY
}
func (g *Game) checkCollide(x, y, w, h float64) bool {
c := int(x)
r := int(y)
t := g.getTileAtPos(float64(c), float64(r))
if t != nil {
if t.solid {
return true
}
}
c = int(x+w)
r = int(y)
t = g.getTileAtPos(float64(c), float64(r))
if t != nil {
if t.solid {
return true
}
}
c = int(x)
r = int(y+h)
t = g.getTileAtPos(float64(c), float64(r))
if t != nil {
if t.solid {
return true
}
}
c = int(x+w)
r = int(y+h)
t = g.getTileAtPos(float64(c), float64(r))
if t != nil {
if t.solid {
return true
}
}
return false
}
And that’s it, the last check on the checklist.
Done
Wow, I decided to come back with a tougher one here, and it was quite fun. Hopefully from this you understand the rendering we used, and would be able to tinker with the logic yourself. Which leads me to your challenges.
Your Next Steps
You’ve go the code here for you to look at and modify, so here are some ideas for you.
- Walls can be any color
- Add different enemy types
- Walls with different heights
- Textured walls
- Jumping
- Non-perpendicular walls
I haven’t had the spare day to make one of these in a while, so hopefully I get another one soon, any suggestions would help!
This content originally appeared on DEV Community and was authored by Chig Beef