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.
Main
Let's go through the code so far, as I've spent quite a while on it so I'll have to go through most of it. Firstly, we're using Ebitengine to work with graphics. I have a bit of experience with this, but not too much so this will also be a learning point for me.
Levels
First, before we get into how we see the world, we actually have to implement the world. World data is currently saved as a text file, for example.
1111111
1030001
1030001
1030001
1000201
1000001
1411111
Each level has data
, which is a 2D slice of Tile
s, which correspond to each digit in the file above. The level also has a name
.
type Level struct {
name string
data [][]Tile
}
Here is also the Tile
struct, which corresponds to each digit.
type Tile struct {
x float64
y float64
w float64
h float64
code uint8
color color.Color
}
We need to keep its coordinates and its size. By keeping hold of its size will make it easier to implement rectangular prisms in the future. The code
property keeps track of which type of Tile
we have, for example, 0 would be an empty space, and 1 could be a wall. We also have color
, which can change based on the Tile
's code
.
Loading The Levels
Since this is going to be a small game (at least for now) we can load all levels at the start of the game.
func load_levels(tileSize float64) map[string]Level {
levels := make(map[string]Level)
var levelData [][]string
rawLevelData, err := os.ReadFile("maps/levels.json")
if err != nil {
return levels
}
err = json.Unmarshal(rawLevelData, &levelData)
if err != nil {
log.Fatal("failed to load `./maps/levels.json`, file may have been tampered with, reinstall advised")
}
// Get every level held in ./maps/
for i := 0; i < len(levelData); i++ {
fName := levelData[i][0]
lName := levelData[i][1]
rawLevel, err := os.ReadFile("maps/" + fName)
if err != nil {
return levels
}
// Now we have a list of strongs as the data
rawRows := strings.Split(string(rawLevel), "\r\n")
tiles := [][]Tile{}
for row := 0; row < len(rawRows); row++ {
tiles = append(tiles, []Tile{})
for col := 0; col < len(rawRows[row]); col++ {
code, err := strconv.Atoi(string(rawRows[row][col]))
if err != nil {
log.Fatal("failed to load a level correctly")
}
clr := get_color(uint8(code))
tiles[row] = append(tiles[row], Tile{
float64(col) * tileSize,
float64(row) * tileSize,
tileSize,
tileSize,
uint8(code),
clr,
})
}
}
levels[lName] = Level{lName, tiles}
}
return levels
}
Now to understand this code a bit more, it would be good to understand how levels are saved. Each level is saved in the maps folder. In the maps folder there is an extra file called "levels.json". This json file holds both the file for each level, and the name of that level. So the first thing we do is load this json up so we know all the levels to load. We can then loop over all levels to load.
Collision With Tiles
Something that will be important later is checking for collision with a tile.
func (t *Tile) check_hit(x, y int) bool {
if x == int(t.x) || x == int(t.x+t.w) {
if y >= int(t.y) && y <= int(t.y+t.h) {
return true
}
}
if y == int(t.y) || y == int(t.y+t.h) {
if x >= int(t.x) && x <= int(t.x+t.w) {
return true
}
}
return false
}
Now, since rays from a raycaster always end up on tile edges, we can check whether it is on one of the Tile
's horizontal lines, or vertical lines. If it's on either, we then need to check whether it's in the range. If this function returns true
, then the ray has ended up on a point of the outline of the Tile
. It does not check for collision within the Tile
.
The Player
type Player struct {
x float64
y float64
angle float64
camera *Camera
curLevel string
}
Most of this may seem normal for a player object, but the Player
also has a Camera
. Since there is going to be quite a bit of code dedicated to viewing the world, I separated it from the Player
logic
func (p *Player) update() {
if ebiten.IsKeyPressed(ebiten.KeyA) {
p.angle--
}
if ebiten.IsKeyPressed(ebiten.KeyD) {
p.angle++
}
if ebiten.IsKeyPressed(ebiten.KeyW) {
p.x += math.Cos(to_radians(p.angle)) * 2
p.y += math.Sin(to_radians(p.angle)) * 2
}
if ebiten.IsKeyPressed(ebiten.KeyS) {
p.x -= math.Cos(to_radians(p.angle)) * 2
p.y -= math.Sin(to_radians(p.angle)) * 2
}
bound_angle(&p.angle)
p.camera.update_props(p)
}
We are going to control the player with the keyboard first, and we'll implement mouse movement later on. Keyboard is easier for testing. We want to move the player in the direction they are facing using sine and cosine. bound_angle
is a function that keeps an angle between 0 and 360.
Now, if you don't know what sine and cosine are, that's fine, just imagine I have a circle with a point in the middle. The Player
is that point, and we can draw a line from the Player
to the outside of the circle. This line will be drawn at the Player
's angle. If the Player
is at x=0
and y=0
then cosine is the x coordinate of the other end of that line, and sine is the y coordinate. Now if we imagine this line is only 1 unit long, then we have x and y values between -1 and 1 that we can scale, and we can make them as big as we want (say if we want the player to move faster). At some point we will look at tangent in the code, which is just sine divided by cosine.
Logic For Drawing The World
Now it would make sense to start off by only worrying about Tile
s that are solid, which in my case are ones with a code
that isn't 0. Now, we want to cast a ray for every vertical column of the screen, so we create a loop to do this for us. We need another variable to keep track of the angle of the rays we are casting. This value should start at the angle the Player
is facing, with half of the field of view taken away. this means that the last ray ends up at the Player
's angle, plus half the field of view, so that difference in angle between the first and last ray should be the field of view. We cast this ray, and the function we are going to look at after this will return the tile it hit, and how far it went.
Now, you may see that we multiply this distance by a cosine value. This gets rid of the fisheye effect I showed in my last post (I don't want to talk about it).
Lastly, we just find how tall the line we need to draw will be, and where we need to draw it, and we can use the color from the tile to draw a line. To finish it off, we need to increment the angle a bit, so that each ray shoots off at a slightly different angle.
func (c *Camera) draw_world(level Level, screen *ebiten.Image) {
tiles := level.get_solid_tiles()
var ra float64
ra = c.angle - c.fov/2.0
bound_angle(&ra)
ri := c.fov / float64(screenWidth)
for r := 0; r < int(screenWidth); r++ {
t, disT := c.ray_cast(ra, tiles)
a := c.angle - ra
bound_angle(&a)
if a > 180 {
a -= 360
}
disT *= math.Cos(to_radians(a))
lineH := (float64(tileSize) * screenHeight) / disT
if lineH == screenHeight {
lineH = screenHeight
}
lineX := screenWidth - (a/c.fov*screenWidth + screenWidth/2.0)
ebitenutil.DrawLine(screen, lineX, float64(screenHeight)/2.0-lineH/2.0, lineX, float64(screenHeight)/2.0+lineH/2.0, t.color)
ra += ri
bound_angle(&ra)
}
}
Casting Rays
Now we are actually going to look at the real blunt work of a raycaster. The way a raycaster works is that each ray works in 2 ways. First it travels, but only checks for collisions with vertical lines. Then, it tries again, checking for horizontal lines. It then acts like nothing happened and only shows us the collision that occurred with the lowest distance. First, we need to find the direction of the ray.
if ra > 180 && ra != 360 { // Looking up
ry = c.y - float64(int(c.y)%tileSize)
rx = (c.y-ry)*aTan + c.x
yo = -tileSize
xo = -yo * aTan
} else if ra < 180 && ra != 0 { // Looking down
ry = c.y - float64(int(c.y)%tileSize) + tileSize
rx = (c.y-ry)*aTan + c.x
yo = tileSize
xo = -yo * aTan
} else {
rx = c.x
ry = c.y
dof = 8
}
Since we're checking for horizontal lines first, we need to know if the ray is pointing up or down, and especially whether it's exactly left or right. If it's left or right, it's parallel, so the ray will never find a collision.
In the other cases, we move the ray to the nearest horizontal line. We also create xo
and yo
, which are the offsets, which we will use later. dof
is used later to keep us from looking infinitely (or really far).
for dof < 8 {
hit := false
for i := 0; i < len(tiles); i++ {
if tiles[i].check_hit(int(rx), int(ry)) {
dof = 8
hit = true
hx = rx
hy = ry
disH = math.Sqrt(math.Pow(hx-c.x, 2) + math.Pow(hy-c.y, 2))
ht = tiles[i]
break
}
}
if hit {
break
}
rx += xo
ry += yo
dof++
}
Now, we need to keep moving this ray until it hits a horizontal line. We do this by first checking whether we've already hit something. If we have, then we exit the loop. We also keep track of the rays position, how far we have traveled, and which tile we hit. If we didn't make contact, we need to keep travelling, and this is where we simply add xo
and yo
. These offsets move us to the next horizontal line using an addition, which is a relatively fast calculation. We do that same thing for the vertical lines (I'll skip in post but it's still on GitHub).
if disV < disH {
disT = disV
tt = vt
} else {
disT = disH
tt = ht
}
This is how we check which ray hit first, and we only use the tile and distance from that ray, which we return.
The Result
That looks 3D right?
It looks good enough for now, I'm sick of coding 3D graphics, so I'm going to leave it at that and work on something else.
Next
Now that we have a somewhat working graphics engine, we can start working on literally everything else. I'm going to try to work on the storyline of the game along with minor code changes. Hopefully we can get most of the story down, and start to create art and sound for each section of the storyline.