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" - блок

Карта и игрок на ней (под капотом)
«.» — пустое место, где может ходить игрок
«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


ссылка на оригинал статьи https://habr.com/ru/articles/749764/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *