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

Почему именно 3D-версия?

После освоения текстового интерфейса и двумерной графики, логичным следующим шагом становится создание полноценной 3D-игры. Вот что принципиально нового нас ждет:

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

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

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

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

Если вы уже знакомы с основами Python и хотите погрузиться в мир 3D-графики и игровой разработки. Или просто поиграть в объёмного сапёра - этот проект станет идеальной отправной точкой для вас.

Технологический прорыв: от Tkinter к OpenGL

В отличие от предыдущих версий, где мы использовали Tkinter для создания интерфейса, здесь мы задействуем всю мощь и красоту всех базовых инструментов:

  • PyGame - для создания игрового окна и обработки ввода

  • OpenGL - для высокопроизводительного 3D-рендеринга

Архитектура проекта

Проект разделен на логические модули:

  • main.py точка входа и координация

  • menu.py система меню и настроек

  • game.py игровая логика и управление

  • renderer.py 3D-рендеринг и графика

Для кого этот проект?

Начинающим разработчикам: отличный способ познакомиться с 3D-графикой на практике
Опытным программистам: интересный вызов и возможность оптимизации
Всем любителям игр или просто любителям изменять код: шанс создать свою собственную 3D-версию классики

Подготовка: что нам понадобится?

Для работы потребуются библиотеки Python, и поэтому перед тем, как приступить к написанию, убедитесь, что у вас установлен Python (версия 3.10 или выше), если нет, то вы можете скачать её с официального сайта python.org. После скачивания python, нужно установить библиотеки:

pip install pygame PyOpenGL PyOpenGL_accelerate

Для следующего шага вам понадобятся знания Python и библиотеки:

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. menu.py - Система меню

Ключевые методы:

  • _init_(screen) - Используется для инициализации (настройки)  всех параметров

  • run() - Главный цикл обработки событий меню

  • handle_mouse_down(mouse_pos) - Обрабатывает клики мыши по элементам UI

  • update_slider_values(mouse_pos) - Обновляет значения при перетаскивании слайдеров

  • draw_interface() - Отрисовывает весь интерфейс меню

  • draw_slider(label, value, y_pos, value_text) - Рисует слайдер с меткой и значением

  • draw_lighting_toggle() - Отрисовывает переключатель освещения

  • draw_start_button() - Рисует кнопку начала игры

    Конструктор класса 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 - слайдер размера

Метод 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() - обновляет весь экран (в отличие от update(), который обновляет только измененные области)

Метод 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() - проверяет, находится ли точка внутри прямоугольника

Метод 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, чтобы мин не было больше, чем клеток, также автоматическая коррекция при превышении лимита)

Метод 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 - куда рисуем (координаты)

Метод 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 пикселей справа от слайдера, показывая текущее числовое значение настроек

Метод 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)

Примичание:

  • border_radius=20 - скругление углов для современного вида

  • Условное изменение цвета в зависимости от состояния (зеленный если включено, красный, если выключено)

Метод 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)

Метод get_settings() - возврат настроек

    def get_settings(self):
        """Возвращаем выбранные пользователем настройки"""
        return {
            'grid_size': self.grid_size,
            'mine_count': self.mine_count,
            'lighting_enabled': self.lighting_on
        }

Простота: Метод возвращает словарь с четко именованными ключами, готовый к использованию в основном классе игры.


3. game.py - Основная игровая логика

Список всех методов класса Minesweeper3D и их назначение:

  1. __init__(self, settings) - инициализация игры и создание игрового окружения

  2. init_game(self) - сброс игрового состояния к начальным значениям

  3. place_mines(self, safe_x, safe_y) - размещение мин с безопасной зоной

  4. reveal_cell(self, x, y) - вскрытие клетки и рекурсивное открытие соседей

  5. check_win(self) - проверка условий победы

  6. handle_input(self) - обработка непрерывного ввода с клавиатуры

  7. run(self) - главный игровой цикл

  8. draw_interface(self) - отрисовка интерфейса поверх игры

Метод 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-графикой

Метод 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 количество мин в соседних клетках

Метод 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 клетки вокруг первого клика, мины никогда не будут размещатся в этой зоне (понимаю, что немного не как в оригинале)

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

Метод 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

Логика победы: Игрок побеждает, когда все безопасные клетки открыты (можно добавить альтернативное условие победы например: все флаги расставлены правильно)

Метод 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 мс между перемещениями (предотвращает быстрое движение курсора)

Метод 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() - обмен буферов (двойная буферизация)

Метод 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)

4. renderer.py - система рендеринга

Список всех методов класса Renderer и их назначение:

  1. init(self, width, height, grid_size, lighting_enabled) - инициализация рендерера и OpenGL

  2. setup_lighting(self) - настройка системы освещения сцены

  3. update_camera(self) - обновление позиции и ориентации камеры

  4. draw_cube(self, x, y, z, color) - отрисовка 3D-куба (клетки поля)

  5. draw_grid(self, grid, cursor_pos, grid_size) - отрисовка всего игрового поля

  6. draw_text(self, text, x, y, background) - отрисовка 2D-текста поверх 3D-сцены

  7. draw_number(self, x, y, number) - отрисовка 3D-чисел на клетках

  8. draw_text_primitive(self, text) - низкоуровневое рисование цифр

  9. draw_flag(self, x, y) - отрисовка 3D-флага

  10. draw_mine(self, x, y) - отрисовка 3D-мины с шипами

Метод 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)

Метод 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 - цвет фонового освещения

Метод 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 (горизонтальное вращение)

Метод 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 - рисует линии (ребра куба)

Метод 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)

Метод 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. Восстанавливаем предыдущие настройки

Метод 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) - центрирование и смещение вниз

Метод 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 (право)

Метод 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. Флаг - красный треугольник, прикрепленный к верхней части флагштока

Метод 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, равномерно распределенных вокруг

Итог: Что мы получили?

Работающую 3D-версию классического "Сапёра" с свободой обзора камеры, интеллектуальным меню и реалистичной 3D-графикой. Игрок может:

  • Настраивать размер поля и количество мин перед игрой

  • Свободно вращать камеру вокруг игрового поля

  • Приближать и отдалять обзор

  • Видеть объемные мины с шипами и 3D-флаги

    пример - вывод меню
    пример - вывод меню
    пример -  вывод игрового поля
    пример - вывод игрового поля

Что можно улучшить?

☆ Добавить текстуры для клеток и объектов вместо однотонных цветов
☆ Реализовать систему частиц для взрыва при поражении
☆ Добавить звуковые эффекты и фоновую музыку
☆ Создать режим "ночная игра" с динамическим освещением


→ Полный код доступен для модификации и улучшения -  на GitHub
→ Все три части проекта (консольная, 2D-GUI, 3D-версия) представляют полный цикл разработки, и также показывают, что одну игру можно реализовать разными способами

Ваши идеи по улучшению визуальных эффектов или игровой механики могут стать основой для следующих версий проекта

P.S. Если обнаружите проблемы или баги - сообщите для исправления

Комментарии (12)


  1. JerryI
    29.09.2025 15:52

    Спасибо за текст, GPT!


    1. urvanov
      29.09.2025 15:52

      Там же один только листинг программного кода. Текста-то и нет.


      1. Laborant_Code Автор
        29.09.2025 15:52

        Согласен, в основном тут просто листинг кода и чуть-чуть описания, также ссылки на сам проект и прошлые статьи


    1. Laborant_Code Автор
      29.09.2025 15:52

      Добрый вечер, не буду врать или отнекиваться, что тут не использовался GPT (будет глупо, тк сейчас почти каждый его использует) - эта статья не исключение. Но могу заверить, что большая часть статьи написано, от руки и текст не исключения (пример можно посмотреть подписание методов, тк одни описанные структурно и красиво, другие на скорую руку - одно предложение)

      Также цель статьи другая не в том, чтобы я рассказал о своем коде и какой я молодец, а в том, чтобы кто-то прочитал статью (или хотя бы - заголовок), потом решил, поиграться с проектом и сделал свою игру сапёр с уникальными идеями. Или например: предложил интересную задумку для оптимизации или улучшения кода


      1. Jijiki
        29.09.2025 15:52

        оптимизация тут вырисовывается это шейдеры/свет, vao/vbo(/ebo), потомучто opengl 1 не дебажится в рендер доке, а так нормально, если работает и устраивает. + вы используете массив наверно, и скорее всего пускаете луч через тангенс, можно подумать о рейкасте(из центра - окна) и какой-то структуре(например дерево ограничивающих обьемов под рейкаст) как тренеровка на такой игре,


    1. Jijiki
      29.09.2025 15:52

      на 3д сложно писать статью это надо и код приводить в порядок, я сужу по learnopengl и описывать, и код подгатавливать, чтобы не обо всём было, да нормально, причем как делать сапёра и так понятно, в кубоиде наверно было бы интереснее, тут как первый пример приближение )

      иммерсивная бродилка в кубическом мире, где надо ставить флажки и нажимать гравитометр )


  1. Jijiki
    29.09.2025 15:52

    иммерсивный сапёр он как в игре от близов(на фестивале есть он )), или это надо подумать как бы выглядело в 3д )))

    кстати в 3д на кубиках можно поидее как вид сверху так и в мире прям бегать по кубикам флажки ставить наверно, у вас в 3д вон тоже есть )

    кстати на сапёре возможно можно строить пещеры, выйгрыш что не надо искать формулы, надо только понять как организовать вход в освободившееся пространство, и замостить эту полость гладкой поверхностью и поверхность для ходьбы выделить в карту высот до террейна, как раз и сталактиты и прочее получается


    1. Laborant_Code Автор
      29.09.2025 15:52

      Идея с пещерой от первого лица так-то отличная (тоже видел в рекомендациях), но боюсь - это пока не мой уровень. Плюс думаю все же это проще сделать через какой-то движок (пример Unity), чем чисто на языке python, а тут был план на простом примере реализовать игру


      1. Jijiki
        29.09.2025 15:52

        и не мой тоже, я тоже в 3д корпею щас, как раз думаю как пещеры генерить, просто я увидел, сам буквально только что, что эта игра по сути генерит то, что генерится сложными дерево образующими или фрактальными алгоритмами, просто клик и полость готова - рандомная)

        ну большой движок, если вам удобно используйте, но нулевой 3д тоже нормально, у меня щас простенькая физика и террейн, не хватает пока пещер для дальнейшей итерации левелинга )

        так, а какая разница если вы реализуете стрктуру, вам будет проще, всё упрётся только в нюансы физики )

        вот С++ glm glew структурку прикрутил bvh, дальше надо понять генерацию, причем в движке придётся генерить же тоже, причем, с бвх значительно проще становится делать чем без, не знаю как с к мерными деревьями, но с бвх проще, потомучто с террейнами можно свою систему на основе высот придумать в теории(типо на основе карт/градиентов/сразу карту под места где ходить и её в граф как-то кидать - это я пока не знаю)

        ну на пайтоне придётся использовать по итогу в большом мире numpy+numba (+ jit) наверное


        1. Jijiki
          29.09.2025 15:52

          градиент нужен для таких спусков/подьёмов

          Скрытый текст


        1. Laborant_Code Автор
          29.09.2025 15:52

          Согласен, идея использовать логику сапёра для генерации пещер - это круто, один клик и работает сложные алгоритмы, а BVH и карта высот отлично ложатся на эту концепцию

          Ваш стек C++/glew/BVH думаю мощное решение, особенно для масштабирования. На Python с numpy+numba, конечно, проще начать, но для большой игры, как будто не подходит. Когда у вас получиться его реализовать до конца, хотел бы увидеть


          1. Jijiki
            29.09.2025 15:52

            да я понимаю вас, один клик и сложное работает, просто есть генерируемый ландшафт стартовая локация(2д массив в 3д мире), её подбирать надо смотря картинку, допустим картинка подходит и старт не плохой, тогда с этой картинкой надо работать сделать логичные градиенты, и встаёт вопрос, как из такой ситуации по точке на карте начать копать пещеру(именно в этой ситуации тоесть мир целостный, максимум пещера отходит в свои карты по частям, потомучто понятно что вся пещера в карту не поместится), и вот если обстрагироваться, и создать массив 3д наподобии как в глобал илюминейшн или как в майнкрафте создать воксели, кастануть сапёра в гипер кубах(зараннее подобранного размера, и каким-то способ конвертнув это в полигоны гладкие, то дешевле, другой способ это генерация трубоподобных проходов на основе фаски, тоесть тут есть над чем подумать), бвх это так чисто ну да ускоряет(на бвх по логике отсечения выбора во время рейкаста можно и менюшки вешать, тоесть меню, интерфейс, а в игре то что выбирается или куда летит спелл), но это незаметно

            testcolBB1.cpp вот можете глянуть