Введение
Все мы помним старые игры, в которых впервые появилось трехмерное измерение.
Основоположником 3д игр стала игра Wolfenstein 3D, выпущенная в 1992 году
а за ней и Doom 1993 года.
Эти две игры разработала одна компания: «id Software»
Она создала свой движок специально для этой игры, и в итоге получилась 3д игра, что считалось практически невозможным на те времена.
Но что будет если я скажу что это не 3д игра, а всего лишь симуляция и игра выглядит на самом деле примерно вот так?
На самом деле здесь используется технология Ray Casting, третьего измерения тут просто не существует.
Что же такое этот самый RayCasting, который даже в наши времена актуален, но уже используется не для игр, а для технологии трассировки лучей в современных играх.
Если переводить на русский, то:
Метод бросания лучей(Ray Casting) - один из методов рендеринга в компьютерной графике, при котором сцена строится на основе замеров пересечения лучей с визуализируемой поверхностью.
Мне стало интересно на сколько это сложно реализовать.
И я принялся за написание технологии RayCasting.
Буду делать его на связке python + pygame
Pygame позволяет рисовать на плоскости простые 2D фигуры, и путем танцами с бубном вокруг них я и буду делать 3D иллюзию
Реализация Ray Casting
Для начала создаем простейшую карту с помощью символов, чтобы разделять при отрисовке где блок а где пустое место.
Рисуем карту в 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