Fixing Raycasting (Cosplore3D Pt:2)

Chig Beef - Feb 2 - - Dev Community

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
Enter fullscreen mode Exit fullscreen mode

Each level has data, which is a 2D slice of Tiles, which correspond to each digit in the file above. The level also has a name.

type Level struct {
    name string
    data [][]Tile
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 Tiles 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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++
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

Raycaster Visuals

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .