Ray Casting 3D game on Python + PyGame / Hebrew

Ray Casting 3D game on Python + PyGame / Hebrew

Introduction

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

The game became the founder of 3D games Wolfenstein 3D, released in 1992

Game Wolfenstein 3D (1992)

and behind her Doom 1993 year

The game DOOM 1993 (1993)

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

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

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

Game Wolfenstein 3D from the inside

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

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

If translated into Russian, then:

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

I became interested in how difficult it is to implement.

And I started writing technology RayCasting.

I will do it on a connection 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 with the help of symbols to separate when drawing where is a block and where is an empty space.

The card and the player on it (under the hood)
“.” – an empty place where a 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 player movement calculation

We get the following result:

Intermediate result of starting the game

Next, we need to present our map as a grid. And in the entire span of the viewing angle throw several 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 dimensions and stops its movement, then the cycle moves to the next beam.

Intersection of the beam and lines on the grid

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

Let’s recall school trigonometry and consider it on the example of vertical lines

The distance to the vertical and horizontal lines with which the beam crossed

We know the side k is the distance of the player to the block

a is the angle of each ray

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

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

Then we apply this to all axes with small changes

For horizontal lines, it is the same with sine.

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

We add a couple of height, depth, and size variables that 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 number of the beam, and the height will be equal to the specified coefficient for the length of the beam.

#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 here comes the illusion of 3D measurement.

Illusion of 3D dimension

Textures

1 block has 4 sides and each must 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 to a strip on the block.

Overlaying texture strips on a block

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

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

Calculation of indentation

#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 possibility of drawing several textures on one map by adding special marks to the map, each will be assigned its own texture.

List of my characters to create levels

Here is an example of what the second level in the game looks like in the form of 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.

We add a collision. We add a so-called collider to each position of the block and add the same collider to the player. If it continues to go the way it was going and at this pace in the next frame it is predicted to enter the block, we simply reset the acceleration along the desired axis to zero.

Block and player collision

For this, we will add a little class Player. I decided to add mouse control for the camera right away. This is how this 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

We sing the 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 see if the player enters these coordinates. Each block, it turns out, has some area around (without corners) the size of several tens of pixels, and when entering it, it is considered that you are near a certain block.

I’m sure there is a better way to find the block near the player, but I decided not to reinvent the wheel and did as I did).

Next, we will implement the quest system and change levels depending on whether the quest is completed or not. And also a level switcher, with a story picture 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 pair of mechanics:

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

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

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 to add a black and white 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()

We implement saving:

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 finally, a mini philosophical story with a deep meaning and an unexpected ending.

Conclusion

Here comes a game with 2.5D dimensions, hundreds of rays, low FPS and stupid gameplay, which required only 4 libraries, 68 textures, and 1018 lines of code.

Also, you can always read the full code of this project or download the game from me on GitHub.

I hope I helped you with this article and you found this information somewhat useful. Thank you for your attention <3

Related posts