В предыдущих частях мы рассмотрели создание консольной и GUI-версии "Сапёра" на Python, теперь пришло время совершить скачок, перенести классическую игру в трехмерное пространство с использованием графических технологий и популярный библиотек (буду стараться подробно описать комментариями в коде, если не понятно, то напишите в коммментариях, чтобы обновил статью и сделал её более подробной)

Раздел 1: Подготовка и настройка проекта, также размышления по проекту

1.1 Размышления: Почему все же 3D, а не улучшение 2D или новые идеи

Изначально я воспринимал разработку игры как учебный проект, который вряд ли кто-то увидит. Однако, получив первую обратную связь, осознал ценность создания чего-то уникального в сообществе

Анализируя существующие проекты на Хабре, я заметил интересный парадокс: при обилии 3D-игр различных жанров, классический "Сапёр" в трёхмерном исполнении практически отсутствует. При этом с технической точки зрения переход от 2D к 3D не настолько сложен, как может показаться, во многом это логичное развитие после освоения Tkinter и 2D-графики, поэтому после освоения текстового интерфейса и двумерной графики, следующим шагом становится создание полноценной 3D-игры. Вот что принципиально нового нас ждет:

  1. Иммерсивный геймплей («Immersive‑gameplay») - вместо плоского поля вы получите объёмное пространство, где каждая клетка становится реальным 3D‑объектом

  2. Свобода камеры - возможность осматривать поле под любым углом, приближать и отдалять

  3. Тактильное управление - плавное перемещение курсора в трех измерениях вместо дискретных прыжков по клеткам

  4. Визуальная глубина - реалистичное освещение, тени, объёмные мины и флаги создают эффект присутствия

Если вы уже знакомы с основами 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_interfacedraw_sliderdraw_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 и автоматически определяет его размеры для адаптивного позиционирования

  1. screen.get_size() - Метод PyGame, возвращающий (width, height) с размерами окна

  2. pygame.font.SysFont('Times New Roman', 50) - Создание объекта шрифта:

    • Первый параметр - название шрифта ( можно менять на любой другой стандартный или установить свой акцидентный шрифт)

    • Второй параметр - размер в пикселях

  3. Пропорциональное позиционирование:

    • self.height // 8 - заголовок занимает 1/8 высоты от верха

    • self.width // 4 - слайдер занимает 1/4 ширины экрана

  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()

Пояснения ключевых команд и моментов:

  1. pygame.mouse.get_pos() - возвращает текущие координаты (x, y) курсора мыши

  2. pygame.event.get() - получает список всех событий, произошедших с последнего вызова

  3. event.type - тип события (QUIT, MOUSEBUTTONDOWN, KEYDOWN и т.д.)

  4. 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  # Заканчиваем работу меню

Слайдеры и обнаружение кликов:

  1. Вычисление позиции ручки:

    • (self.grid_size - 5) - нормализация (т.к. диапазон 5-20 → 0-15)

    • (self.slider_length / 15) - масштабирование на длину слайдера

  2. pygame.Rect() - создает прямоугольник для проверки столкновений

  3. 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

Алгоритм преобразования координат в значения:

  1. relative_x = x - slider_start_x - смещение от начала слайдера

  2. max(0, min(relative_x, self.slider_length)) - ограничение в границах слайдера

  3. Формула нормализации(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:

  1. self.screen.fill((40, 40, 80)) - заливка фона цветом RGB(40,40,80) - (тёмно-синий цвет с фиолетовым оттенком) , можно менять или например вставить фотографию

  2. font.render(text, antialias, color) - создание поверхности с текстом:

    • antialias=True - сглаживание краев текста

  3. surface.get_rect(center=(x, y)) - получение прямоугольника с центром в указанной позиции

  4. 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:

  1. pygame.draw.rect(surface, color, rect, width=0) рисование прямоугольника:

    • width=0 - заливка, width>0 - контур толщиной width

  2. pygame.draw.circle(surface, color, center, radius) рисование круга

  3. font.render() создает изображение текста с бежевым цветом (255,255,200) и сглаживанием

  4. 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()

Пояснение ключевых моментов:

  1. DOUBLEBUF | OPENGL - флаги для создания окна с двойной буферизацией и OpenGL контекстом (Окно с двойной буферизацией создаётся для устранения мерцания и для плавности изображения)

  2. 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))  # Фоновое освещение

Параметры источников света:

  1. GL_POSITION - позиция света в формате (x, y, z, w)

  2. GL_DIFFUSE - цвет основное освещение

  3. 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 режимами:

  1. Сохраняем текущие проекции и моделирования

  2. Переключаемся в проекцию для 2D

  3. Отключаем 3D-функции (глубину, освещение)

  4. Включаем blending для прозрачности

  5. Рисуем текст

  6. Восстанавливаем предыдущие настройки

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()

Цветовая схема чисел:

  • Соответствует классическому "Сапёру" , каждое число имеет уникальный цвет для быстрой идентификации

Трансформации для позиционирования:

  1. glTranslatef() - перемещение в центр клетки

  2. glScalef(0.003, 0.003, 0.003) - масштабирование текста

  3. 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()

Структура флага:

  1. Флагшток - коричневый прямоугольник от -0.4 до 0.4 по Y

  2. Флаг - красный треугольник, прикрепленный к верхней части флагштока

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. Если обнаружите проблемы или баги - сообщите для исправления