Введение
Все мы помним старые игры, в которых впервые появилось трехмерное измерение.
Основоположником 3д игр стала игра Wolfenstein 3D, выпущенная в 1992 году
а за ней и Doom 1993 года.
Эти две игры разработала одна компания: «id Software»
Она создала свой движок специально для этой игры, и в итоге получилась 3д игра, что считалось практически невозможным на те времена.
Но что будет если я скажу что это не 3д игра, а всего лишь симуляция и игра выглядит на самом деле примерно вот так?
На самом деле здесь используется технология 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 измерения.
Текстуры
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/
Добавить комментарий