Ray Casting 3D игра на Python + PyGame

Введение

Все мы помним старые игры, в которых впервые появилось трехмерное измерение.

Основоположником 3д игр стала игра Wolfenstein 3D, выпущенная в 1992 году

Игра Wolfenstein 3D (1992 год)

Игра Wolfenstein 3D (1992 год)

а за ней и Doom 1993 года.

Игра DOOM 1993 (1993)

Игра DOOM 1993 (1993)

Эти две игры разработала одна компания: «id Software»

Она создала свой движок специально для этой игры, и в итоге получилась 3д игра, что считалось практически невозможным на те времена.

Но что будет если я скажу что это не 3д игра, а всего лишь симуляция и игра выглядит на самом деле примерно вот так?

Игра Wolfenstein 3D изнутри

Игра Wolfenstein 3D изнутри

На самом деле здесь используется технология Ray Casting, третьего измерения тут просто не существует.

Что же такое этот самый RayCasting, который даже в наши времена актуален, но уже используется не для игр, а для технологии трассировки лучей в современных играх.

Если переводить на русский, то:

Метод бросания лучей(Ray Casting) — один из методов рендеринга в компьютерной графике, при котором сцена строится на основе замеров пересечения лучей с визуализируемой поверхностью.

Мне стало интересно на сколько это сложно реализовать.

И я принялся за написание технологии RayCasting.

Буду делать его на связке python + pygame

Pygame позволяет рисовать на плоскости простые 2D фигуры, и путем танцами с бубном вокруг них я и буду делать 3D иллюзию

Реализация Ray Casting

Для начала создаем простейшую карту с помощью символов, чтобы разделять при отрисовке где блок, а где пустое место.

Карта и игрок на ней (под капотом)

Карта и игрок на ней (под капотом)
».» — пустое место, где может ходить игрок
»1» — блок

Рисуем карту в 2D, и игрока с возможностью управления и расчетом точки взгляда.

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

Движение будет осуществляться путем сложения косинуса угла зрения по горизонтали и синуса угла зрения по вертикали

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

Визуализация расчета движения игрока

Визуализация расчета движения игрока

Получаем такой результат:

Промежуточный результат запуска игры

Промежуточный результат запуска игры

Далее мы должны представить нашу карту в виде сетки. И во всем промежутке угла обзора бросать некоторое количество лучей, чем их больше, тем лучше будет картинка, но меньше кадров в секунду.

Каждый луч должен находить пересечение с каждой вертикальной и горизонтальной линией сетки. Как только он находит столкновение с блоком, он рисует его нужных размеров и прекращает свое движение, далее цикл переходит к следующему лучу.

Пересечение луча и линий на сетке

Пересечение луча и линий на сетке

Так же нужно рассчитать расстояние до вертикальных и горизонтальных линий с которыми пересекся луч.

Вспоминаем школьную тригонометрию и рассмотрим это на примере вертикальных линий

Расстояние до вертикальных и горизонтальных линий с которыми пересекся луч

Расстояние до вертикальных и горизонтальных линий с которыми пересекся луч

Нам известна сторона k — это расстояние игрока до блока

a — это угол каждого луча

Далее просто добавляем длину, так как мы знаем размер нашего блока сетки.

И когда луч врежется в стену цикл остановиться.

Потом применяем это ко всем осям с небольшими изменениями

Для горизонтальных линий тоже самое только с синусом.

В расстояние луча записываем горизонтальное или вертикальное расстояние, в зависимости от того, что находиться ближе

Добавляем пару переменных высоты, глубины, размера которые высчитываются из достаточно простых формул

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))

Рисуем прямоугольники по центру экрана, положение по горизонтали будет зависеть от номера луча, а высота будет равна отношению заданного коэффициента на длину луча.

#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))

И вот получается уже какая-никакая иллюзия 3D измерения.

Иллюзия 3D измерения

Иллюзия 3D измерения

Текстуры

1 блок имеет 4 стороны и каждую бы должны покрыть текстурой.

Каждую сторону мы разделяем на полоски с маленькой шириной, главное чтобы количество лучей падающих на блок совпадало с количеством полосок на стороне, и делим на количество этих полосок нашу текстуру и поочередно отрисовываем полоску из текстуры на полоску на блоке.

Наложение полосок текстуры на блок

Наложение полосок текстуры на блок

Так ширина будет варьироваться в зависимости от удаленности стороны блока. А положение полоски рассчитывается путем умножения отступа на размер текстуры.

Если луч падает на вертикаль то отступ высчитываем от верхней точки, если на горизонталь то от левой точки.

Расчет отступа

Расчет отступа

#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))

Добавляем еще возможность отрисовки нескольких текстур на одной карте путем добавления на карту специальных знаков, каждому будет присваиваться своя текстура.

Список моих знаков для создания уровней

Список моих знаков для создания уровней

Вот пример как выглядит 2-ой уровень в игре в виде кода:

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",
]

В итоге получаем адекватное отображение текстур:

Коллизия

Где же такое видано что мы можем проходить через блоки…

Добавляем коллизию. К каждой позиция блока добавляем так называемый коллайдер и такой же коллайдер добавляем игроку. Если он продолжит идти так как шел и такими темпами на следующем кадре по предсказанию зайдет в блок, то мы просто зануляем ускорение по нужной оси.

Столкновение блока и игрока

Столкновение блока и игрока

Для этого чуть допишем класс Player. Я решил еще сразу добавить управление камерой с помощью мыши. Вот как по итогу стал выглядеть этот класс:

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

Геймплей

Спавним цвета на карте, и делаем так, чтобы игрок мог взять их, и красить любые блоки. Для того что понять находиться персонаж рядом с блоком или нет пишем хитрую цепочку условий:

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')

Грубо говоря, мы условно увеличиваем диапазон координат которые захватывает один блок, и постоянно смотрим, заходит ли игрок в эти координаты. У каждого блока, получается, есть некая область вокруг (без углов) размером в несколько десятков пикселей, и при заходе в нее, считается что ты рядом с определенным блоком.

Я уверен что есть способ лучше чтобы обнаружить блок рядом с игроком, но я решил не придумывать колесо и сделал, как сделал).

Далее реализуем систему квестов и смену уровней в зависимости от того выполнен квест или нет. А так же переключатель уровней, с картинкой для сюжета в начале каждого уровня.

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)

Реализуем пару механик:

Первая механика — банально поставить нужный цвет в нужную ячейку. Объяснений не требуется.

Вторая механика — телепортация создается новая карта в виде листа и блоки в ней раз в какое то время перемешиваются, создается ощущения телепортаций цветов.

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)

Третья механика — добавляем ЧБ фильтр на каждую текстуру…

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")

Дальше я сделал меню в виде класса, чтобы удобно добавлять опции когда это будет нужно.

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()

Реализуем сохранения:

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']

И в заключении мини философскую историю с глубоким смыслом и неожиданную концовку.

Заключение

Вот и получается игра с 2.5D измерением, сотнями лучей, маленьким FPS и незамысловатым геймплеем, на которую потребовалось всего 4 библиотеки, 68 текстур, и 1018 строчек кода.

Также вы всегда можете ознакомиться с полным кодом этого проекта или скачать игру у меня на GitHub.

Надеюсь этой статьей я вам чем то помог и вы нашли данную информацию в какой-то степени полезной. Спасибо за внимание <3

© Habrahabr.ru