В предыдущих частях мы рассмотрели создание консольной и GUI-версии "Сапёра" на Python, теперь пришло время совершить скачок, перенести классическую игру в трехмерное пространство с использованием графических технологий и популярный библиотек (буду стараться подробно описать комментариями в коде, если не понятно, то напишите в коммментариях, чтобы обновил статью и сделал её более подробной)
Раздел 1: Подготовка и настройка проекта, также размышления по проекту
1.1 Размышления: Почему все же 3D, а не улучшение 2D или новые идеи
Изначально я воспринимал разработку игры как учебный проект, который вряд ли кто-то увидит. Однако, получив первую обратную связь, осознал ценность создания чего-то уникального в сообществе
Анализируя существующие проекты на Хабре, я заметил интересный парадокс: при обилии 3D-игр различных жанров, классический "Сапёр" в трёхмерном исполнении практически отсутствует. При этом с технической точки зрения переход от 2D к 3D не настолько сложен, как может показаться, во многом это логичное развитие после освоения Tkinter и 2D-графики, поэтому после освоения текстового интерфейса и двумерной графики, следующим шагом становится создание полноценной 3D-игры. Вот что принципиально нового нас ждет:
Иммерсивный геймплей («Immersive‑gameplay») - вместо плоского поля вы получите объёмное пространство, где каждая клетка становится реальным 3D‑объектом
Свобода камеры - возможность осматривать поле под любым углом, приближать и отдалять
Тактильное управление - плавное перемещение курсора в трех измерениях вместо дискретных прыжков по клеткам
Визуальная глубина - реалистичное освещение, тени, объёмные мины и флаги создают эффект присутствия
Если вы уже знакомы с основами Python и хотите погрузиться в мир 3D-графики и игровой разработки или просто поиграть в объёмного сапёра - этот проект станет идеальной отправной точкой для вас.
1.2 Прорыв: от Tkinter к OpenGL
В отличие от предыдущих версий, где мы использовали Tkinter для создания интерфейса, здесь мы задействуем всю мощь и красоту всех базовых инструментов:
PyGame
- для создания игрового окна и обработки вводаOpenGL
- для высокопроизводительного 3D-рендеринга
1.3 Архитектура проекта
Проект разделен на логические модули, которые будут представлены во втором разделе:
main.py
точка входа и координацияmenu.py
система меню и настроекgame.py
игровая логика и управлениеrenderer.py
3D-рендеринг и графика
1.4 На какую аудиторию этот проект
Начинающим разработчикам: отличный способ познакомиться с 3D-графикой на практике
Опытным программистам: интересный вызов и возможность оптимизации
Всем любителям игр или просто любителям изменять код: шанс создать свою собственную 3D-версию классики
1.5 Подготовка: что нам нужно для запуска и работы с проектом
Для работы потребуются библиотеки Python, и поэтому перед тем, как приступить к написанию, убедитесь, что у вас установлен Python (версия 3.10 или выше), если нет, то вы можете скачать её с официального сайта python.org. После скачивания python, нужно установить библиотеки:
pip install pygame PyOpenGL PyOpenGL_accelerate
Для следующего шага нам понадобятся знания Python и библиотеки
Раздел 2: Главная часть - код программы и его описание
2.1 main.py - Главный файл приложения
import pygame
from game import Minesweeper3D
from menu import GameMenu
def main():
pygame.init()
# Создаем окно меню
menu_screen = pygame.display.set_mode((1280, 1024))
pygame.display.set_caption("3D-Minesweeper - Menu")
# Создаем меню
menu = GameMenu(menu_screen)
game_settings = menu.run()
if game_settings: # Если пользователь начал игру
# Закрываем меню и создаем игровое окно
pygame.quit()
# Перезапускаем pygame для игрового окна
pygame.init()
# Создаем и запускаем игру с выбранными настройками
game = Minesweeper3D(game_settings)
game.run()
if __name__ == "__main__":
main()
Назначение файла - нужен для правильной работы между меню и игрой, можно сказать управление жизненным циклом приложения
2.2 menu.py - Система меню
Класс меню реализует базовый функционал для настройки игры, где ключевые методы включают инициализацию параметров в init
, главный цикл обработки событий в run
, обработку кликов мыши через handle_mouse_down
, обновление значений слайдеров в update_slider_values
, а также отрисовку интерфейса с помощью группы методов draw_interface
, draw_slider
, draw_lighting_toggle
и draw_start_button
.
Интерфейс меню был реализован как минимально рабочий вариант - здесь открывается широкий простор для творчества: можно добавить анимированные переходы, дополнительные настройки графики, систему профилей или даже мини-превью геймплея на фоне и т.д.
2.2.1 Конструктор класса GameMenu - инициализация всех параметров
import pygame
class GameMenu:
def __init__(self, screen):
self.screen = screen
self.width, self.height = screen.get_size()
# Шрифты - подбираем на глаз
self.big_font = pygame.font.SysFont('Times New Roman', 50)
self.normal_font = pygame.font.SysFont('Times New Roman', 35)
# Настройки по умолчанию - стандартные значения
self.grid_size = 10 # Размер поля
self.mine_count = 15 # Мины для начала
self.lighting_on = True # Освещение включено
# Вычисляем позиции относительно размера экрана
self.middle_x = self.width // 2 # Центр экрана
# Высоты элементов отступаем от верха пропорционально
self.title_y = self.height // 8 # Заголовок в верхней части
self.first_slider_y = self.height // 4 # Первый слайдер чуть ниже
self.slider_spacing = self.height // 10 # Расстояние между слайдерами
self.toggle_y = self.first_slider_y + self.slider_spacing * 2 # Переключатель после двух слайдеров
self.button_y = self.toggle_y + self.slider_spacing # Кнопка внизу
# Ширины элементов пропорционально ширине экрана
self.slider_length = self.width // 4 # Слайдер занимает четверть экрана
self.btn_width = self.width // 5 # Кнопка поменьше
self.btn_height = 50 # Высота кнопки стандартная
# Для удобства - отступ слайдера от центра
self.slider_offset_x = self.width // 9
# Состояния перетаскивания
self.grid_dragging = False
self.mines_dragging = False
# Флаг работы меню
self.running = True
Пояснение к работе: Класс принимает объект экрана ("screen") PyGame и автоматически определяет его размеры для адаптивного позиционирования
screen.get_size()
- Метод PyGame, возвращающий (width, height) с размерами окнаpygame.font.SysFont('Times New Roman', 50)
- Создание объекта шрифта:Первый параметр - название шрифта ( можно менять на любой другой стандартный или установить свой акцидентный шрифт)
Второй параметр - размер в пикселях
Пропорциональное позиционирование:
self.height // 8
- заголовок занимает 1/8 высоты от верхаself.width // 4
- слайдер занимает 1/4 ширины экрана
Состояния перетаскивания - флаги для отслеживания, какой слайдер сейчас перетаскивается
self.mines_dragging = False
- слайдер минself.grid_dragging = False
- слайдер размера
2.2.2 Метод run() - главный игровой цикл меню
def run(self):
"""Главный цикл открыт пока пользователь не выберет настройки"""
while self.running:
# Текущая позиция мыши для обработки hover эффектов
# P.S. hover-эффект — это изменение вида элемента, когда пользователь наводит на него курсор мыши
mouse_pos = pygame.mouse.get_pos()
# Разбираем все события которые накопились
for event in pygame.event.get():
if event.type == pygame.QUIT:
return None # Выход из игры если закрыли окно
# Нажатие кнопки мыши - возможно начало перетаскивания или клик
if event.type == pygame.MOUSEBUTTONDOWN:
self.handle_mouse_down(mouse_pos)
# Отпустили кнопку - заканчиваем перетаскивание
if event.type == pygame.MOUSEBUTTONUP:
self.grid_dragging = False
self.mines_dragging = False
# Клавиша ESC - выход в меню
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
return None
# Если тащим слайдер - обновляем его значение
if self.grid_dragging or self.mines_dragging:
self.update_slider_values(mouse_pos)
# Рисуем все элементы интерфейса
self.draw_interface()
pygame.display.flip()
# Не грузим процессор - ждем немного
pygame.time.delay(30)
# Возвращаем настройки когда пользователь нажал "Начать игру"
return self.get_settings()
Пояснения ключевых команд и моментов:
pygame.mouse.get_pos()
- возвращает текущие координаты (x, y) курсора мышиpygame.event.get()
- получает список всех событий, произошедших с последнего вызоваevent.type
- тип события (QUIT, MOUSEBUTTONDOWN, KEYDOWN и т.д.)pygame.display.flip()
- обновляет весь экран
2.2.3 Метод handle_mouse_down() - обработка нажатий мыши
def handle_mouse_down(self, mouse_pos):
"""Обрабатываем клик мыши проверяем куда попали"""
x, y = mouse_pos
# Позиция начала слайдера (левый край)
slider_start_x = self.middle_x - self.slider_offset_x
# Вычисляем где должна быть ручка слайдера размера сетки
grid_handle_x = slider_start_x + (self.grid_size - 5) * (self.slider_length / 15)
# Область ручки - немного расширяем для удобства клика
handle_left = grid_handle_x - 15
handle_right = grid_handle_x + 15
handle_top = self.first_slider_y - 5
handle_bottom = self.first_slider_y + 25
# Проверяем попали ли в ручку слайдера размера сетки
if (handle_left < x < handle_right and handle_top < y < handle_bottom):
self.grid_dragging = True
# Аналогично для слайдера количества мин (он ниже)
mines_handle_x = slider_start_x + (self.mine_count - 5) * (self.slider_length / 95)
mines_handle_top = self.first_slider_y + self.slider_spacing - 5
mines_handle_bottom = mines_handle_top + 30
if (mines_handle_x - 15 < x < mines_handle_x + 15 and
mines_handle_top < y < mines_handle_bottom):
self.mines_dragging = True
# Проверяем клик по переключателю освещения
toggle_rect = pygame.Rect(self.middle_x - 70, self.toggle_y, 140, 40)
if toggle_rect.collidepoint(mouse_pos):
self.lighting_on = not self.lighting_on # Переключаем состояние
# Проверяем клик по кнопке "Начать игру"
start_rect = pygame.Rect(self.middle_x - self.btn_width // 2, self.button_y,
self.btn_width, self.btn_height)
if start_rect.collidepoint(mouse_pos):
self.running = False # Заканчиваем работу меню
Слайдеры и обнаружение кликов:
Вычисление позиции ручки:
(self.grid_size - 5)
- нормализация (т.к. диапазон 5-20 → 0-15)(self.slider_length / 15)
- масштабирование на длину слайдера
pygame.Rect()
- создает прямоугольник для проверки столкновенийcollidepoint()
- проверяет, находится ли точка внутри прямоугольника
2.2.4 Метод update_slider_values() - обновление значений при перетаскивании
def update_slider_values(self, mouse_pos):
"""Обновляем значения когда тащим слайдер"""
x, y = mouse_pos
slider_start_x = self.middle_x - self.slider_offset_x
# Обновляем размер сетки если тащим первый слайдер
if self.grid_dragging:
# Вычисляем относительное положение мыши на слайдере
relative_x = x - slider_start_x
# Ограничиваем в пределах слайдера
relative_x = max(0, min(relative_x, self.slider_length))
# Пересчитываем в значение от 5 до 20
self.grid_size = 5 + int(relative_x / self.slider_length * 15)
# Аналогично для количества мин
if self.mines_dragging:
relative_x = x - slider_start_x
relative_x = max(0, min(relative_x, self.slider_length))
self.mine_count = 5 + int(relative_x / self.slider_length * 95)
# Защита от дурака - не может быть мин больше чем клеток (минус 9 для безопасной зоны)
max_possible_mines = self.grid_size * self.grid_size - 9
if self.mine_count > max_possible_mines:
self.mine_count = max_possible_mines
Алгоритм преобразования координат в значения:
relative_x = x - slider_start_x
- смещение от начала слайдераmax(0, min(relative_x, self.slider_length))
- ограничение в границах слайдераФормула нормализации:
(relative_x / slider_length) * range + min_value
Защита от некорректных значений:
grid_size * grid_size - 9
- максимальное количество мин (минус безопасная зона 3x3, чтобы мин не было больше, чем клеток, также автоматическая коррекция при превышении лимита)
2.2.6 Метод draw_interface() - основная отрисовка интерфейса
def draw_interface(self):
"""Рисуем весь интерфейс меню"""
# Заливаем фон темно-синим цветом
self.screen.fill((40, 40, 80))
# Заголовок игры по центру сверху
title = self.big_font.render("Сапер 3D", True, (255, 255, 200))
title_rect = title.get_rect(center=(self.middle_x, self.title_y))
self.screen.blit(title, title_rect)
# Рисуем два слайдера - для размера поля и количества мин
self.draw_slider("Размер поля:", self.grid_size, self.first_slider_y,
f"{self.grid_size}×{self.grid_size}")
self.draw_slider("Количество мин:", self.mine_count,
self.first_slider_y + self.slider_spacing, str(self.mine_count))
# Переключатель освещения
self.draw_lighting_toggle()
# Кнопка начала игры
self.draw_start_button()
Ключевые команды PyGame:
self.screen.fill((40, 40, 80))
- заливка фона цветом RGB(40,40,80) - (тёмно-синий цвет с фиолетовым оттенком) , можно менять или например вставить фотографиюfont.render(text, antialias, color)
- создание поверхности с текстом:antialias=True
- сглаживание краев текста
surface.get_rect(center=(x, y))
- получение прямоугольника с центром в указанной позицииself.screen.blit(source, dest)
- отрисовка поверхности на экран:source
- что рисуемdest
- куда рисуем (координаты)
2.2.7 Метод draw_slider() - отрисовка отдельного слайдера
def draw_slider(self, label, value, y_pos, value_text):
"""Рисуем один слайдер с меткой и значением"""
# Начало слайдера (левый край)
slider_start_x = self.middle_x - self.slider_offset_x
# Метка слева от слайдера
label_surface = self.normal_font.render(label, True, (255, 255, 255))
# Размещаем метку слева от слайдера с выравниванием по базовой линии
self.screen.blit(label_surface, (slider_start_x - label_surface.get_width() - 20, y_pos))
# Значение справа от слайдера
value_surface = self.normal_font.render(value_text, True, (255, 255, 200))
self.screen.blit(value_surface, (slider_start_x + self.slider_length + 10, y_pos))
# Фоновая полоса слайдера
pygame.draw.rect(self.screen, (80, 80, 120),
(slider_start_x, y_pos + 15, self.slider_length, 8))
# Заполненная часть - показывает текущее значение
if value > 5:
# Вычисляем ширину заполненной части в зависимости от типа слайдера
if "Размер" in label:
fill_width = (value - 5) * (self.slider_length / 15)
else:
fill_width = (value - 5) * (self.slider_length / 95)
pygame.draw.rect(self.screen, (0, 120, 220),
(slider_start_x, y_pos + 15, fill_width, 8))
# Ручка слайдера - кружок по текущей позиции
if "Размер" in label:
handle_x = slider_start_x + (value - 5) * (self.slider_length / 15)
else:
handle_x = slider_start_x + (value - 5) * (self.slider_length / 95)
pygame.draw.circle(self.screen, (255, 255, 255), (int(handle_x), y_pos + 19), 12)
Примитивы PyGame:
pygame.draw.rect(surface, color, rect, width=0)
рисование прямоугольника:width=0
- заливка,width>0
- контур толщиной width
pygame.draw.circle(surface, color, center, radius)
рисование кругаfont.render()
создает изображение текста с бежевым цветом (255,255,200) и сглаживаниемscreen.blit()
рисует это изображение с отступом 10 пикселей справа от слайдера, показывая текущее числовое значение настроек
2.2.8 Метод draw_lighting_toggle() - переключатель освещения
def draw_lighting_toggle(self):
"""Рисуем переключатель освещения"""
# Позиция переключателя - по центру
toggle_x = self.middle_x - 70
toggle_y = self.toggle_y
# Метка слева
label = self.normal_font.render("Освещение:", True, (255, 255, 255))
self.screen.blit(label, (toggle_x - label.get_width() - 30, toggle_y))
# Сам переключатель - зеленный если включено, красный если выключено
toggle_color = (50, 200, 50) if self.lighting_on else (200, 50, 50)
pygame.draw.rect(self.screen, toggle_color,
(toggle_x, toggle_y, 140, 40), border_radius=20)
# Текст на переключателе
state_text = "ВКЛ" if self.lighting_on else "ВЫКЛ"
text_surface = self.normal_font.render(state_text, True, (255, 255, 255))
text_rect = text_surface.get_rect(center=(toggle_x + 70, toggle_y + 20))
self.screen.blit(text_surface, text_rect)
Примичание:
Условное изменение цвета в зависимости от состояния (зеленный если включено, красный, если выключено)
2.2.9 Метод draw_start_button() - кнопка начала игры
def draw_start_button(self):
"""Рисуем кнопку начала игры"""
# Позиция кнопки - по центру внизу
btn_x = self.middle_x - self.btn_width // 2
btn_y = self.button_y
# Цвет кнопки - зеленый
button_color = (30, 150, 30)
# Рисуем саму кнопку со скругленными углами
pygame.draw.rect(self.screen, button_color,
(btn_x, btn_y, self.btn_width, self.btn_height), border_radius=10)
# Обводка кнопки
pygame.draw.rect(self.screen, (200, 200, 200),
(btn_x, btn_y, self.btn_width, self.btn_height), 2, border_radius=10)
# Текст на кнопке
text_surface = self.normal_font.render("Начать игру", True, (255, 255, 255))
text_rect = text_surface.get_rect(center=(btn_x + self.btn_width // 2, btn_y + self.btn_height // 2))
self.screen.blit(text_surface, text_rect)
2.2.10 Метод get_settings() - возврат настроек
def get_settings(self):
"""Возвращаем выбранные пользователем настройки"""
return {
'grid_size': self.grid_size,
'mine_count': self.mine_count,
'lighting_enabled': self.lighting_on
}
Простота: Метод возвращает словарь с четко именованными ключами, готовый к использованию в основном классе игры.
2.3 game.py - Основная игровая логика
Класс Minesweeper3D представляет собой ядро игровой логики, где каждый метод выполняет четко определенную роль: от инициализации игрового окружения в init и сброса состояния в init_game до стратегического размещения мин с безопасной зоной через place_mines, интерактивного вскрытия клеток с рекурсивной логикой в reveal_cell, проверки условий победы в check_win, обработки непрерывного пользовательского ввода в handle_input, управления главным игровым циклом в run и визуализации интерфейса в draw_interface
Возможно при модификации рекурсивного метода reveal_cell следует быть осторожным с глубиной рекурсии на больших полях, а также учитывать взаимосвязь между handle_input и главным циклом run эти изменения в одном моменте могут потребовать корректировки в другом
2.3.1 Метод init(self, settings) - инициализация игры
import pygame
from pygame.locals import *
from OpenGL.GL import *
from OpenGL.GLU import *
import random
from renderer import Renderer
class Minesweeper3D:
def __init__(self, settings):
"""
Инициализация игры с настройками и создание игрового окружения
Args:
settings: Словарь настроек игры (размер поля, количество мин и т.д.)
"""
self.settings = settings
self.grid_size = settings['grid_size']
self.mine_count = settings['mine_count']
self.last_arrow_time = 0
self.width, self.height = (1280, 1024)
# Создаем OpenGL окно с указанными параметрами
pygame.display.set_mode((self.width, self.height), DOUBLEBUF | OPENGL)
pygame.display.set_caption("3D Minesweeper - Use WASD, Arrows, Q/E, Space, F, R")
# Инициализация рендерера для 3D-графики
self.renderer = Renderer(self.width, self.height, self.grid_sizeself.settings['lighting_enabled'])
# Первоначальная настройка игрового состояния
self.init_game()
Пояснение ключевых моментов:
DOUBLEBUF | OPENGL
- флаги для создания окна с двойной буферизацией и OpenGL контекстом (Окно с двойной буферизацией создаётся для устранения мерцания и для плавности изображения)Renderer()
- создание объекта рендерера, который будет заниматься всей 3D-графикой
2.3.2 Метод init_game(self) - сброс игрового состояния
def init_game(self):
"""
Инициализация или сброс игрового состояния к начальным значениям
Создает чистую сетку, сбрасывает позицию курсора, таймеры и флаги состояния игры
"""
# Создаем сетку клеток с начальными значениями
self.grid = [[{'mine': False, 'revealed': False, 'flagged': False, 'adjacent': 0}
for _ in range(self.grid_size)] for _ in range(self.grid_size)]
# Позиция курсора (начинаем в центре поля)
self.cursor_pos = [self.grid_size // 2, self.grid_size // 2]
# Состояние игры
self.game_over = False # Флаг завершения игры
self.win = False # Флаг победы
self.first_click = True # Флаг первого хода (для безопасного старта)
self.start_time = pygame.time.get_ticks() # Время начала игры
self.elapsed_time = 0 # Прошедшее время игры
# Словарь для отслеживания состояния клавиш управления
self.keys_pressed = {
pygame.K_w: False, pygame.K_s: False, pygame.K_a: False, pygame.K_d: False,
pygame.K_q: False, pygame.K_e: False, pygame.K_UP: False, pygame.K_DOWN: False,
pygame.K_LEFT: False, pygame.K_RIGHT: False, pygame.K_ESCAPE: False
}
Структура данных клетки:
mine
есть ли мина в клеткеrevealed
открыта ли клеткаflagged
помечена ли флагомadjacent
количество мин в соседних клетках
2.3.3 Метод place_mines(self, safe_x, safe_y) - размещение мин
def place_mines(self, safe_x, safe_y):
"""
Размещение мин на поле с гарантией безопасной зоны вокруг первого клика
Args:
safe_x, safe_y: Координаты безопасной клетки (первый клик)
"""
mines_placed = 0
safe_cells = set()
# Создаем безопасную зону вокруг первого клика
for dx in [-1, 0, 1]:
for dy in [-1, 0, 1]:
nx, ny = safe_x + dx, safe_y + dy
if 0 <= nx < self.grid_size and 0 <= ny < self.grid_size:
safe_cells.add((nx, ny))
# Размещаем мины
while mines_placed < self.mine_count:
x, y = random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1)
if (x, y) not in safe_cells and not self.grid[y][x]['mine']:
self.grid[y][x]['mine'] = True
mines_placed += 1
# Обновляем счетчики соседних мин
for dx in [-1, 0, 1]:
for dy in [-1, 0, 1]:
if dx == 0 and dy == 0:
continue
nx, ny = x + dx, y + dy
if 0 <= nx < self.grid_size and 0 <= ny < self.grid_size:
self.grid[ny][nx]['adjacent'] += 1
Алгоритм безопасной зоны:
Создается зона 3x3 клетки вокруг первого клика, мины никогда не будут размещатся в этой зоне (понимаю, что немного не как в оригинале)
2.3.4 Метод reveal_cell(self, x, y) - вскрытие клетки
def reveal_cell(self, x, y):
"""Вскрытие клетки и рекурсивное вскрытие"""
if not (0 <= x < self.grid_size and 0 <= y < self.grid_size):
return
cell = self.grid[y][x]
if cell['revealed'] or cell['flagged']:
return
# Первый клик гарантируем безопасность
if self.first_click:
self.first_click = False
self.place_mines(x, y)
cell['revealed'] = True
# Проверка на мину
if cell['mine']:
self.game_over = True
# Показываем все мины при проигрыше
for row in self.grid:
for cell_data in row:
if cell_data['mine']:
cell_data['revealed'] = True
return
# Рекурсивное вскрытие пустых клеток
if cell['adjacent'] == 0:
for dx in [-1, 0, 1]:
for dy in [-1, 0, 1]:
if dx != 0 or dy != 0:
self.reveal_cell(x + dx, y + dy)
Рекурсивный алгоритм:
Если клетка пустая (0 мин вокруг), автоматически открываются все соседи, процесс продолжается, пока не встретятся клетки с числами
2.3.5 Метод check_win(self) - проверка победы
def check_win(self):
"""Проверка условия победы"""
for y in range(self.grid_size):
for x in range(self.grid_size):
cell = self.grid[y][x]
if not cell['mine'] and not cell['revealed']:
return False
self.win = True
return True
Логика победы: Игрок побеждает, когда все безопасные клетки открыты (можно добавить альтернативное условие победы например: все флаги расставлены правильно)
2.3.6 Метод handle_input(self) - обработка ввода
def handle_input(self):
"""Обработка ввода с клавиатуры"""
current_time = pygame.time.get_ticks()
# Управление камерой
speed = 2.0
if self.keys_pressed[pygame.K_w]:
self.renderer.camera_rotation_x = (self.renderer.camera_rotation_x - speed) % 360
if self.keys_pressed[pygame.K_s]:
self.renderer.camera_rotation_x = (self.renderer.camera_rotation_x + speed) % 360
if self.keys_pressed[pygame.K_a]:
self.renderer.camera_rotation_y = (self.renderer.camera_rotation_y - speed) % 360
if self.keys_pressed[pygame.K_d]:
self.renderer.camera_rotation_y = (self.renderer.camera_rotation_y + speed) % 360
# Приближение/отдаление
if self.keys_pressed[pygame.K_q]:
self.renderer.camera_distance = min(-5, self.renderer.camera_distance + speed * 0.5)
if self.keys_pressed[pygame.K_e]:
self.renderer.camera_distance = max(-40, self.renderer.camera_distance - speed * 0.5)
# Управление курсором
if self.keys_pressed[pygame.K_UP] and current_time - self.last_arrow_time > 150:
self.cursor_pos[1] = min(self.grid_size - 1, self.cursor_pos[1] + 1)
self.last_arrow_time = current_time
if self.keys_pressed[pygame.K_DOWN] and current_time - self.last_arrow_time > 150:
self.cursor_pos[1] = max(0, self.cursor_pos[1] - 1)
self.last_arrow_time = current_time
if self.keys_pressed[pygame.K_LEFT] and current_time - self.last_arrow_time > 150:
self.cursor_pos[0] = max(0, self.cursor_pos[0] - 1)
self.last_arrow_time = current_time
if self.keys_pressed[pygame.K_RIGHT] and current_time - self.last_arrow_time > 150:
self.cursor_pos[0] = min(self.grid_size - 1, self.cursor_pos[0] + 1)
self.last_arrow_time = current_time
Система задержки для курсора:
current_time - self.last_arrow_time > 150
- задержка 150 мс между перемещениями (предотвращает быстрое движение курсора)
2.3.7 Метод run(self) - главный игровой цикл
def run(self):
"""Главный игровой цикл"""
clock = pygame.time.Clock()
running = True
while running:
# Обновление времени
current_time = pygame.time.get_ticks()
if not self.game_over and not self.win:
self.elapsed_time = (current_time - self.start_time) // 1000
# Обработка событий
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key in self.keys_pressed:
self.keys_pressed[event.key] = True
# Выход в меню по ESC
if event.key == pygame.K_ESCAPE:
running = False
# Перезапуск игры
if (self.game_over or self.win) and event.key == pygame.K_r:
self.init_game()
continue
# Игровые действия
if not self.game_over and not self.win:
if event.key == pygame.K_SPACE:
x, y = self.cursor_pos
self.reveal_cell(x, y)
if not self.game_over and not self.first_click:
self.check_win()
elif event.key == pygame.K_f:
x, y = self.cursor_pos
if not self.grid[y][x]['revealed']:
self.grid[y][x]['flagged'] = not self.grid[y][x]['flagged']
elif event.type == pygame.KEYUP:
if event.key in self.keys_pressed:
self.keys_pressed[event.key] = False
# Обработка непрерывного ввода
self.handle_input()
# Очистка экрана
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
# Обновление камеры
self.renderer.update_camera()
# Отрисовка игрового поля
self.renderer.draw_grid(self.grid, self.cursor_pos, self.grid_size)
# Отображение интерфейса
self.draw_interface()
# Обновление дисплея
pygame.display.flip()
clock.tick(60)
Команды OpenGL:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
- очистка буферов цвета и глубиныpygame.display.flip()
- обмен буферов (двойная буферизация)
2.3.8 Метод draw_interface(self) - интерфейс поверх игры
def draw_interface(self):
"""Отрисовка интерфейса поверх игры"""
# Статус игры
if self.game_over:
self.renderer.draw_text("GAME OVER! Press R to restart", self.width // 1.5, self.height // 2, background=True)
elif self.win:
self.renderer.draw_text("YOU WIN! Press R to restart", self.width // 1.5, self.height // 2, background=True)
# Время и мины
time_text = f"Time: {self.elapsed_time}s"
mines_text = f"Mines: {self.mine_count}"
grid_text = f"Grid: {self.grid_size}x{self.grid_size}"
# Вычисляем относительные отступы от краев экрана
margin_x = self.width * 0.05 # 5% от ширины экрана
margin_y = self.height * 0.2 # 5% от высоты экрана
text_spacing = 40 + self.width * 0.05 # Фиксированный интервал между строками
# Левая колонка
self.renderer.draw_text(time_text, margin_x, margin_y, background=True)
self.renderer.draw_text(mines_text, margin_x, margin_y + text_spacing, background=True)
# Правая колонка - отступаем от правого края
right_margin_x = self.width - 200 # Фиксированная ширина текста или можно сделать относительной
self.renderer.draw_text(grid_text, right_margin_x, margin_y, background=True)
# Подсказки управления (нижний левый угол)
controls_text = "WASD: Camera Q/E: Zoom Arrows: Move Space: Reveal F: Flag"
self.renderer.draw_text(controls_text, margin_x, self.height - margin_y, background=True)
# Клавиши управления (нижний правый угол)
control_keys = "R: Restart ESC: Menu"
right_controls_x = self.width - 200
self.renderer.draw_text(control_keys, right_controls_x, self.height - margin_y, background=True)
2.4 renderer.py - система рендеринга
Рендерер игры представляет собой мощный инструмент для визуализации 3D-пространства, где каждый метод отвечает за ключевые аспекты отображения: инициализация OpenGL в init, настройка атмосферного освещения через setup_lighting, динамическое управление камерой в update_camera, отрисовка базовых элементов вроде кубов draw_cube и сложных объектов вроде мин с шипами в draw_mine, построение сетки поля draw_grid, а также гибкая система текстовой визуализации - от простого 2D-текста draw_text до объёмных 3D-чисел draw_number и специальных маркеров вроде флагов draw_flag.
Могу сказать, что удалось разделить по максимуму методы между собой, что даёт большую свободу для модификаций и добавления новых визуальных эффектов будущими разработчикам (то есть вам)
2.4.1 Метод init(self, width, height, grid_size, lighting_enabled) - инициализация
from OpenGL.GL import *
from OpenGL.GLU import *
import pygame
class Renderer:
def __init__(self, width, height, grid_size, lighting_enabled=True):
"""
Инициализация рендерера для визуализации игрового поля
Args:
width: Ширина окна отображения
height: Высота окна отображения
grid_size: Размер игрового поля (количество клеток)
lighting_enabled: Флаг включения освещения (по умолчанию True)
"""
self.width = width
self.height = height
self.grid_size = grid_size
self.cell_size = 2 # Размер одной клетки
self.depth = 0.7 # Глубина клеток
# Создание квадриков (мины и шипы)
self.mine_quadric = gluNewQuadric()
self.spike_quadric = gluNewQuadric()
# Параметры камеры
self.camera_distance = -30 # Дистанция камеры от центра сцены
self.camera_rotation_x = 30 # Угол поворота по оси X (наклон)
self.camera_rotation_y = -45 # Угол поворота по оси Y (вращение)
# Настройка OpenGL
gluPerspective(60, (width / height), 0.1, 75.0) # Перспективная проекция
glTranslatef(0.0, 0.0, self.camera_distance) # Позиционирование камеры
glRotatef(self.camera_rotation_x, 1, 0, 0) # Поворот по оси X
glRotatef(self.camera_rotation_y, 0, 1, 0) # Поворот по оси Y
glEnable(GL_DEPTH_TEST) # Включение теста глубины
glEnable(GL_COLOR_MATERIAL) # Включение цветовых материалов
# Настройка освещения если включено
if lighting_enabled:
self.setup_lighting()
# Инициализация шрифта для текстовых надписей
pygame.font.init()
self.font = pygame.font.SysFont('Times New Roman', 24)
2.4.2 Метод setup_lighting(self) - настройка освещения
def setup_lighting(self):
"""
Создает основное и дополнительное освещение
"""
glEnable(GL_LIGHTING) # Включение системы освещения
glEnable(GL_LIGHT0) # Активация первого источника света
glEnable(GL_LIGHT1) # Активация второго источника света
# Расчет половины размера игрового поля для позиционирования света
field_half_size = (self.grid_size * self.cell_size) / 2
# Настройка первого источника света (верхний-правый)
glLightfv(GL_LIGHT0, GL_POSITION, (field_half_size, -field_half_size, 10, 1))
glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.8, 0.8, 0.8, 1)) # Рассеянный свет
glLightfv(GL_LIGHT0, GL_AMBIENT, (0.2, 0.2, 0.2, 1)) # Фоновое освещение
# Настройка второго источника света (спереди-сверху)
glLightfv(GL_LIGHT1, GL_POSITION, (0, 0, 100, 1))
glLightfv(GL_LIGHT1, GL_DIFFUSE, (0.8, 0.8, 0.8, 1)) # Рассеянный свет
glLightfv(GL_LIGHT1, GL_AMBIENT, (0.2, 0.2, 0.2, 1)) # Фоновое освещение
Параметры источников света:
GL_POSITION
- позиция света в формате (x, y, z, w)GL_DIFFUSE
- цвет основное освещениеGL_AMBIENT
- цвет фонового освещения
2.4.3 Метод update_camera(self) - обновление камеры
def update_camera(self):
"""
Обновление позиции и ориентации камеры в 3D-пространстве
"""
glLoadIdentity() # Сброс матрицы преобразований
gluPerspective(60, (self.width / self.height), 0.1, 75.0) # Установка перспективы
glTranslatef(0.0, 0.0, self.camera_distance) # Перемещение камеры
glRotatef(self.camera_rotation_x, 1, 0, 0) # Поворот по оси X (вертикальный наклон)
glRotatef(self.camera_rotation_y, 0, 1, 0) # Поворот по оси Y (горизонтальное вращение)
2.4.4 Метод draw_cube(self, x, y, z, color) - отрисовка 3D-куба
def draw_cube(self, x, y, z, color):
"""
Метод создает и отрисовывает трехмерный куб:
Args:
x, y, z - координаты переднего верхнего угла куба в 3D-пространстве
color - цвет заливки граней куба
"""
vertices = [
[x, y, z], [x + self.cell_size, y, z], [x + self.cell_size, y + self.cell_size, z],
[x, y + self.cell_size, z], # Передняя грань (ближняя к наблюдателю)
[x, y, z - self.depth], [x + self.cell_size, y, z - self.depth],
[x + self.cell_size, y + self.cell_size, z - self.depth], [x, y + self.cell_size, z - self.depth]
# Задняя грань (дальняя от наблюдателя)
]
faces = [[0, 1, 2, 3], [4, 5, 6, 7], [0, 1, 5, 4], [2, 3, 7, 6], [0, 3, 7, 4], [1, 2, 6, 5]]
glBegin(GL_QUADS)
glColor3fv(color)
for face in faces:
for vertex in face:
glVertex3fv(vertices[vertex])
glEnd()
glColor3f(1, 1, 1)
glBegin(GL_LINES)
edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]
for edge in edges:
for vertex in edge:
glVertex3fv(vertices[vertex])
glEnd()
GL_QUADS
- рисует четырехугольники (грани куба)GL_LINES
- рисует линии (ребра куба)
2.4.5.Метод draw_grid(self, grid, cursor_pos, grid_size) - отрисовка поля
def draw_grid(self, grid, cursor_pos, grid_size):
"""
Отрисовка игрового поля с клетками, минами, флагами и курсором
Args:
grid: Двумерный массив клеток игрового поля
cursor_pos: Текущая позиция курсора (x, y)
grid_size: Размер игрового поля
"""
# Смещение для центрирования поля относительно начала координат
offset_x = -grid_size * self.cell_size / 2
offset_y = -grid_size * self.cell_size / 2
# Отрисовка всех клеток поля
for y in range(grid_size):
for x in range(grid_size):
cell_x = offset_x + x * self.cell_size
cell_y = offset_y + y * self.cell_size
cell = grid[y][x]
# Выбор цвета клетки в зависимости от состояния
if cell['revealed']:
color = (1, 0, 0) if cell['mine'] else (0.8, 0.8, 0.8) # Красный для мин, серый для пустых
else:
color = (0.4, 0.4, 0.8) # Синий для неоткрытых клеток
# Отрисовка базового куба клетки
self.draw_cube(cell_x, cell_y, 0, color)
# Отрисовка содержимого открытых клеток
if cell['revealed']:
if cell['mine']:
self.draw_mine(cell_x, cell_y) # Мина
elif cell['adjacent'] > 0:
self.draw_number(cell_x, cell_y, cell['adjacent']) # Число соседних мин
elif cell['flagged']:
self.draw_flag(cell_x, cell_y) # Флаг
# Отрисовка курсора (желтая рамка поверх клетки)
cursor_x = offset_x + cursor_pos[0] * self.cell_size
cursor_y = offset_y + cursor_pos[1] * self.cell_size
glColor3f(1, 1, 0) # Желтый цвет
glBegin(GL_LINE_LOOP)
glVertex3f(cursor_x, cursor_y, 0.1) # Лево-низ
glVertex3f(cursor_x + self.cell_size, cursor_y, 0.1) # Право-низ
glVertex3f(cursor_x + self.cell_size, cursor_y + self.cell_size, 0.1) # Право-верх
glVertex3f(cursor_x, cursor_y + self.cell_size, 0.1) # Лево-верх
glEnd()
Цветовая схема клеток:
Неоткрытые: синий
(0.4, 0.4, 0.8)
Открытые с миной: красный
(1, 0, 0)
Открытые пустые: серый
(0.8, 0.8, 0.8)
2.4.6 Метод draw_text(self, text, x, y, background) - 2D-текст
def draw_text(self, text, x, y, background=False):
"""
Отрисовка 2D-текста
Args:
text: Текст для отображения
x, y: Координаты левого верхнего угла
background: Добавлять ли полупрозрачный фон
"""
# Сохраняем текущие матрицы и настройки
glMatrixMode(GL_PROJECTION)
glPushMatrix()
glLoadIdentity()
glOrtho(0, self.width, self.height, 0, -1, 1)
glMatrixMode(GL_MODELVIEW)
glPushMatrix()
glLoadIdentity()
# Отключаем глубину и освещение для 2D
glDisable(GL_DEPTH_TEST)
glDisable(GL_LIGHTING)
# Включаем blending для прозрачности
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Рендерим текст
text_surface = self.font.render(text, True, (0, 255, 0, 255))
text_width = text_surface.get_width()
text_height = text_surface.get_height()
try:
text_data = pygame.image.tostring(text_surface, "RGBA", True)
glRasterPos2f(x, y)
# Устанавливаем цвет текста (белый)
glColor4f(0 , 0, 0, 5)
glDrawPixels(text_width, text_height, GL_RGBA, GL_UNSIGNED_BYTE, text_data)
except Exception as e:
print(f"Error drawing text: {e}")
# Восстанавливаем настройки
glDisable(GL_BLEND)
glEnable(GL_LIGHTING)
glEnable(GL_DEPTH_TEST)
glPopMatrix()
glMatrixMode(GL_PROJECTION)
glPopMatrix()
glMatrixMode(GL_MODELVIEW)
Переключение между 2D и 3D режимами:
Сохраняем текущие проекции и моделирования
Переключаемся в проекцию для 2D
Отключаем 3D-функции (глубину, освещение)
Включаем blending для прозрачности
Рисуем текст
Восстанавливаем предыдущие настройки
2.4.7 Метод draw_number(self, x, y, number) - отрисовка чисел на клетках
def draw_number(self, x, y, number):
"""
Рисование 3D-числа, показывающего количество мин вокруг
Args:
x, y: Координаты клетки
number: Число для отображения (1-8)
"""
if number == 0:
return # Не рисуем 0
# Цвета для разных чисел (как в классическом сапере)
colors = [
(0, 0, 255), # 1 - синий
(0, 255, 0), # 2 - зеленый
(255, 0, 0), # 3 - красный
(0, 0, 0.5), # 4 - темно-синий
(0.5, 0, 0), # 5 - темно-красный
(0, 0.5, 0.5), # 6 - бирюзовый
(0, 0, 0), # 7 - черный
(0.5, 0.5, 0.5) # 8 - серый
]
# Выбираем цвет в зависимости от числа
color = colors[number - 1] if number <= 8 else (1, 0, 1) # Фиолетовый для чисел >8
# Сохраняем текущую матрицу преобразований
glPushMatrix()
# Перемещаемся в центр клетки и немного выше поверхности
glTranslatef(x + self.cell_size / 2, y + self.cell_size / 2, 0.1)
# Масштабируем текст до нужного размера
glScalef(0.003, 0.003, 0.003) # Отрицательный масштаб по X исправляет зеркальность
# Центрируем текст (компенсируем отрицательное масштабирование)
text_width = len(str(number)) * 80 # Примерная ширина текста
glTranslatef(-text_width / 2, -150, 0) # Центрируем и опускаем немного вниз
glColor3fv(color)
# Рисуем число с помощью встроенных символов OpenGL
# (заменяем GLUT_STROKE_ROMAN на базовые примитивы)
self.draw_text_primitive(str(number))
# Восстанавливаем предыдущую матрицу
glPopMatrix()
Цветовая схема чисел:
Соответствует классическому "Сапёру" , каждое число имеет уникальный цвет для быстрой идентификации
Трансформации для позиционирования:
glTranslatef()
- перемещение в центр клеткиglScalef(0.003, 0.003, 0.003)
- масштабирование текстаglTranslatef(-text_width/2, -150, 0)
- центрирование и смещение вниз
2.4.8 Метод draw_text_primitive(self, text) - рисование цифр
def draw_text_primitive(self, text):
"""
Рисование текста с помощью базовых примитивов OpenGL
Простая реализация для цифр 0-9
Args:
text: Текст для отображения
"""
for char in text:
if char == '1':
glBegin(GL_LINES)
glVertex2f(40, -150)
glVertex2f(40, 150)
glEnd()
glTranslatef(100, 0, 0)
elif char == '2':
glBegin(GL_LINE_STRIP)
glVertex2f(10, 150) # Левая нижняя
glVertex2f(90, 150) # Правая нижняя
glVertex2f(90, 0) # Правая середина
glVertex2f(10, 0) # Левая середина
glVertex2f(10, -150) # Левая верхняя
glVertex2f(90, -150) # Правая верхняя
glEnd()
glTranslatef(120, 0, 0)
elif char == '3':
glBegin(GL_LINE_STRIP)
glVertex2f(10, -150)
glVertex2f(90, -150)
glVertex2f(90, 0)
glVertex2f(10, 0)
glVertex2f(90, 0)
glVertex2f(90, 150)
glVertex2f(10, 150)
glEnd()
glTranslatef(120, 0, 0)
elif char == '4':
glBegin(GL_LINES)
glVertex2f(10, 150)
glVertex2f(10, 0) # Левая вертикаль
glVertex2f(10, 0)
glVertex2f(90, 0) # Горизонталь
glVertex2f(90, 150)
glVertex2f(90, -150) # Правая вертикаль
glEnd()
glTranslatef(120, 0, 0)
elif char == '5':
glBegin(GL_LINE_STRIP)
glVertex2f(90, 150)
glVertex2f(10, 150)
glVertex2f(10, 0)
glVertex2f(90, 0)
glVertex2f(90, -150)
glVertex2f(10, -150)
glEnd()
glTranslatef(120, 0, 0)
elif char == '6':
glBegin(GL_LINE_STRIP)
glVertex2f(90, -150)
glVertex2f(10, -150)
glVertex2f(10, 150)
glVertex2f(90, 150)
glVertex2f(90, 0)
glVertex2f(10, 0)
glEnd()
glTranslatef(120, 0, 0)
elif char == '7':
glBegin(GL_LINE_STRIP)
glVertex2f(10, 150)
glVertex2f(90, 150)
glVertex2f(90, -150)
glEnd()
glTranslatef(120, 0, 0)
elif char == '8':
glBegin(GL_LINE_LOOP)
glVertex2f(10, -150)
glVertex2f(90, -150)
glVertex2f(90, 150)
glVertex2f(10, 150)
glEnd()
glBegin(GL_LINES)
glVertex2f(10, 0)
glVertex2f(90, 0)
glEnd()
glTranslatef(120, 0, 0)
else:
# Для неизвестных символов просто сдвигаемся
glTranslatef(100, 0, 0)
Примитивы OpenGL для рисования цифр:
GL_LINES
- отдельные линии (для цифры 1)GL_LINE_STRIP
- последовательные соединенные линии (для большинства цифр)GL_LINE_LOOP
- замкнутый контур (для цифры 8)
Координатная система для цифр:
Центр координат в середине цифры
Y от -150 (низ) до 150 (верх) и X от 10 (лево) до 90 (право)
2.9.9 Метод draw_flag(self, x, y) - отрисовка флага
def draw_flag(self, x, y):
"""
Рисование 3D-флага для помеченных клеток
Args:
x, y: Координаты клетки с флагом
"""
glPushMatrix()
glTranslatef(x + self.cell_size / 2, y + self.cell_size / 2, 0.1)
# Рисуем флагшток (коричневый прямоугольник)
glColor3f(0.5, 0.3, 0.1)
glBegin(GL_QUADS)
glVertex3f(-0.1, -0.4, 0)
glVertex3f(0.1, -0.4, 0)
glVertex3f(0.1, 0.4, 0)
glVertex3f(-0.1, 0.4, 0)
glEnd()
# Рисуем флаг (красный треугольник)
glColor3f(1, 0, 0)
glBegin(GL_TRIANGLES)
glVertex3f(0.1, 0.4, 0) # Верх флагштока
glVertex3f(0.1, 0.1, 0) # Низ флага
glVertex3f(0.5, 0.25, 0) # Кончик флага
glEnd()
glPopMatrix()
Структура флага:
Флагшток - коричневый прямоугольник от -0.4 до 0.4 по Y
Флаг - красный треугольник, прикрепленный к верхней части флагштока
2.4.10 Метод draw_mine(self, x, y) - отрисовка мины
def draw_mine(self, x, y):
"""
Рисование 3D-мины в виде сферы с шипами
Args:
x, y: Координаты клетки с миной
"""
glPushMatrix()
glTranslatef(x + self.cell_size / 2, y + self.cell_size / 2, 0.1)
# Рисуем черную сферу (тело мины)
glColor3f(0, 0, 0)
gluSphere(self.mine_quadric, 0.3, 20, 20)
# Рисуем серые шипы вокруг мины
glColor3f(0.5, 0.5, 0.5)
for i in range(8):
glPushMatrix()
glRotatef(45 * i, 0, 0, 1)
glTranslatef(0.5, 0, 0)
gluSphere(self.spike_quadric, 0.1, 10, 10)
glPopMatrix()
glPopMatrix()
Структура мины:
Тело: черная сфера радиусом 0.3
Шипы: 8 серых сфер радиусом 0.1, равномерно распределенных вокруг
Раздел 3: Финал - итоги и добрые слова
3.1 Итог: Что мы получили
Работающую 3D-версию классического "Сапёра" с свободой обзора камеры, интеллектуальным меню и реалистичной 3D-графикой. Игрок может:
Настраивать размер поля и количество мин перед игрой
Свободно вращать камеру вокруг игрового поля
Приближать и отдалять обзор
Видеть объемные мины с шипами и 3D-флаги
пример - вывод меню пример - вывод игрового поля
3.2 Что можно улучшить
☆ Добавить текстуры для клеток и объектов вместо однотонных цветов
☆ Реализовать систему частиц для взрыва при поражении
☆ Добавить звуковые эффекты и фоновую музыку
☆ Создать режим "ночная игра" с динамическим освещением
→ Полный код доступен для модификации и улучшения - на GitHub
→ Все три части проекта (консольная, 2D-GUI, 3D-версия) представляют полный цикл разработки, и также показывают, что одну игру можно реализовать разными способами
Я очень рад, что получилось создать несколько версий игры, которые визуально отличаются уже при запуске кода, демонстрируя эволюцию от простого к сложному. Очень хотелось бы, чтобы кто-то взял этот проект за основу и доработал его до красивой, законченной версии, а не просто оставил в каркасном состоянии - всегда можно добавить эффектный фон, анимацию взрыва или другие визуальные улучшения, превратив это либо в качественное дополнение, либо в совершенно новый продукт. Код находится в открытом доступе, и я уже видел, как сообщество улучшало консольную версию, поэтому очень надеюсь, что и эту 3D-реализацию кто-то подхватит и доведет до идеала, раскрыв весь её потенциал.
P.S. Если обнаружите проблемы или баги - сообщите для исправления