Эээм а почему Python?

Прочитав заголовок вы сразу скажете то что Python — это язык не для игр! И окажетесь правы! Python — реально не был ориентирован под игры, но благодаря своему простому синтаксису в нем появился pygame и PyOpenGL и конечно же Ursina.
Pygame — библиотека Python основанная на SDL 2 для создания игр.
Именно с помощью Pygame мы сможем рисовать примитивы на экране, загружать текстуры и работать с логикой игры!
Ну и как будем это делать?
Pygame позволяет рисовать только 2D примитивы и для опыта я решил сделать 3D движок.
В этой статье у меня была задача сделать движок на котором можно будет создать что‑то типо Doom. Но как? Давайте разберём все способы рисовать 3D:
RayCasting — метод отрисовки 2.5D использующий лучи.
RayMarching — метод отрисовки 3D использующий шаги.
Полигоны — стандартный метод отрисовки 3D
Так‑как у нас Python я решил выбрать RayCasting.
RayCasting работает очень легко — мы пускаем лучи и чем он длиннее тем меньше рисуем объект. Но как нам это сделать в Python?
Начинаем!

Нам понадобится только сам Python(3.7 и выше), pygame,и еще две библиотеки для скорости. Давайте их скачаем:
pip install pygame numpy numba
В начале создадим основу для pygame :
# main.py
import pygame
from settings import * # Импорт констант из файла настроек
# Инициализация pygame
pygame.init()
# Создание окна с размерами из настроек
sc = pygame.display.set_mode((WIDTH, HEIGHT))
# Создание объекта для контроля FPS
clock = pygame.time.Clock()
# Основной игровой цикл
while True:
# Обработка событий
for event in pygame.event.get():
# Выход при закрытии окна
if event.type == pygame.QUIT:
exit()
# Заливка экрана черным цветом
sc.fill(BLACK)
# Здесь будет основной код отрисовки игры
# Обновление экрана
pygame.display.flip()
# Ограничение FPS до 60 кадров в секунду
clock.tick(60)
Дальше добавим игрока и его класс:
# player.py
from settings import *
import pygame
class Player:
def __init__(self):
# Начальная позиция и угол поворота
self.x, self.y = player_pos
self.angle = player_angle
@property
def pos(self):
# Текущая позиция игрока
return (self.x, self.y)
def movement(self):
keys = pygame.key.get_pressed()
# Движение вперед/назад (W/S)
if keys[pygame.K_w]:
self.y -= player_speed
if keys[pygame.K_s]:
self.y += player_speed
# Движение влево/вправо (A/D)
if keys[pygame.K_a]:
self.x -= player_speed
if keys[pygame.K_d]:
self.x += player_speed
# Поворот (стрелки влево/вправо)
if keys[pygame.K_LEFT]:
self.angle -= 0.02
if keys[pygame.K_RIGHT]:
self.angle += 0.02
pygame.draw.circle(sc, GREEN, player_pos, 12)
Теперь добавим к нашему главному файлу main импорт этого класса и передвижение игрока (player.movement()
)
#main.py
import pygame
from settings import *
from player import Player
pygame.init()
sc = pygame.display.set_mode( (WIDTH,HEIGHT) )
clock = pygame.time.Clock()
player = Player()
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
exit()
player.movement()
sc.fill(BLACK)
pygame.draw.circle(sc, GREEN, player.pos, 12)
pygame.display.flip()
clock.tick(60)
Как кидать лучи ?
Как нам отобразить направление луча? Наш игрок - это зеленая точка, назовем точка O.

А нам нужно нарисовать отрезок до красной точки P. Нам всегда известно основное направление нашего игрока то есть наш angle,а расстояние, мы сами задаем!

Используя простейшую тригонометрию мы получаем такую формулу для координат P:

Пускаем луч!
С помощью math мы получим синус и косинус угла игрока, а направление зададим отрисовкой линии, длина которой будет равна ширине экрана.
pygame.draw.line(sc, GREEN, player.pos, (player.x +WIDTH * math.cos(player.angle),
player.y +WIDTH * math.sin(player.angle)) )
Весь код к этому моменту
#main.py
import pygame
from settings import *
from player import Player
import math
pygame.init()
sc = pygame.display.set_mode( (WIDTH,HEIGHT) )
clock = pygame.time.Clock()
player = Player()
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
exit()
player.movement()
sc.fill(BLACK)
pygame.draw.circle(sc, GREEN, player.pos, 12)
pygame.draw.line(sc, GREEN, player.pos, (player.x +WIDTH * math.cos(player.angle), player.y +WIDTH * math.sin(player.angle)) )
pygame.display.flip()
clock.tick(FPS)
# player.py
from settings import *
import pygame
class Player:
def __init__(self):
# Начальная позиция и угол поворота
self.x, self.y = player_pos
self.angle = player_angle
@property
def pos(self):
# Текущая позиция игрока
return (self.x, self.y)
def movement(self):
keys = pygame.key.get_pressed()
# Движение вперед/назад (W/S)
if keys[pygame.K_w]:
self.y -= player_speed
if keys[pygame.K_s]:
self.y += player_speed
# Движение влево/вправо (A/D)
if keys[pygame.K_a]:
self.x -= player_speed
if keys[pygame.K_d]:
self.x += player_speed
# Поворот (стрелки влево/вправо)
if keys[pygame.K_LEFT]:
self.angle -= 0.02
if keys[pygame.K_RIGHT]:
self.angle += 0.02
#settings.py
WIDTH = 600
HEIGHT = 400
HALF_WIDTH = WIDTH // 2
HALF_HEIGHT = HEIGHT // 2
FPS = 60
BLACK = (0,0,0)
GREEN = (0,244,0)
player_speed = 2
player_pos = (HALF_WIDTH,HALF_HEIGHT)
player_angle = 0
Ну вот теперь стало наглядно видно в какую строну смотрит игрок.
Карта нашего мира.
Чтобы игрок мог ходить по миру и преодолевать препятствия мы сделаем карту.
Спойлер
В будущем мы с вами сделаем редактор в котором мы как раз будем создавать нашу игру и карту!
Самый простой способ сделать карту - это задать карту в текстовом виде! В отличии от всех я решил сделать более сложную систему текстовой карты - чтение из файла и несколько вариантов задачи карты!
#map.py
from settings import *
# Инициализация пустого списка для текстовой карты
text_map = []
def init_map():
f = open("map.txt", "r")
# Чтение первого числа - количества строк карты
num = int(f.readline())
# Чтение построчно и добавление в text_map
for i in range(num):
text_map.append(f.readline())
f.close()
# Инициализация карты из файла
init_map()
# Создание множества для хранения координат стен
world_map = set()
# Парсинг текстовой карты в мировые координаты
for j, row in enumerate(text_map): # Проход по строкам карты
for i, char in enumerate(row): # Проход по символам в строке
if char == 'W': # Если символ - стена ('W')
# Добавление координат стены в мировых единицах (умножаем на размер тайла)
world_map.add((i * TILE, j * TILE))
if char == '1': # Если символ - стена ('1')
# Добавление координат стены в мировых единицах (умножаем на размер тайла)
world_map.add((i * TILE, j * TILE))
Импортируем нашу карту в main.py - from map import world_map
Насчет парсинга в мировые координаты, он работает вот так :
Программа проходит по каждому символу карты
При обнаружении 'W' вычисляет координаты стены в пикселях (умножая позицию символа на размер тайла TILE)
Координаты сохраняются в множестве world_map для быстрой проверки коллизий
Исправляем проблемки!
Как вы наверняка заметили мы сделали управление как для 2D игры и оно никак не подходит для 3D! И чтобы это исправить обратимся к математике :

Этот круг представляет нашего игрока. У нас есть луч, направленный под углом α. Поскольку перемещение для каждой клавиши должно быть одинаковым, обозначим его как d.
Мы умеем находить точку в направлении луча. Заметим, что все направления отличаются друг от друга на 90 градусов. Следовательно, можно использовать формулы приведения тригонометрических функций, чтобы получить нужный результат для каждой клавиши.
И сейчас нам уже легко поменять управление для 3D!
from settings import *
import pygame
import math
class Player:
def __init__(self):
# Инициализация позиции и угла поворота игрока
self.x, self.y = player_pos
self.angle = player_angle
@property
def pos(self):
# Возвращает текущую позицию в виде кортежа
return (self.x, self.y)
def movement(self):
# Предварительный расчет синуса и косинуса угла поворота
sin_a = math.sin(self.angle)
cos_a = math.cos(self.angle)
keys = pygame.key.get_pressed()
# Движение вперед (W) - по направлению взгляда
if keys[pygame.K_w]:
self.x += player_speed * cos_a
self.y += player_speed * sin_a
# Движение назад (S) - против направления взгляда
if keys[pygame.K_s]:
self.x -= player_speed * cos_a
self.y -= player_speed * sin_a
# Движение влево (A) - перпендикулярно направлению (вектор влево)
if keys[pygame.K_a]:
self.x += player_speed * sin_a # Используем sin для перпендикулярного вектора
self.y -= player_speed * cos_a # Отрицательный cos для левого направления
# Движение вправо (D) - перпендикулярно направлению (вектор вправо)
if keys[pygame.K_d]:
self.x -= player_speed * sin_a # Отрицательный sin для правого направления
self.y += player_speed * cos_a # Положительный cos
# Поворот влево (стрелка влево)
if keys[pygame.K_LEFT]:
self.angle -= 0.02
# Поворот вправо (стрелка вправо)
if keys[pygame.K_RIGHT]:
self.angle += 0.02
И теперь наше управление стало близко к тому что мы привыкли видеть в шутерах от первого лица!
Переход в 3D !!!
Да и опять теория!

Введём понятие поля зрения (FOV — Field of View) — это угловой сектор, в пределах которого будут испускаться лучи.
Для работы с лучами понадобятся следующие параметры:
num_rays
— количество лучей в пределах FOV.Начальный угол первого луча:
.
Конечный угол последнего луча:
.
DELTA_ANGLE
— угловой шаг между соседними лучами (рассчитывается как, если лучи распределены равномерно).
MAX_DEPTH
— максимальная дальность, на которую испускаются лучи (глубина прорисовки).
Таким образом, лучи будут равномерно покрывать заданный сектор FOV, начиная с угла и заканчивая
, с шагом DELTA_ANGLE
.
Введем все это в настройки:
# ray casting settings
FOV = math.pi / 3
HALF_FOV = FOV / 2
NUM_RAYS = 120
MAX_DEPTH = 800
DELTA_ANGLE = FOV / NUM_RAYS
И сделаем файл ray_casting.py:
#Начальные импорты
import pygame
from settings import *
from map import world_map
Теперь напишем функцию которая будет принимать наш экран, позицию и угол игрока:
def ray_casting(sc, player_pos, player_angle):
pass
И заполним её:
import pygame
import math
from numba import njit
from settings import *
from map import world_map
def ray_casting(sc, player_pos, player_angle):
cur_angle = player_angle - HALF_FOV
#получаем позицию точки O
xo, yo = player_pos
#Проходимся по всем лучам
for ray in range(NUM_RAYS):
#Синус и косинус направления
sin_a = math.sin(cur_angle)
cos_a = math.cos(cur_angle)
#Идем в глубь карты
for depth in range(MAX_DEPTH):
x = xo + depth * cos_a
y = yo + depth * sin_a
pygame.draw.line(sc, DARKGRAY, player_pos, (x, y), 2)
# if (x // TILE * TILE, y // TILE * TILE) in world_map:
# depth *= math.cos(player_angle - cur_angle)
# proj_height = min(PROJ_COEFF / (depth + 0.0001), HEIGHT)
# c = 255 / (1 + depth * depth * 0.0001)
# color = (c // 2, c, c // 3)
# pygame.draw.rect(sc, color, (ray * SCALE, HALF_HEIGHT - proj_height // 2, SCALE, proj_height))
# break
cur_angle += DELTA_ANGLE
Ну что посмотрим на все что мы сделали сбоку!

Нарисуем стену и её проекцию на наш экран. Искомую высоту будем находить из подобия треугольников которые вы видите на рисунке :

DIST = NUM_RAYS / (2 * math.tan(HALF_FOV))
PROJ_COEFF = 3 * DIST * TILE
SCALE = WIDTH // NUM_RAYS
Теперь используем все сделанное ранее в нашем отрисовщике :
if (x // TILE * TILE, y // TILE * TILE) in world_map:
depth *= math.cos(player_angle - cur_angle)
proj_height = min(PROJ_COEFF / (depth + 0.0001), HEIGHT)
c = 255 / (1 + depth * depth * 0.0001)
color = (c // 2, c, c // 3)
pygame.draw.rect(sc, color, (ray * SCALE, HALF_HEIGHT - proj_height // 2, SCALE, proj_height))
break
Весь божечки код
#main.py
import pygame
from settings import *
from player import Player
import math
from map import text_map,world_map
from ray_casting import ray_casting
pygame.init()
sc = pygame.display.set_mode( (WIDTH,HEIGHT) )
clock = pygame.time.Clock()
player = Player()
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
exit()
player.movement()
sc.fill(BLACK)
ray_casting(sc,player.pos,player.angle)
pygame.draw.circle(sc, GREEN, (int(player.x),int(player.y)), 12)
pygame.draw.line(sc, GREEN, player.pos, (player.x +WIDTH * math.cos(player.angle), player.y +WIDTH * math.sin(player.angle)) )
for x,y in world_map:
pygame.draw.rect(sc, DARKGRAY, (x, y, TILE, TILE), 2)
pygame.display.flip()
clock.tick(FPS)
from settings import *
import pygame
import math
class Player:
def __init__(self):
# Инициализация позиции и угла поворота игрока
self.x, self.y = player_pos
self.angle = player_angle
@property
def pos(self):
# Возвращает текущую позицию в виде кортежа
return (self.x, self.y)
def movement(self):
# Предварительный расчет синуса и косинуса угла поворота
sin_a = math.sin(self.angle)
cos_a = math.cos(self.angle)
keys = pygame.key.get_pressed()
# Движение вперед (W) - по направлению взгляда
if keys[pygame.K_w]:
self.x += player_speed * cos_a
self.y += player_speed * sin_a
# Движение назад (S) - против направления взгляда
if keys[pygame.K_s]:
self.x -= player_speed * cos_a
self.y -= player_speed * sin_a
# Движение влево (A) - перпендикулярно направлению (вектор влево)
if keys[pygame.K_a]:
self.x += player_speed * sin_a # Используем sin для перпендикулярного вектора
self.y -= player_speed * cos_a # Отрицательный cos для левого направления
# Движение вправо (D) - перпендикулярно направлению (вектор вправо)
if keys[pygame.K_d]:
self.x -= player_speed * sin_a # Отрицательный sin для правого направления
self.y += player_speed * cos_a # Положительный cos
# Поворот влево (стрелка влево)
if keys[pygame.K_LEFT]:
self.angle -= 0.02
# Поворот вправо (стрелка вправо)
if keys[pygame.K_RIGHT]:
self.angle += 0.02
import pygame
import math
from numba import njit
from settings import *
from map import world_map
def ray_casting(sc, player_pos, player_angle):
cur_angle = player_angle - HALF_FOV
#получаем позицию точки O
xo, yo = player_pos
#Проходимся по всем лучам
for ray in range(NUM_RAYS):
#Синус и косинус направления
sin_a = math.sin(cur_angle)
cos_a = math.cos(cur_angle)
#Идем в глубь карты
for depth in range(MAX_DEPTH):
x = xo + depth * cos_a
y = yo + depth * sin_a
pygame.draw.line(sc, DARKGRAY, player_pos, (x, y), 2)
if (x // TILE * TILE, y // TILE * TILE) in world_map:
depth *= math.cos(player_angle - cur_angle)
proj_height = min(PROJ_COEFF / (depth + 0.0001), HEIGHT)
c = 255 / (1 + depth * depth * 0.0001)
color = (c // 2, c, c // 3)
pygame.draw.rect(sc, color, (ray * SCALE, HALF_HEIGHT - proj_height // 2, SCALE, proj_height))
break
cur_angle += DELTA_ANGLE
# Импорт всех настроек из файла settings.py
from settings import *
# Инициализация пустого списка для текстовой карты
text_map = []
def init_map():
f = open("map.txt", "r")
# Чтение первого числа - количества строк карты
num = int(f.readline())
# Чтение построчно и добавление в text_map
for i in range(num):
text_map.append(f.readline())
f.close()
# Инициализация карты из файла
init_map()
print(text_map)
# Создание множества для хранения координат стен
world_map = set()
# Парсинг текстовой карты в мировые координаты
for j, row in enumerate(text_map): # Проход по строкам карты
for i, char in enumerate(row): # Проход по символам в строке
if char == 'W': # Если символ - стена ('W')
# Добавление координат стены в мировых единицах (умножаем на размер тайла)
world_map.add((i * TILE, j * TILE))
if char == '1': # Если символ - стена ('1')
# Добавление координат стены в мировых единицах (умножаем на размер тайла)
world_map.add((i * TILE, j * TILE))
import math
# game settings
WIDTH = 1200
HEIGHT = 800
HALF_WIDTH = WIDTH // 2
HALF_HEIGHT = HEIGHT // 2
FPS = 60
TILE = 100
# ray casting settings
FOV = math.pi / 3
HALF_FOV = FOV / 2
NUM_RAYS = 120
MAX_DEPTH = 800
DELTA_ANGLE = FOV / NUM_RAYS
DIST = NUM_RAYS / (2 * math.tan(HALF_FOV))
PROJ_COEFF = 3 * DIST * TILE
SCALE = WIDTH // NUM_RAYS
# player settings
player_pos = (HALF_WIDTH, HALF_HEIGHT)
player_angle = 0
player_speed = 2
# colors
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
RED = (220, 0, 0)
GREEN = (0, 220, 0)
BLUE = (0, 0, 255)
DARKGRAY = (40, 40, 40)
PURPLE = (120, 0, 120)
7
WWWWWWWWWWWW
W..........W
W...WW.....W
W..........W
W..WW......W
W..........W
W....WWW...W
WWWWWWWWWWWW
Добавляем мелкие детали перед концом!
Небо! Давайте добавим небо!
Для этого нарисуем два прямоугольника. Один - это земля, а другой это небо
pygame.draw.rect(sc, BLUE, (0, 0, WIDTH, HALF_HEIGHT))
pygame.draw.rect(sc, DARKGRAY, (0, HALF_HEIGHT, WIDTH, HALF_HEIGHT))
И да давайте уберем этот 2D! Уберите или закоментите эти строки :
pygame.draw.circle(sc, GREEN, (int(player.x),int(player.y)), 12)
pygame.draw.line(sc, GREEN, player.pos, (player.x +WIDTH * math.cos(player.angle), player.y +WIDTH * math.sin(player.angle)) )
# pygame.draw.circle(sc, GREEN, (int(player.x), int(player.y)), 12)
# pygame.draw.line(sc, GREEN, player.pos, (player.x + WIDTH * math.cos(player.angle),
# player.y + WIDTH * math. sin(player.angle)), 2)
# for x,y in world_map:
# pygame.draw.rect(sc, DARKGRAY, (x, y, TILE, TILE), 2)
Конец.
Это конец первой части и вот скриншоты того что у нас получилось :


Список использованных материалов:
Как сделать 3D Игру на Python с Нуля [ Pygame ] — перейти туда
Видео из 2d в 3d — посмотреть видосик
Спасибо за прочтение ! Помните в следующей статье мы исправим все баги увеличим FPS и сделаем редактор карт.