- PVSM.RU - https://www.pvsm.ru -
В предыдущих частях [1] мы рассмотрели создание консольной и GUI-версии "Сапёра" на Python. Теперь пришло время совершить качественный скачок перенести классическую игру в трехмерное пространство с использованием современных графических технологий и популярный библиотек.(буду стараться подробно описать комментариями в коде, если не понятно, то напишите в коммментариях, чтобы обновил статью и сделал её более подробной)
После освоения текстового интерфейса и двумерной графики, логичным следующим шагом становится создание полноценной 3D-игры. Вот что принципиально нового нас ждет:
1 - Иммерсивный геймплей ("Immersive-gameplay") - вместо плоского поля вы получите объёмное пространство, где каждая клетка становится реальным 3D-объектом
2 - Свобода камеры - возможность осматривать поле под любым углом, приближать и отдалять интересующие участки
3 - Тактильное управление - плавное перемещение курсора в трех измерениях вместо дискретных прыжков по клеткам
4 - Визуальная глубина - реалистичное освещение, тени, объёмные мины и флаги создают эффект присутствия
Если вы уже знакомы с основами Python и хотите погрузиться в мир 3D-графики и игровой разработки. Или просто поиграть в объёмного сапёра - этот проект станет идеальной отправной точкой для вас.
В отличие от предыдущих версий [2], где мы использовали Tkinter для создания интерфейса, здесь мы задействуем всю мощь и красоту всех базовых инструментов:
PyGame - для создания игрового окна и обработки ввода
OpenGL - для высокопроизводительного 3D-рендеринга
Проект разделен на логические модули:
main.py - точка входа и координация
menu.py - система меню и настроек
game.py - игровая логика и управление
renderer.py - 3D-рендеринг и графика
Начинающим разработчикам - отличный способ познакомиться с 3D-графикой на практике
Опытным программистам - интересный вызов и возможность оптимизации
Всем любителям игр или просто любителям изменять код - шанс создать свою собственную 3D-версию классики
Для работы потребуются библиотеки Python, и поэтому перед тем, как приступить к написанию, убедитесь, что у вас установлен Python (версия 3.10 или выше), если нет, то вы можете скачать её с официального сайта python.org [3].
Для следующего шага вам понадобятся знания Python и библиотеки:
pip install pygame PyOpenGL PyOpenGL_accelerate
Всё остальное - стандартные библиотеки Python
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()
Назначение: Координация работы между меню и игрой, управление жизненным циклом приложения
Ключевые методы:
_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() - Рисует кнопку начала игры
import pygame
class GameMenu:
def __init__(self, screen):
self.screen = screen
self.width, self.height = screen.get_size()
# Шрифты - подбираем на глаз
self.big_font = pygame.font.SysFont('Times New Roman', 50)
self.normal_font = pygame.font.SysFont('Times New Roman', 35)
# Настройки по умолчанию - стандартные значения
self.grid_size = 10 # Размер поля
self.mine_count = 15 # Мины для начала
self.lighting_on = True # Освещение включено
# Вычисляем позиции относительно размера экрана
self.middle_x = self.width // 2 # Центр экрана
# Высоты элементов - отступаем от верха пропорционально
self.title_y = self.height // 8 # Заголовок в верхней части
self.first_slider_y = self.height // 4 # Первый слайдер чуть ниже
self.slider_spacing = self.height // 10 # Расстояние между слайдерами
self.toggle_y = self.first_slider_y + self.slider_spacing * 2 # Переключатель после двух слайдеров
self.button_y = self.toggle_y + self.slider_spacing # Кнопка внизу
# Ширины элементов - пропорционально ширине экрана
self.slider_length = self.width // 4 # Слайдер занимает четверть экрана
self.btn_width = self.width // 5 # Кнопка поменьше
self.btn_height = 50 # Высота кнопки стандартная
# Для удобства - отступ слайдера от центра
self.slider_offset_x = self.width // 9
# Состояния перетаскивания
self.grid_dragging = False
self.mines_dragging = False
# Флаг работы меню
self.running = True
Пояснение: Класс принимает объект экрана ("screen") PyGame и автоматически определяет его размеры для адаптивного позиционирования элементов
screen.get_size() - метод PyGame, возвращающий кортеж (width, height) с размерами окна
pygame.font.SysFont('Times New Roman', 50) - создание объекта шрифта:
Первый параметр - название шрифта ( можно менять на любой другой стандартный или установить свой акцидентный шрифт)
Второй параметр - размер в пикселях
Пропорциональное позиционирование:
self.height // 8 - заголовок занимает 1/8 высоты от верха
self.width // 4 - слайдер занимает 1/4 ширины экрана
Состояния перетаскивания - флаги для отслеживания, какой слайдер сейчас перетаскивается
self.mines_dragging = False - слайдер мин
self.grid_dragging = False - слайдер размера
def run(self):
"""Главный цикл - открыт пока пользователь не выберет настройки"""
while self.running:
# Текущая позиция мыши для обработки hover эффектов
# P.S. hover-эффект — это изменение вида элемента, когда пользователь наводит на него курсор мыши
mouse_pos = pygame.mouse.get_pos()
# Разбираем все события которые накопились
for event in pygame.event.get():
if event.type == pygame.QUIT:
return None # Выход из игры если закрыли окно
# Нажатие кнопки мыши - возможно начало перетаскивания или клик
if event.type == pygame.MOUSEBUTTONDOWN:
self.handle_mouse_down(mouse_pos)
# Отпустили кнопку - заканчиваем перетаскивание
if event.type == pygame.MOUSEBUTTONUP:
self.grid_dragging = False
self.mines_dragging = False
# Клавиша ESC - выход в меню
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
return None
# Если тащим слайдер - обновляем его значение
if self.grid_dragging or self.mines_dragging:
self.update_slider_values(mouse_pos)
# Рисуем все элементы интерфейса
self.draw_interface()
pygame.display.flip()
# Не грузим процессор - ждем немного
pygame.time.delay(30)
# Возвращаем настройки когда пользователь нажал "Начать игру"
return self.get_settings()
Пояснения ключевых команд и моментов:
pygame.mouse.get_pos() - возвращает текущие координаты (x, y) курсора мыши
pygame.event.get() - получает список всех событий, произошедших с последнего вызова
event.type - тип события (QUIT, MOUSEBUTTONDOWN, KEYDOWN и т.д.)
pygame.display.flip() - обновляет весь экран (в отличие от update(), который обновляет только измененные области)
pygame.time.delay(30) - приостанавливает выполнение на 30 миллисекунд для контроля
def handle_mouse_down(self, mouse_pos):
"""Обрабатываем клик мыши - проверяем куда попали"""
x, y = mouse_pos
# Позиция начала слайдера (левый край)
slider_start_x = self.middle_x - self.slider_offset_x
# Вычисляем где должна быть ручка слайдера размера сетки
grid_handle_x = slider_start_x + (self.grid_size - 5) * (self.slider_length / 15)
# Область ручки - немного расширяем для удобства клика
handle_left = grid_handle_x - 15
handle_right = grid_handle_x + 15
handle_top = self.first_slider_y - 5
handle_bottom = self.first_slider_y + 25
# Проверяем попали ли в ручку слайдера размера сетки
if (handle_left < x < handle_right and handle_top < y < handle_bottom):
self.grid_dragging = True
# Аналогично для слайдера количества мин (он ниже)
mines_handle_x = slider_start_x + (self.mine_count - 5) * (self.slider_length / 95)
mines_handle_top = self.first_slider_y + self.slider_spacing - 5
mines_handle_bottom = mines_handle_top + 30
if (mines_handle_x - 15 < x < mines_handle_x + 15 and
mines_handle_top < y < mines_handle_bottom):
self.mines_dragging = True
# Проверяем клик по переключателю освещения
toggle_rect = pygame.Rect(self.middle_x - 70, self.toggle_y, 140, 40)
if toggle_rect.collidepoint(mouse_pos):
self.lighting_on = not self.lighting_on # Переключаем состояние
# Проверяем клик по кнопке "Начать игру"
start_rect = pygame.Rect(self.middle_x - self.btn_width // 2, self.button_y,
self.btn_width, self.btn_height)
if start_rect.collidepoint(mouse_pos):
self.running = False # Заканчиваем работу меню
Математика слайдеров и обнаружение кликов:
Вычисление позиции ручки:
(self.grid_size - 5) - нормализация (т.к. диапазон 5-20 → 0-15)
(self.slider_length / 15) - масштабирование на длину слайдера
pygame.Rect() - создает прямоугольник для проверки столкновений:
Параметры: (x, y, width, height)
collidepoint() - проверяет, находится ли точка внутри прямоугольника
Область клика расширена на ±15 пикселей для удобства пользователя
def update_slider_values(self, mouse_pos):
"""Обновляем значения когда тащим слайдер"""
x, y = mouse_pos
slider_start_x = self.middle_x - self.slider_offset_x
# Обновляем размер сетки если тащим первый слайдер
if self.grid_dragging:
# Вычисляем относительное положение мыши на слайдере
relative_x = x - slider_start_x
# Ограничиваем в пределах слайдера
relative_x = max(0, min(relative_x, self.slider_length))
# Пересчитываем в значение от 5 до 20
self.grid_size = 5 + int(relative_x / self.slider_length * 15)
# Аналогично для количества мин
if self.mines_dragging:
relative_x = x - slider_start_x
relative_x = max(0, min(relative_x, self.slider_length))
self.mine_count = 5 + int(relative_x / self.slider_length * 95)
# Защита от дурака - не может быть мин больше чем клеток (минус 9 для безопасной зоны)
max_possible_mines = self.grid_size * self.grid_size - 9
if self.mine_count > max_possible_mines:
self.mine_count = max_possible_mines
Алгоритм преобразования координат в значения:
relative_x = x - slider_start_x - смещение от начала слайдера
max(0, min(relative_x, self.slider_length)) - ограничение в границах слайдера
Формула нормализации: (relative_x / slider_length) * range + min_value
Защита от некорректных значений:
grid_size * grid_size - 9 - максимальное количество мин (минус безопасная зона 3x3, чтобы мин не было больше, чем клеток, также автоматическая коррекция при превышении лимита)
def draw_interface(self):
"""Рисуем весь интерфейс меню"""
# Заливаем фон темно-синим цветом
self.screen.fill((40, 40, 80))
# Заголовок игры по центру сверху
title = self.big_font.render("Сапер 3D", True, (255, 255, 200))
title_rect = title.get_rect(center=(self.middle_x, self.title_y))
self.screen.blit(title, title_rect)
# Рисуем два слайдера - для размера поля и количества мин
self.draw_slider("Размер поля:", self.grid_size, self.first_slider_y,
f"{self.grid_size}×{self.grid_size}")
self.draw_slider("Количество мин:", self.mine_count,
self.first_slider_y + self.slider_spacing, str(self.mine_count))
# Переключатель освещения
self.draw_lighting_toggle()
# Кнопка начала игры
self.draw_start_button()
Ключевые команды PyGame:
self.screen.fill((40, 40, 80)) - заливка фона цветом RGB(40,40,80) - (тёмно-синий цвет с фиолетовым оттенком) , можно менять или например вставить фотографию
font.render(text, antialias, color) - создание поверхности с текстом:
antialias=True - сглаживание краев текста
Цвет в формате RGB
surface.get_rect(center=(x, y)) - получение прямоугольника с центром в указанной позиции
self.screen.blit(source, dest) - отрисовка поверхности на экран:
source - что рисуем
dest - куда рисуем (координаты)
def draw_slider(self, label, value, y_pos, value_text):
"""Рисуем один слайдер с меткой и значением"""
# Начало слайдера (левый край)
slider_start_x = self.middle_x - self.slider_offset_x
# Метка слева от слайдера
label_surface = self.normal_font.render(label, True, (255, 255, 255))
# Размещаем метку слева от слайдера с выравниванием по базовой линии
self.screen.blit(label_surface, (slider_start_x - label_surface.get_width() - 20, y_pos))
# Значение справа от слайдера
value_surface = self.normal_font.render(value_text, True, (255, 255, 200))
self.screen.blit(value_surface, (slider_start_x + self.slider_length + 10, y_pos))
# Фоновая полоса слайдера
pygame.draw.rect(self.screen, (80, 80, 120),
(slider_start_x, y_pos + 15, self.slider_length, 8))
# Заполненная часть - показывает текущее значение
if value > 5:
# Вычисляем ширину заполненной части в зависимости от типа слайдера
if "Размер" in label:
fill_width = (value - 5) * (self.slider_length / 15)
else:
fill_width = (value - 5) * (self.slider_length / 95)
pygame.draw.rect(self.screen, (0, 120, 220),
(slider_start_x, y_pos + 15, fill_width, 8))
# Ручка слайдера - кружок по текущей позиции
if "Размер" in label:
handle_x = slider_start_x + (value - 5) * (self.slider_length / 15)
else:
handle_x = slider_start_x + (value - 5) * (self.slider_length / 95)
pygame.draw.circle(self.screen, (255, 255, 255), (int(handle_x), y_pos + 19), 12)
Примитивы PyGame:
pygame.draw.rect(surface, color, rect, width=0) - рисование прямоугольника:
width=0 - заливка, width>0 - контур толщиной width
pygame.draw.circle(surface, color, center, radius) - рисование круга
font.render() создает изображение текста с бежевым цветом (255,255,200) и сглаживанием
screen.blit() рисует это изображение с отступом 10 пикселей справа от слайдера, показывая текущее числовое значение настроек
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 - скругление углов для современного вида
Условное изменение цвета в зависимости от состояния (зеленный если включено, красный если выключено)
Центрирование текста относительно переключателя
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)
def get_settings(self):
"""Возвращаем выбранные пользователем настройки"""
return {
'grid_size': self.grid_size,
'mine_count': self.mine_count,
'lighting_enabled': self.lighting_on
}
Простота: Метод возвращает словарь с четко именованными ключами, готовый к использованию в основном классе игры.
__init__(self, settings) - инициализация игры и создание игрового окружения
init_game(self) - сброс игрового состояния к начальным значениям
place_mines(self, safe_x, safe_y) - размещение мин с безопасной зоной
reveal_cell(self, x, y) - вскрытие клетки и рекурсивное открытие соседей
check_win(self) - проверка условий победы
handle_input(self) - обработка непрерывного ввода с клавиатуры
run(self) - главный игровой цикл
draw_interface(self) - отрисовка интерфейса поверх игры
import pygame
from pygame.locals import *
from OpenGL.GL import *
from OpenGL.GLU import *
import random
from renderer import Renderer
class Minesweeper3D:
def __init__(self, settings):
"""
Инициализация игры с настройками и создание игрового окружения
Args:
settings: Словарь настроек игры (размер поля, количество мин и т.д.)
"""
self.settings = settings
self.grid_size = settings['grid_size']
self.mine_count = settings['mine_count']
self.last_arrow_time = 0
self.width, self.height = (1280, 1024)
# Создаем OpenGL окно с указанными параметрами
pygame.display.set_mode((self.width, self.height), DOUBLEBUF | OPENGL)
pygame.display.set_caption("3D Minesweeper - Use WASD, Arrows, Q/E, Space, F, R")
# Инициализация рендерера для 3D-графики
self.renderer = Renderer(self.width, self.height, self.grid_sizeself.settings['lighting_enabled'])
# Первоначальная настройка игрового состояния
self.init_game()
Пояснение ключевых моментов:
DOUBLEBUF | OPENGL - флаги для создания окна с двойной буферизацией и OpenGL контекстом (Окно с двойной буферизацией создаётся для устранения мерцания и для плавности изображения)
Renderer(...) - создание объекта рендерера, который будет заниматься всей 3D-графикой
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 - количество мин в соседних клетках
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 клетки вокруг первого клика, мины никогда не будут размещатся в этой зоне (понимаю, что немного не как в оригинале)
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 мин вокруг), автоматически открываются все сосед, процесс продолжается пока не встретятся клетки с числами
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
Логика победы: Игрок побеждает, когда все безопасные клетки открыты (можно добавить альтернативное условие победы например: все флаги расставлены правильно)
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 мс между перемещениями (предотвращает слишком быстрое движение курсора)
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)
Структура игрового цикла:
Обработка событий (клавиатуы и мыши)
Обновление игрового состояния
Отрисовка графики
Контроль FPS
Команды OpenGL:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - очистка буферов цвета и глубины
pygame.display.flip() - обмен буферов (двойная буферизация)
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)
init(self, width, height, grid_size, lighting_enabled) - инициализация рендерера и OpenGL
setup_lighting(self) - настройка системы освещения сцены
update_camera(self) - обновление позиции и ориентации камеры
draw_cube(self, x, y, z, color) - отрисовка 3D-куба (клетки поля)
draw_grid(self, grid, cursor_pos, grid_size) - отрисовка всего игрового поля
draw_text(self, text, x, y, background) - отрисовка 2D-текста поверх 3D-сцены
draw_number(self, x, y, number) - отрисовка 3D-чисел на клетках
draw_text_primitive(self, text) - низкоуровневое рисование цифр
draw_flag(self, x, y) - отрисовка 3D-флага
draw_mine(self, x, y) - отрисовка 3D-мины с шипами
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)
def setup_lighting(self):
"""
Настройка системы освещения сцены с двумя источниками света
Создает основное и дополнительное освещение
"""
glEnable(GL_LIGHTING) # Включение системы освещения
glEnable(GL_LIGHT0) # Активация первого источника света
glEnable(GL_LIGHT1) # Активация второго источника света
# Расчет половины размера игрового поля для позиционирования света
field_half_size = (self.grid_size * self.cell_size) / 2
# Настройка первого источника света (верхний-правый)
glLightfv(GL_LIGHT0, GL_POSITION, (field_half_size, -field_half_size, 10, 1))
glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.8, 0.8, 0.8, 1)) # Рассеянный свет
glLightfv(GL_LIGHT0, GL_AMBIENT, (0.2, 0.2, 0.2, 1)) # Фоновое освещение
# Настройка второго источника света (спереди-сверху)
glLightfv(GL_LIGHT1, GL_POSITION, (0, 0, 100, 1))
glLightfv(GL_LIGHT1, GL_DIFFUSE, (0.8, 0.8, 0.8, 1)) # Рассеянный свет
glLightfv(GL_LIGHT1, GL_AMBIENT, (0.2, 0.2, 0.2, 1)) # Фоновое освещение
Параметры источников света:
GL_POSITION - позиция света в формате (x, y, z, w)
GL_DIFFUSE - цвет основное освещение
GL_AMBIENT - цвет фонового освещения
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 (горизонтальное вращение)
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 - рисует линии (ребра куба)
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)
def draw_text(self, text, x, y, background=False):
"""
Отрисовка 2D-текста
Args:
text: Текст для отображения
x, y: Координаты левого верхнего угла
background: Добавлять ли полупрозрачный фон
"""
# Сохраняем текущие матрицы и настройки
glMatrixMode(GL_PROJECTION)
glPushMatrix()
glLoadIdentity()
glOrtho(0, self.width, self.height, 0, -1, 1)
glMatrixMode(GL_MODELVIEW)
glPushMatrix()
glLoadIdentity()
# Отключаем глубину и освещение для 2D
glDisable(GL_DEPTH_TEST)
glDisable(GL_LIGHTING)
# Включаем blending для прозрачности
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Рендерим текст
text_surface = self.font.render(text, True, (0, 255, 0, 255))
text_width = text_surface.get_width()
text_height = text_surface.get_height()
try:
text_data = pygame.image.tostring(text_surface, "RGBA", True)
glRasterPos2f(x, y)
# Устанавливаем цвет текста (белый)
glColor4f(0 , 0, 0, 5)
glDrawPixels(text_width, text_height, GL_RGBA, GL_UNSIGNED_BYTE, text_data)
except Exception as e:
print(f"Error drawing text: {e}")
# Восстанавливаем настройки
glDisable(GL_BLEND)
glEnable(GL_LIGHTING)
glEnable(GL_DEPTH_TEST)
glPopMatrix()
glMatrixMode(GL_PROJECTION)
glPopMatrix()
glMatrixMode(GL_MODELVIEW)
Переключение между 2D и 3D режимами:
Сохраняем текущие проекции и моделирования
Переключаемся в проекцию для 2D
Отключаем 3D-функции (глубину, освещение)
Включаем blending для прозрачности
Рисуем текст
Восстанавливаем предыдущие настройки
def draw_number(self, x, y, number):
"""
Рисование 3D-числа, показывающего количество мин вокруг
Args:
x, y: Координаты клетки
number: Число для отображения (1-8)
"""
if number == 0:
return # Не рисуем 0
# Цвета для разных чисел (как в классическом сапере)
colors = [
(0, 0, 255), # 1 - синий
(0, 255, 0), # 2 - зеленый
(255, 0, 0), # 3 - красный
(0, 0, 0.5), # 4 - темно-синий
(0.5, 0, 0), # 5 - темно-красный
(0, 0.5, 0.5), # 6 - бирюзовый
(0, 0, 0), # 7 - черный
(0.5, 0.5, 0.5) # 8 - серый
]
# Выбираем цвет в зависимости от числа
color = colors[number - 1] if number <= 8 else (1, 0, 1) # Фиолетовый для чисел >8
# Сохраняем текущую матрицу преобразований
glPushMatrix()
# Перемещаемся в центр клетки и немного выше поверхности
glTranslatef(x + self.cell_size / 2, y + self.cell_size / 2, 0.1)
# Масштабируем текст до нужного размера
glScalef(0.003, 0.003, 0.003) # Отрицательный масштаб по X исправляет зеркальность
# Центрируем текст (компенсируем отрицательное масштабирование)
text_width = len(str(number)) * 80 # Примерная ширина текста
glTranslatef(-text_width / 2, -150, 0) # Центрируем и опускаем немного вниз
glColor3fv(color)
# Рисуем число с помощью встроенных символов OpenGL
# (заменяем GLUT_STROKE_ROMAN на базовые примитивы)
self.draw_text_primitive(str(number))
# Восстанавливаем предыдущую матрицу
glPopMatrix()
Цветовая схема чисел:
Соответствует классическому "Сапёру"
Каждое число имеет уникальный цвет для быстрой идентификации
Трансформации для позиционирования:
glTranslatef() - перемещение в центр клетки
glScalef(0.003, 0.003, 0.003) - масштабирование текста
glTranslatef(-text_width/2, -150, 0) - центрирование и смещение вниз
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 (право)
def draw_flag(self, x, y):
"""
Рисование 3D-флага для помеченных клеток
Args:
x, y: Координаты клетки с флагом
"""
glPushMatrix()
glTranslatef(x + self.cell_size / 2, y + self.cell_size / 2, 0.1)
# Рисуем флагшток (коричневый прямоугольник)
glColor3f(0.5, 0.3, 0.1)
glBegin(GL_QUADS)
glVertex3f(-0.1, -0.4, 0)
glVertex3f(0.1, -0.4, 0)
glVertex3f(0.1, 0.4, 0)
glVertex3f(-0.1, 0.4, 0)
glEnd()
# Рисуем флаг (красный треугольник)
glColor3f(1, 0, 0)
glBegin(GL_TRIANGLES)
glVertex3f(0.1, 0.4, 0) # Верх флагштока
glVertex3f(0.1, 0.1, 0) # Низ флага
glVertex3f(0.5, 0.25, 0) # Кончик флага
glEnd()
glPopMatrix()
Структура флага:
Флагшток - коричневый прямоугольник от -0.4 до 0.4 по Y
Флаг - красный треугольник, прикрепленный к верхней части флагштока
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 [4]
→ Все три части проекта (консольная [1], 2D-GUI [2], 3D-версия) представляют полный цикл разработки, и также показывают, что одну игру можно реализовать разными способами
Ваши идеи по улучшению визуальных эффектов или игровой механики могут стать основой для следующих версий проекта
P.S. Если обнаружите проблемы - сообщите для исправления
Автор: Laborant_Code
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/opengl/432069
Ссылки в тексте:
[1] предыдущих частях: https://habr.com/ru/articles/936038/
[2] предыдущих версий: https://habr.com/ru/articles/937688/
[3] python.org: http://python.org
[4] GitHub: https://github.com/Novelros/Minesweeper_python_game
[5] Источник: https://habr.com/ru/articles/951782/?utm_campaign=951782&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.