В предыдущих частях мы рассмотрели создание консольной и 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.py3D-рендеринг и графика
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. Если обнаружите проблемы или баги - сообщите для исправления
