Ray Casting 3D game in Python + PyGame

Introduction

We all remember the old games that first introduced the 3D dimension.

The founder of 3D games was the game wolfenstein 3d, released in 1992

Wolfenstein 3D game (1992)

Wolfenstein 3D game (1992)

and after her Doom 1993 of the year.

DOOM 1993 game (1993)

DOOM 1993 game (1993)

These two games were developed by the same company: ID Software

She created her own engine specifically for this game, and the result was a 3D game, which was considered almost impossible at that time.

But what happens if I say that this is not a 3D game, but just a simulation and the game actually looks something like this?

Inside Wolfenstein 3D

Inside Wolfenstein 3D

In fact, technology is used here Ray Castingthe third dimension simply does not exist here.

What is this very RayCasting, which is relevant even in our times, but is already used not for games, but for ray tracing technology in modern games.

If translated into Russian, then:

Ray casting method(Ray Casting)one of the rendering methods in computer graphics, in which the scene is built based on measurements of the intersection of rays with the rendered surface.

I was wondering how difficult it is to implement.

And I set to writing technology RayCasting.

I will do it in conjunction python+pygame

Pygame allows you to draw simple 2D shapes on a plane, and by dancing with a tambourine around them, I will create a 3D illusion

Implementation of Ray Casting

To begin with, we create the simplest map using symbols to separate when drawing where is the block and where is the empty space.

Map and player on it (under the hood)"." - an empty place where the player can walk"1" - block

The map and the player on it (under the hood)
“.” – an empty place where the player can walk
“1” – block

We draw a map in 2D, and a player with the ability to control and calculate the point of view.

player.delta = delta_time()
player.move(enableMoving)

display.fill((0, 0, 0))

pg.draw.circle(display, pg.Color("yellow"), (player.x, player.y), 0)

drawing.world(player)
class Drawing:
    def __init__(self, surf, surf_map):
        self.surf = surf
        self.surf_map = surf_map
        self.font = pg.font.SysFont('Arial', 25, bold=True)

        
    def world(self, player):
        rayCasting(self.surf, player)
def rayCasting(display, player):
    inBlockPos = {'left': player.x - player.x // blockSize * blockSize,
                  'right': blockSize - (player.x - player.x // blockSize * blockSize),
                  'top': player.y - player.y // blockSize * blockSize,
                  'bottom': blockSize - (player.y - player.y // blockSize * blockSize)}

    for ray in range(numRays):
        cur_angle = player.angle - halfFOV + deltaRays * ray
        cos_a, sin_a = cos(cur_angle), sin(cur_angle)
        vl, hl = 0, 0

The movement will be carried out by adding the cosine of the horizontal angle of view and the sine of the vertical angle of view

class Player: 
  def init(self)
    self.x = 0
    self.y = 0
    self.angle = 0
    self.delta = 0
    self.speed = 100
    self.mouse_sense = settings.mouse_sensivity

  def move(self, active):
    self.rect.center = self.x, self.y
    key = pygame.key.get_pressed()
    key2 = pygame.key.get_pressed()
    cos_a, sin_a = cos(self.angle), sin(self.angle)

    if key2[pygame.K_LSHIFT]:
        self.speed += 5
        if self.speed >= 200:
            self.speed = 200
    else:
        self.speed = 100

    if key[pygame.K_w]:
        dx = cos_a * self.delta * self.speed
        dy = sin_a * self.delta * self.speed
    if key[pygame.K_s]:
        dx = cos_a * self.delta * -self.speed
        dy = sin_a * self.delta * -self.speed
    if key[pygame.K_a]:
        dx = sin_a * self.delta * self.speed
        dy = cos_a * self.delta * -self.speed
    if key[pygame.K_d]:
        dx = sin_a * self.delta * -self.speed
        dy = cos_a * self.delta * self.speed
Visualization of the calculation of the player's movement

Visualization of the calculation of the player’s movement

We get this result:

Intermediate result of launching the game

Intermediate result of launching the game

Next, we need to represent our map as a grid. And in the entire interval of the viewing angle, throw a certain number of rays, the more of them, the better the picture will be, but fewer frames per second.

Each ray must intersect with each vertical and horizontal grid line. As soon as it finds a collision with a block, it draws it to the required size and stops its movement, then the loop goes to the next ray.

Intersection of a ray and lines on a grid

Intersection of a ray and lines on a grid

You also need to calculate the distance to the vertical and horizontal lines with which the beam intersected.

We recall school trigonometry and consider this using the example of vertical lines

Distance to vertical and horizontal lines with which the ray intersected

Distance to vertical and horizontal lines with which the ray intersected

We know the side k is the player’s distance to the block

a is the angle of each beam

Next, we just add the length since we know the size of our grid box.

And when the beam hits the wall, the loop will stop.

Then apply this to all axes with minor changes

For horizontal lines, the same thing only with a sine.

In the distance of the beam, we write the horizontal or vertical distance, depending on what is closer

We add a couple of variables for height, depth, size, which are calculated from fairly simple formulas

def rayCasting(display, player):
  inBlockPos = {'left': player.x - player.x // blockSize * blockSize,
                'right': blockSize - (player.x - player.x // blockSize * blockSize),
                'top': player.y - player.y // blockSize * blockSize,
                'bottom': blockSize - (player.y - player.y // blockSize * blockSize)}

  for ray in range(numRays):
      cur_angle = player.angle - halfFOV + deltaRays * ray
      cos_a, sin_a = cos(cur_angle), sin(cur_angle)
      vl, hl = 0, 0

      #Вертикали
      for k in range(mapWidth):
          if cos_a > 0:
              vl = inBlockPos['right'] / cos_a + blockSize / cos_a * k + 1
          elif cos_a < 0:
              vl = inBlockPos['left'] / -cos_a + blockSize / -cos_a * k + 1

          xw, yw = vl * cos_a + player.x, vl * sin_a + player.y
          fixed = xw // blockSize * blockSize, yw // blockSize * blockSize
          if fixed in blockMap:
              textureV = blockMapTextures[fixed]
              break

      #Горизонтали
      for k in range(mapHeight):
          if sin_a > 0:
              hl = inBlockPos['bottom'] / sin_a + blockSize / sin_a * k + 1
          elif sin_a < 0:
              hl = inBlockPos['top'] / -sin_a + blockSize / -sin_a * k + 1

          xh, yh = hl * cos_a + player.x, hl * sin_a + player.y
          fixed = xh // blockSize * blockSize, yh // blockSize * blockSize
          if fixed in blockMap:
              textureH = blockMapTextures[fixed]
              break

      ray_size = min(vl, hl) * depthCoef
      toX, toY = ray_size * cos(cur_angle) + player.x, ray_size * sin(cur_angle) + player.y
      pg.draw.line(display, pg.Color("yellow"), (player.x, player.y), (toX, toY))

We draw rectangles in the center of the screen, the horizontal position will depend on the beam number, and the height will be equal to the ratio of the given coefficient to the beam length.

#def rayCasting

ray_size += cos(player.angle - cur_angle)
height_c = coef / (ray_size + 0.0001)
c = 255 / (1 + ray_size ** 2 * 0.0000005)
color = (c, c, c)
block = pg.draw.rect(display, color, (ray * scale, half_height - height_c // 2, scale, height_c))

And now it turns out to be some kind of illusion of a 3D dimension.

Illusion of 3D measurements

Illusion of 3D measurements

Textures

1 block has 4 sides and each should be covered with a texture.

We divide each side into strips with a small width, the main thing is that the number of rays falling on the block coincides with the number of strips on the side, and we divide our texture by the number of these strips and alternately draw a strip from the texture onto a strip on the block.

Applying texture stripes to a block

Applying texture stripes to a block

So the width will vary depending on the distance of the side of the block. And the position of the strip is calculated by multiplying the padding by the size of the texture.

If the beam falls on the vertical, then the indent is calculated from the top point, if on the horizontal, then from the left point.

Indent Calculation

Indent Calculation

#def rayCasting

if hl > vl:
    ray_size = vl
    mr = yw
    textNum = textureV
else:
    ray_size = hl
    mr = xh
    textNum = textureH

mr = int(mr) % blockSize

textures[textNum].set_alpha(c)
wallLine = textures[textNum].subsurface(mr * textureScale, 0, textureScale, textureSize)
wallLine = pg.transform.scale(wallLine, (scale, int(height_c))).convert_alpha()
display.blit(wallLine, (ray * scale, half_height - height_c // 2))

We also add the ability to draw several textures on one map by adding special characters to the map, each will be assigned its own texture.

List of my signs for creating levels

List of my signs for creating levels

Here is an example of what the 2nd level looks like in the game as a code:

textMaplvl2 = [
            "111111111111111111111111",
            "1111................1111",
            "11.........1....11...111",
            "11....151..1....31...111",
            "1111............331...11",
            "11111.....115..........1",
            "1111.....11111....1113.1",
            "115.......111......333.1",
            "15....11.......11......1",
            "11....11.......11..11111",
            "111...................51",
            "111........1......115551",
            "11111...11111...11111111",
            "11111%<@1111111111111111",
]

As a result, we get an adequate display of textures:

collision

Where is it seen that we can pass through the blocks …

Adding a collision. We add a so-called collider to each block position and add the same collider to the player. If it continues to go as it was going and at such a pace on the next frame, according to the prediction, it enters the block, then we simply zero out the acceleration along the desired axis.

Block and Player Collision

Block and Player Collision

To do this, let’s add a class Player. I decided to immediately add camera control with the mouse. This is how the class ended up looking like:

class Player:
    def __init__(self):
        self.x = 0
        self.y = 0

        self.angle = 0
        self.delta = 0
        self.speed = 100
        self.mouse_sense = settings.mouse_sensivity

        #collision
        self.side = 50
        self.rect = pygame.Rect(*(self.x, self.y), self.side, self.side)

    def detect_collision_wall(self, dx, dy):
        next_rect = self.rect.copy()
        next_rect.move_ip(dx, dy)
        hit_indexes = next_rect.collidelistall(collision_walls)

        if len(hit_indexes):
            delta_x, delta_y = 0, 0
            for hit_index in hit_indexes:
                hit_rect = collision_walls[hit_index]
                if dx > 0:
                    delta_x += next_rect.right - hit_rect.left
                else:
                    delta_x += hit_rect.right - next_rect.left
                if dy > 0:
                    delta_y += next_rect.bottom - hit_rect.top
                else:
                    delta_y += hit_rect.bottom - next_rect.top
            if abs(delta_x - delta_y) < 50:
                dx, dy = 0, 0
            elif delta_x > delta_y:
                dy = 0
            elif delta_y > delta_x:
                dx = 0

        self.x += dx
        self.y += dy

    def move(self, active):
        self.rect.center = self.x, self.y
        key = pygame.key.get_pressed()
        key2 = pygame.key.get_pressed()
        cos_a, sin_a = cos(self.angle), sin(self.angle)

        if key2[pygame.K_LSHIFT]:
            self.speed += 5
            if self.speed >= 200:
                self.speed = 200
        else:
            self.speed = 100

        self.mouse_control(active=active)

        if key[pygame.K_w]:
            dx = cos_a * self.delta * self.speed
            dy = sin_a * self.delta * self.speed
            self.detect_collision_wall(dx, dy)
        if key[pygame.K_s]:
            dx = cos_a * self.delta * -self.speed
            dy = sin_a * self.delta * -self.speed
            self.detect_collision_wall(dx, dy)
        if key[pygame.K_a]:
            dx = sin_a * self.delta * self.speed
            dy = cos_a * self.delta * -self.speed
            self.detect_collision_wall(dx, dy)
        if key[pygame.K_d]:
            dx = sin_a * self.delta * -self.speed
            dy = cos_a * self.delta * self.speed
            self.detect_collision_wall(dx, dy)

    def mouse_control(self, active):
        if active:
            if pygame.mouse.get_focused():
                diff = pygame.mouse.get_pos()[0] - half_width
                pygame.mouse.set_pos((half_width, half_height))
                self.angle += diff * self.delta * self.mouse_sense

Gameplay

Spawn colors on the map, and make it so that the player can take them and paint any blocks. In order to understand whether the character is next to the block or not, we write a tricky chain of conditions:

for blockNow in blockMapTextures:
        questBlock = False
        if (blockNow[0] - blockSize // 2 < player.x < blockNow[0] + blockSize * 1.5 and blockNow[1] < player.y < blockNow[1] + blockSize) or \
        (blockNow[1] - blockSize // 2 < player.y < blockNow[1] + blockSize * 1.5 and blockNow[0] < player.x < blockNow[0] + blockSize):
            if countOfDraw < len(blocksActive) and doubleDrawOff:
                display.blit(
                    pg.transform.scale(ui['mouse2'], (ui['mouse2'].get_width() // 2, ui['mouse2'].get_height() // 2)),
                    (130, 750))
                if event.type == pg.MOUSEBUTTONDOWN and pg.mouse.get_pressed()[2]:
                    if blockMapTextures[blockNow] == '<':
                        questBlock = True
                    if questBlock == False:
                        try:
                            tempbackup_color.clear()
                            tempbackup.clear()
                            coloredBlocks.clear()
                            block_in_bag.pop(-1) 
                            tempbackup.append(blockMapTextures[blockNow])
                            tempbackup_color.append(blocks_draw_avaliable[list(blocks_draw_avaliable.keys())[-1]])
                            print('tempbackup_color : ', tempbackup_color)
                            blockMapTextures[blockNow] = blocks_draw_avaliable[list(blocks_draw_avaliable.keys())[-1]]
                            coloredBlocks.append(blockNow)
                            blocks_draw_avaliable.pop(list(blocks_draw_avaliable.keys())[-1])
                            countOfDraw += 1         
                            doubleDrawOff = False
                            doubleBack = False
                        except:
                            print('Error in color drawing')

Roughly speaking, we conditionally increase the range of coordinates that one block captures, and constantly look to see if the player enters these coordinates. Each block, it turns out, has a certain area around (without corners) a few tens of pixels in size, and when you enter it, it is considered that you are next to a certain block.

I’m sure there is a better way to detect a block next to the player, but I decided not to invent the wheel and did as I did).

Next, we implement a quest system and change levels depending on whether the quest is completed or not. As well as a level switch, with a picture for the plot at the beginning of each level.

def lvlSwitch():
    settings.textMap = levels.levelsList[str(settings.numOfLvl)]
    with open("game/settings/settings.json", 'w') as f:
        settings.sett['numL'] = settings.numOfLvl
        js.dump(settings.sett, f)
    print(settings.numOfLvl)
    main.tempbackup.clear()
    main.coloredBlocks.clear()
    main.blocksActive.clear()
    main.tempbackup_color.clear()
    main.block_in_bag.clear()
    main.blocks_draw_avaliable.clear()
    main.countOfDraw = 0
    main.blockClickAvaliable = 0
    
def switcher():  
    global lvlSwitches 
    main.display.blit(ui[f'lvl{settings.numOfLvl+1}'], (0,0))
    main.timer = False
    if pg.key.get_pressed()[pg.K_SPACE]:
        level5_quest.clear()
        main.doubleQuest = True 
        settings.numOfLvl += 1 
        lvlSwitch()
        main.timer = True
        level5_quest.clear()
        lvlSwitches = False
    

def quest(lvl):
    global lvlSwitches
    tmp = []
    for blockNeed in blockQuest:
        if blockQuest[blockNeed] == '@':
            if blockMapTextures[blockNeed] == '3':
                tmp.append(1)
                if settings.numOfLvl == 5:
                    level5_quest.add(1)
        if blockQuest[blockNeed] == '!':
            if blockMapTextures[blockNeed] == '2':
                tmp.append(2)
                if settings.numOfLvl == 5:
                    level5_quest.add(2)
                    
        if blockQuest[blockNeed] == '$':
            if blockMapTextures[blockNeed] == '4':
                tmp.append(3)
                if settings.numOfLvl == 5:
                    level5_quest.add(3)
        if blockQuest[blockNeed] == '%':
            if blockMapTextures[blockNeed] == '5':
                tmp.append(4)
                if settings.numOfLvl == 5:
                    level5_quest.add(4)

We implement a couple of mechanics:

The first mechanic is to simply put the right color in the right cell. No explanation required.

The second mechanic – teleportation – a new map is created in the form of a sheet and the blocks in it are mixed once in a while, a feeling of teleportation of colors is created.

def randomColorBlockMap(textMap):
    timer = t.perf_counter()
    text = textMap
    newTextMap = []
    generatedMap = []
    for row in text:
        roww = []
        for column in row:
            roww.append(column)
        newTextMap.append(roww)
    textsForShuffle = []
    for row in text:
        for column in row:
            if column != '.' and column != '<' and column != '$' and column != '%' and column != '@' and column != '!':
                textsForShuffle.append(column)
    xy_original = []
    for y, row in enumerate(text):
        for x, column in enumerate(row):
            if column != '.' and column != '<' and column != '$' and column != '%' and column != '@' and column != '!':
                if (x*blockSize, y*blockSize) not in list(settings.blockQuest.keys()):
                    xy_original.append([x,y])
    xy_tmp = xy_original
    for y, row in enumerate(newTextMap):       
        for x, column in enumerate(row):
            if column != '.' and column != '<' and column != '$' and column != '%' and column != '@' and column != '!':
                if (x*blockSize, y*blockSize) not in list(settings.blockQuest.keys()):  
                    ch = rn.choice(textsForShuffle)
                    newTextMap[y][x] = ch
                    textsForShuffle.remove(ch)
                
    for row in newTextMap:
        generatedMap.append(''.join(row))

    initMap(generatedMap)

The third mechanic is adding a B&W filter to each texture…

def toBlack():
    settings.textures['2'] = pygame.image.load('textures/colorYellowWallBlack.png').convert()
    settings.textures['3'] =  pygame.image.load('textures/colorBlueWallBlack.png').convert()
    settings.textures['4'] =  pygame.image.load('textures/colorRedWallBlack.png').convert()
    settings.textures['5'] =  pygame.image.load('textures/colorGreenWallBlack.png').convert()
    settings.textures['<'] =  pygame.image.load('textures/robotBlack.png').convert()
    ui['3'] = pygame.image.load("textures/blue_uiBlack.png")
    ui['2'] = pygame.image.load("textures/yellow_uiBlack.png")
    ui['4'] = pygame.image.load("textures/red_uiBlack.png")
    ui['5'] = pygame.image.load("textures/green_uiBlack.png")

Next, I made the menu as a class to conveniently add options when needed.

class Menu:
    def __init__(self):
        self.option_surface = []
        self.callbacks = []
        self.current_option_index = 0

    def add_option(self, option, callback):
        self.option_surface.append(f1.render(option, True, (255, 255, 255)))
        self.callbacks.append(callback)

    def switch(self, direction):
        self.current_option_index = max(0, min(self.current_option_index + direction, len(self.option_surface) - 1))

    def select(self):
        self.callbacks[self.current_option_index]()

    def draw(self, surf, x, y, option_y):
        for i, option in enumerate(self.option_surface):
            option_rect = option.get_rect()
            option_rect.topleft = (x, y + i * option_y)
            if i == self.current_option_index:
                pg.draw.rect(surf, (0, 100, 0), option_rect)
            b = surf.blit(option, option_rect)
            pos = pygame.mouse.get_pos()
            if b.collidepoint(pos):
                self.current_option_index = i
                for event in pg.event.get():
                    if pg.mouse.get_pressed()[0]:
                        self.select()

Implementing saves:

try:
    with open("game/settings/settings.json", 'r') as f:
        sett = js.load(f)
except:
    with open("game/settings/settings.json", 'w') as f:
        sett = {
            'FOV' : pi / 2,
            'numRays' : 400,
            'MAPSCALE' : 10,
            'numL' : 1,
            'mouse_sensivity' : 0.15
        }
        js.dump(sett, f)

numOfLvl = sett['numL']
textMap = levels.levelsList[str(numOfLvl)]

mouse_sensivity = sett['mouse_sensivity']

And in conclusion, a mini philosophical story with a deep meaning and an unexpected ending.

Conclusion

So we get a game with 2.5D dimension, hundreds of rays, low FPS and uncomplicated gameplay, which required only 4 libraries, 68 textures, and 1018 lines of code.

Also, you can always get acquainted with the full code of this project or download the game from my github.

I hope this article helped you in some way and you found this information useful to some extent. Thank you for your attention <3

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *