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