игра «Сапёра»
игра «Сапёра»

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

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

После текстового интерфейса в консоли, логично перейти к визуальному оформлению. Вот что нас ждёт:

1 — Кнопки вместо ввода координат
2 — Визуальные флажки и открытие клеток
3 — Таймер
4 — Полноценный игровой процесс, как в классической версии

Если вы только начинаете работать с GUI в Python — этот проект идеально подойдёт для практики.(буду стараться подробно описать комментариями в коде, если не понятно, то напишите в коммментариях, чтобы обновил статью и сделал её более подробной)

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

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

import random
import tkinter as tk
from tkinter import messagebox, simpledialog
from time import time

Архитектура игры:

«Сапёр» состоит из нескольких ключевых компонентов:

  1. Главное окно (класс MinesweeperGUI) — основа интерфейса

  2. Игровое поле — сетка 9×9 кнопок

  3. Логика игры — генерация мин, обработка ходов

  4. Интерфейс — меню, таймер, кнопки

2. Создаём основу игры

Создаём класс MinesweeperGUI и настраиваем главное окно:

2.1. Инициализация окна

Метод init выполняет начальную настройку:

  • root — главное окно приложения, которое передается в конструктор.

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

class MinesweeperGUI:
    def __init__(self, root):
        self.root = root
        self.root.title("Сапёр") # названия окна для игры сапер
        
        # Настройки игры
        self.grid_size = 9  # Стандартный размер поля 9x9
        self.max_bombs = 80  # Максимальное допустимое количество мин
        self.min_bombs = 1   # Минимальное количество мин
        self.default_bombs = 10  # Количество мин по умолчанию
        self.bomb_count = self.default_bombs  # Текущее количество мин

        # Состояние игры
        self.flags = set()  # Множество координат клеток с флагами
        self.correct_flags = set()  # Множество правильных флагов
        self.first_click = True  # Флаг первого хода (для мин так, чтобы не было ошибок, как в первой статье)
        self.game_started = False  # Флаг запущенной игры (для таймера)
        self.start_time = 0  # Время начала игры

        # Элементы интерфейса (таймер)
        self.timer_label = None
        self.buttons = []

        # Инициализация интерфейса
        self.create_menu()
        self.create_game_interface()
        self.init_game()

2.2. Добавляем меню

Метод create_menu() добавляет стандартное меню с пунктами:

  • Новая игра (вызывает start_new_game())

  • Настройки (вызывает change_settings())

 def create_menu(self):
        """Создает меню игры"""
        menubar = tk.Menu(self.root)

        game_menu = tk.Menu(menubar, tearoff=0)
        game_menu.add_command(label="Новая игра", command=self.start_new_game)
        game_menu.add_command(label="Настройки", command=self.change_settings)
        game_menu.add_separator() # Разделитель между пунктами
        game_menu.add_command(label="Выход", command=self.root.quit)
        menubar.add_cascade(label="Игра", menu=game_menu)

        self.root.config(menu=menubar)

    def create_game_interface(self):
        """Создает игровой интерфейс (таймер и поле)"""
        # Создаем таймер
        self.timer_label = tk.Label(
            self.root,
            text="Время: 0 сек",
            font=('Calibri', 12)
        )
        self.timer_label.grid(row=0, column=0, columnspan=self.grid_size, sticky="ew")

        # Создаем кнопки для клеток
        self.create_buttons()

    def create_buttons(self):
        """Создает кнопки игрового поля"""

        # Создаем новые кнопки
        self.buttons = []  # Очищаем массив кнопок
        for row in range(self.grid_size):
            button_row = []  # Создаем ряд кнопок
            for col in range(self.grid_size):
                btn = tk.Button(
                    self.root,
                    text=' ',  # Пустой текст по умолчанию
                    width=3, height=1,  # Размер кнопки
                    font=('Calibri', 12, 'bold'),
                    command=lambda r=row, c=col: self.on_left_click(r, c)
                )
                btn.bind('<Button-3>', lambda event, r=row, c=col: self.on_right_click(r, c))
                btn.grid(row=row + 1, column=col, sticky='nsew')  # +1 чтобы пропустить строку с таймером
                button_row.append(btn)
            self.buttons.append(button_row)

3. Игровая логика

3.1. Генерация мин после первого хода

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

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

  1. place_mines(first_row, first_col) — размещает мины, избегая зоны 3×3 вокруг первого клика

  2. count_adjacent_mines(row, col) — считает мины вокруг указанной клетки

  3. create_buttons() — создаёт сетку интерактивных кнопок

 def place_mines(self, first_row, first_col):
        """Размещает мины на поле, избегая первой клетки и соседей"""
        # Создаем безопасную зону 3x3 вокруг первого клика
        safe_zone = set()
        for r in range(first_row - 1, first_row + 2):
            for c in range(first_col - 1, first_col + 2):
                # Проверяем, что координаты находятся в пределах поля
                if 0 <= r < self.grid_size and 0 <= c < self.grid_size:
                    safe_zone.add((r, c))

        #  Генерируем мины, избегая безопасной зоны
        self.mines = set() # Множество для хранения координат мин
        while len(self.mines) < self.bomb_count:
            r, c = random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1) # randint вручную задаёте диапазон, и из него в консоль выводится случайное целое число.
            if (r, c) not in safe_zone:
                self.mines.add((r, c))
                self.hidden_map[r][c] = 'M'

        # Заполняем поле числами (количество мин вокруг каждой клетки)
        for r in range(self.grid_size):
            for c in range(self.grid_size):
                if self.hidden_map[r][c] != 'M':
                    count = self.count_adjacent_mines(r, c)
                    if count > 0:
                        self.hidden_map[r][c] = str(count)

def count_adjacent_mines(self, row, col):
  """Считает количество мин вокруг указанной клетки"""
  count = 0
  # Проверяем все соседние клетки (3x3 область)
  for r in range(row - 1, row + 2):
      for c in range(col - 1, col + 2):
          if (0 <= r < self.grid_size and 0 <= c < self.grid_size and
                    self.hidden_map[r][c] == 'M'):
              count += 1
  return count

3.2. Обработка кликов

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

  • on_left_click(row, col) — обрабатывает открытие клетки.

  • on_right_click(row, col) — обработка флагов.

  • reveal_cells(row, col) — рекурсивно открывает соседние пустые клетки

    def on_left_click(self, row, col):
        """Обрабатывает левый клик мыши (открытие клетки)"""
        if (row, col) in self.flags:  # Не открываем помеченные флажками клетки
            return
    
        # Если это первый ход - генерируем мины
        if self.first_click:
            self.place_mines(row, col)
            self.start_game_timer()
            self.first_click = False
            
        # Если наступили на мину - конец игры
        if self.hidden_map[row][col] == 'M':
            self.game_over(False)
            return
          
        # Открываем клетку/область
        self.reveal_cells(row, col)
        self.update_buttons()

        if self.check_win():
            self.game_over(True)

    def on_right_click(self, row, col):
        """Обрабатывает правый клик мыши (установка/снятие флага)"""    
        if self.player_map[row][col] != '-':  # Не ставим флаги на открытые клетки
            return

        # Если флаг уже стоит - убираем его
        if (row, col) in self.flags:
            self.flags.remove((row, col))
            if (row, col) in self.correct_flags:
                self.correct_flags.remove((row, col))
        else:
            # Ставим новый флаг
            self.flags.add((row, col))
            if self.hidden_map[row][col] == 'M':
                self.correct_flags.add((row, col))

        self.update_buttons()

        # Проверяем победу (все мины правильно помечены)
        if (len(self.correct_flags) == self.bomb_count and
                len(self.flags) == self.bomb_count):
            self.game_over(True)

    def reveal_cells(self, row, col):
        """Рекурсивно открывает клетки"""
        # Проверяем, что координаты в пределах поля и клетка не открыта
        if not (0 <= row < self.grid_size and 0 <= col < self.grid_size) or self.player_map[row][col] != '-':
            return

        self.player_map[row][col] = self.hidden_map[row][col]

        # Если клетка пустая, открываем соседей
        if self.hidden_map[row][col] == ' ':
            for r in range(row - 1, row + 2):
                for c in range(col - 1, col + 2):
                    if r != row or c != col:  # Не проверяем саму клетку
                        self.reveal_cells(r, c)

4. Визуальные улучшения

4.1. Добавляем таймер

Чтобы игрок видел, сколько времени он играет: Методы Таймер (start_game_timer()update_timer() — Запускается при первом ходе)

    def start_game_timer(self):
        """Запускает таймер игры"""
        self.game_started = True
        self.start_time = time()
        self.update_timer()

    def update_timer(self):
        """Обновляет отображение таймера"""
        if self.game_started:
            elapsed = int(time() - self.start_time) # прошедшее время
            self.timer_label.config(text=f"Время: {elapsed} сек")
            self.root.after(1000, self.update_timer)
        else:
            self.timer_label.config(text="Время: 0 сек")

4.2. Подсветка чисел

Как в оригинальной игре, цифры будут разного цвета:

    def update_buttons(self):
        """Обновляет внешний вид кнопок в соответствии с состоянием игры"""
        for row in range(self.grid_size):
            for col in range(self.grid_size):
                if (row, col) in self.flags:
                    self.buttons[row][col].config(text='?', fg='red', bg='SystemButtonFace')
                elif self.player_map[row][col] == '-':
                    self.buttons[row][col].config(text=' ', bg='SystemButtonFace')
                else:
                    cell = self.player_map[row][col]
                    self.buttons[row][col].config(text=cell, bg='light gray')

                    # Разные цвета для цифр
                    if cell.isdigit():
                        num = int(cell)
                        colors = ['', 'blue', 'green', 'red', 'dark blue',
                                  'brown', 'teal', 'black', 'gray']
                        self.buttons[row][col].config(fg=colors[num])

5. Завершение игры

При поражении или победе показываем все мины и выводим сообщение:

  1. start_new_game() — сбрасывает состояние и начинает заново

  2. check_win() — проверяет условия победы

    def check_win(self):
        """Проверяет условия победы"""
        # Все безопасные клетки должны быть открыты
        for row in range(self.grid_size):
            for col in range(self.grid_size):
                if self.hidden_map[row][col] != 'M' and self.player_map[row][col] == '-':
                    return False
        return True

    def game_over(self, won):
        """Обрабатывает завершение игры"""
        self.game_started = False

        # Показываем все мины
        for row in range(self.grid_size):
            for col in range(self.grid_size):
                if self.hidden_map[row][col] == 'M':
                    self.buttons[row][col].config(
                        text='?',
                        bg='light green' if won else 'orange'
                    )

        # Отключаем все кнопки
        for row in range(self.grid_size):
            for col in range(self.grid_size):
                self.buttons[row][col].config(state='disabled')

        # Показываем сообщение
        if won:
            elapsed = int(time() - self.start_time)
            messagebox.showinfo("Победа!", f"Поздравляем! Вы выиграли за {elapsed} секунд!")
        else:
            messagebox.showinfo("Поражение", "Вы наступили на мину!")
    
    def start_new_game(self):
        """Начинает новую игру"""
        self.init_game()

6.Настройки игры и запус:

change_settings() — позволяет изменить количество мин

    def change_settings(self):
        """Изменяет настройки игры (количество мин)"""
        # simpledialog - это модуль, который предоставляет удобные функции для создания стандартных диалоговых окон
        bombs = simpledialog.askinteger(
            "Количество мин",
            f"Введите количество мин ({self.min_bombs}-{self.max_bombs}):",
            parent=self.root,
            minvalue=self.min_bombs,
            maxvalue=self.max_bombs,
            initialvalue=self.bomb_count
        )

        if bombs is not None:  # Если пользователь не нажал "Отмена"
            self.bomb_count = bombs
            self.start_new_game()
# запуск игры
if __name__ == "__main__":
    root = tk.Tk()
    game = MinesweeperGUI(root)
    root.mainloop()

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

пример работы кода, с генерацией карты после первого хода
пример работы кода, с генерацией карты после первого хода

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

Наш «Сапёр» уже полностью играбелен, но есть куда расти:

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


 Полный код доступен на GitHub
 Первая часть (консольная версия) — здесь

Пишите идеи в комментарии! Ваш вариант кода может попасть в обновлённую версию статьи, как было с первой.

P.S. Если найдёте баги — сообщите, исправлю

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


  1. urvanov
    15.08.2025 19:57

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


    1. Laborant_Code Автор
      15.08.2025 19:57

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


  1. urvanov
    15.08.2025 19:57

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