Intro
This is a new series following Cosplore3D, a raycaster game to learn 3D graphics. This project is part of 12 Months 12 Projects, a challenge I set myself.
Sophisticated Levels
Currently our levels are stores in a very simple way. This leads to many features that we are missing out on.
- Colored flooring for different levels.
- Colored skies for different levels.
- A broader range for tiles and enemies.
Step 3 is easy enough to fix, so we'll do that first.
Adding Commas
Adding commas in-between tile codes should fix this issue, allowing for an arbitrary range of codes. Here is what a line of a level used to look like.
10000001
Remember, 1
is a wall and 0
is air. This is nice and compact, and probably the simplest way to store the level. The issue here, however, is that we can only use the numbers from 0 to 9. Now, I could be fancy and use ASCII so that we can use letters and symbols to allow for way more codes, but I feel like that's not exactly fixing the problem.
Here is my solution.
1,0,0,0,0,0,0,1
That's a bit more verbose, but definitely works. In this way, I could have the number 24, 119, or even numbers past a million if I really wanted.
Floors & Skies
Now to fix the other 2 problems, floors and skies. We can use the first line of the level file to hold 2 RGB values, like so.
255,0,0|0,255,0
Pretty intense coloring, but that's fine for now. Let's see what our code can do.
That looks pretty good, it definitely adds to our graphics quite well.
Images On Walls
I think it's time we start creating images on the walls. It's kind of boring seeing simple colors, and it will really push the game to a new level by adding this. From a previous post we had a picture of a wall, we're just going to use that.
Here is the drawing code, which is only a small part of all the code we ended up adding.
imgs := *t.imageCols
img := imgs[0]
op := ebiten.DrawImageOptions{}
op.GeoM.Scale(1, lineH/float64(img.Bounds().Dy()))
op.GeoM.Translate(lineX, y)
screen.DrawImage(img, &op)
Now, images are saved as a slice of images, with each image being a column of the whole image. At the start, we get the slice of images from the tile, and we get the first column (we will work on calculating all columns later). We then make the image taller (or shorter) so it's the same size as the line we would've drawn before. we move it to the x position it needs to be at, and also move it down. This means the column should end up in exactly the same place, and be the same size. Lastly, we draw that image.
Now, how do we get these images? Firstly, I store all images in the Game
object, and tiles don't have their own image, but have pointers to the Game
's images. This saves me from calculating the same thing for every Tile
, but instead do it once.
func create_image_columns(g *Game, keys []string) {
for _, key := range keys {
img := g.images[key]
imgW, imgH := img.Size()
images := make([]ebiten.Image, imgW) // Since we know the size we can initialise with size
var newImageebiten.Image
for i := 0; i < imgW; i++ {
newImage, _ = ebiten.NewImage(1, imgH, ebiten.FilterDefault)
for j := 0; j < imgH; j++ {
clr := img.At(i, j)
newImage.Set(0, j, clr)
}
images[i] = newImage
}
g.imageColumns[key] = &images
}
}
Now, keys
is used so that we can choose which images we turn into columns. We convert images that are already loaded into g
. For every column of the image, we need to make a new image, and we can do this by creating a blank image with width 1 and height to match the original image. We can then copy the pixels from the original image to the new image.
The Result
That was a lot of work, and it was really weird (images are saved in the property with type map[string]*[]*ebiten.Image
). I also hit issues with loading all images before the game started, as ebiten wouldn't allow me, and after some research it seems like I can't read and write individual pixels while outside of the game loop. But after all that, here is our result.
That's looking pretty good so far, but now we have to not just use the first column, but the whole image. This actually turned out to be pretty simple.
subX := int(rx) % tileSize
tx := int(float64(subX) * float64(len(imgs)) / float64(tileSize))
img := imgs[tx]
All we do is find how far in the x position we are from the tile border. We then scale that value from a world range to an image range. Lastly, we use that new value to find the correct column.
Now, a small issue we have is that every image is facing the same way, when images behind you should backwards. This is simply fixed by finding out if the ray is pointing down. If it is, then we just use the distance between the next border, not the last border.
The Other Walls
Now I know that it looks like I missed the side walls, but don't worry, I didn't, and there isn't much code to fix that.
var subX int
if isV {
subX = int(ry) % tileSize
if ra < 90 || ra > 270 {
subX = tileSize - 1 - subX
}
} else {
subX = int(rx) % tileSize
if ra > 180 {
subX = tileSize - 1 - subX
}
}
isV
is a boolean variable that shows whether the ray that hit a Tile
hit the vertical or horizontal side.
That looks good now. Of course, we don't have our shading as things are further away as we did with the regular line. It's way too slow to calculate that for every column every frame, I even tried the fast inverse square root algorithm, and that was too slow. Here are some potential solutions.
- I'm missing something in Ebiten's code and I could do this in a much simpler way
- I could create a smart inverse square root function that uses caching to only calculate numbers it hasn't already.
- Make shading stupid
For now, we're going with option 3, which is going to use this line of code.
op.ChangeHSV(0, 255, float64(fastInvSqrt(float32(int(disT)/tileSize + 1))))
Here we are using division with integers, which creates integers. By doing this, we reduced complexity immensely, but we also introduced heavy rounding, which gives us this effect.
You can see the lines where the tiles get darker, they're very obvious, but I would rather this than completely flat looking walls, so I'm keeping this until I decide to fix this issue.
Next
Adding images to the walls was a big and great step for our game, and really pushed it into looking like a real playable one. We need to start making level one, which is the planet Ankaran. Ankaran is an orange (like Mars) planet, and I think we'll keep the little blobs because they'll stand out against the orange. This planet also needs some music in the background, which could also be a great addition.