Pull to refresh

Pet-проект: игра Дебаггер на Python с графическим интерфейсом на Tkinter

Level of difficultyEasy
Reading time11 min
Views3.4K
Игра Дебаггер на Python
Игра Дебаггер на Python

Всем привет! В статье расскажу, как я написал игру Дебаггер на Python и добавил к ней графический интерфейс на Tkinter. Мне хотелось сделать простую игру на IT тематику, поэтому я скопировал игровую механику из игры Сапер и теперь нам нужно отметить все баги на игровом поле или наш код сломается.

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

Оглавление

Начало Pet-проекта

Вдохновением для проекта стала интересная статья Головоломка «Сапёр» на Python в 66 строк и ее решение вероятностным алгоритмом.

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

Текстовая версия игры была написана в виде монолита, а вот в графической версии решил применить паттерн MVC, что позволило сделать игру модульной. В будущем можно легко заменить графическую оболочку Tkinter на другую, например, pygame, Unity или встроить игру на сайт во frontend.

Паттерн Model‑View‑Controller (MVC, Модель‑Представление‑Контроллер) — это такая схема разделения данных и логики управления, в ней есть 3 отдельных компонента:

  • Модель (Model) — хранит данные

  • Представление (View) — отображает данные из модели на экране

  • Контроллер (Controller) — реагирует на действия пользователя и изменяет данные модели

Паттерн Model‑View‑Controller (MVC, Модель‑Представление‑Контроллер)
Паттерн Model‑View‑Controller (MVC, Модель‑Представление‑Контроллер)

У меня в коде компонент Модель представляет собой ядро игры, которое реализовано в классе DebuggerGame, но у меня это не просто данные, а данные с некоторой логикой. Стоит рассматривать этот класс как черный ящик, который реагирует на входящие данные и возвращает некоторый результат на выходе.

Компоненты Представление и Контроллер реализованы в одном классе DebuggerGameGUI, потому что отображение и обработка кнопок реализуется через библиотеку Tkinter, но они написаны как отдельные методы.

Получилась такая схема:

Моя реализация Model‑View‑Controller в коде
Моя реализация Model‑View‑Controller в коде

Ядро игры DebuggerGame

Ядро игры реализовано в классе DebuggerGame и представляет собой компонент Модель из MVC. Для удобства работы с классом DebuggerGame были созданы отдельные вспомогательные классы:

  • модель типа действия: открыть клетку или отметить

  • модель игровой клетки

  • модель ответа от ядра игры

import tkinter as tk
from enum import StrEnum
from random import randint
from tkinter import ttk, messagebox


class ActionType(StrEnum):
    """Тип действия"""

    OPEN= "open" # открыть клетку
    MARK= "mark" # отметить клетку флагом

class Cell:
    """Класс одной клетки поля"""

    def __init__(self):
        self.is_bug: bool = False  # установлен ли баг на клетку
        self.is_revealed: bool = False  # открыта ли клетка или еще нет
        self.is_set_flag = False # установлен ли флаг в клетку
        self.num_of_bugs_around: int = 0  # кол-во багов вокруг клетки

class DebuggerGameResponse:
    """Класс результата игры после клика по клетке"""

    def __init__(self, is_win: bool, is_gameover: bool, board: list[list[Cell]]) -> None:
        """
        :param is_win: флаг победы
        :param is_gameover: флаг конца игры
        :param board: список списков с игровым полем из клеток
        :return: None
        """
        self.is_win: bool = is_win
        self.is_gameover: bool = is_gameover
        self.board: list[list[Cell]] = board

Основная логика ядра игры заключается в том, что сначала мы инициализируем класс DebuggerGame, в котором создаем поле из списка списков клеток Cell. Затем из графической оболочки вызываем метод play_game, который возвращает модель ответа от ядра игры. В метод play_game, передаем координаты клетки и тип действия.

class DebuggerGame:
    """Класс игры Дебаггер"""

    def __init__(self, rows: int = 10, cols: int = 10, bugs: int = 10) -> None:
        """
        :param rows: кол-во строк игровых клеток
        :param cols: кол-во столбцов игровых клеток
        :param bugs: кол-во баг
        :return: None
        """
        self.rows: int = rows
        self.cols: int = cols
        self.bugs: int = bugs if bugs < rows * cols else (rows * cols) // 2
        self.is_first_click: bool = True # флаг определяет это первый клик по игровому полю или нет

        # Создали поле с клетками
        self.board: list[list[Cell]] = [[self.get_new_cell() for _ in range(cols)] for _ in range(rows)]

        self.is_win: bool = False
        self.is_gameover: bool = False

    @staticmethod
    def get_new_cell() -> Cell:
        """Возвращает инстанс клетки поля"""
        return Cell()

    def play_game(self, row: int, col: int, action_type: ActionType) -> DebuggerGameResponse:
        """
        Игровой цикл.
        :param row: индекс строки клетки
        :param col: индекс столбца клетки
        :param action_type: тип действия (открыть клетку или отметить флагом)
        :return: модель результата игры после клика по клетке
        """
        # Если игра закончена победой и поражением, то выходим
        if self.is_win or self.is_gameover:
            print("Game Over!")
            return DebuggerGameResponse(
                is_win=self.is_win,
                is_gameover=self.is_gameover,
                board=self.board
            )

        # При первом выборе клетки расставляем баги и подсчитываем кол-во багов вокруг клеток
        if self.is_first_click:
            self.is_first_click = False
            self.place_bugs(row, col)
            self.set_num_of_bugs_around()

        # Если действие отметить клетку флагом
        if action_type == ActionType.MARK:
            self.board[row][col].is_set_flag = not self.board[row][col].is_set_flag
            return DebuggerGameResponse(
                is_win=self.is_win,
                is_gameover=self.is_gameover,
                board=self.board
            )

        # Если действие открыть клетку с флагом, то выходим
        if action_type == ActionType.OPEN and self.board[row][col].is_set_flag:
            return DebuggerGameResponse(
                is_win=self.is_win,
                is_gameover=self.is_gameover,
                board=self.board
            )

        # Отобразили клетку и пустые клетки вокруг
        self.reveal(row, col)

        # Если открыли баг, то проиграли
        if self.board[row][col].is_bug:
            print("You hit a bug! Game over!")
            self.is_gameover = True
            self.show_all_cells()

        # Проверили условие победы
        if self.is_game_win():
            print("Congratulations! You win!")
            self.is_win = True
            self.is_gameover = True
            self.show_all_cells()

        return DebuggerGameResponse(
            is_win=self.is_win,
            is_gameover=self.is_gameover,
            board=self.board
        )

При первом клике по клетке происходит следующее:

  • случайным образом расставляются баги на игровом поле

  • рассчитывается количество багов вокруг каждой клетки

  • возле первой указанной клетки открывается свободная от багов область

В этом же методе:

  • проверяется условие поражения или победы

  • если нажали отметить клетку, то проставляется флаг на клетке

  • возвращается модель ответа от ядра игры

Вспомогательные методы этого же класса приведены ниже:

def show_all_cells(self) -> None:
    """
    Помечает все клетки открытыми.

    :return: None
    """
    for row in range(self.rows):
        for col in range(self.cols):
            self.board[row][col].is_revealed = True
            self.board[row][col].is_set_flag = False

def is_game_win(self) -> bool:
    """
    Проверяет условие победы: количество не открытых клеток == количеству багов.

    :return: истина = победа, ложь = игра не закончена
    """
    unrevealed_cells = 0
    for row in range(self.rows):
        for col in range(self.cols):
            if not self.board[row][col].is_revealed:
                unrevealed_cells += 1

    if unrevealed_cells == self.bugs:
        return True

    return False

def set_num_of_bugs_around(self) -> None:
    """
    Рассчитывает количество багов вокруг клетки и записывает значение в num_of_bugs_around клетки.

    :return: None
    """
    for row in range(self.rows):
        for col in range(self.cols):
            # Если клетка с багом, то пропускаем ее
            if self.board[row][col].is_bug:
                self.board[row][col].num_of_bugs_around = -1
                continue

            _bugs = 0
            neighbors = self.get_neighbors(row, col)  # Берем список всех соседних клеток (их индексы)
            for neighbor in neighbors:
                _row, _col = neighbor
                # Если соседняя клетка с багом, то увеличиваем счетчик багов вокруг
                if self.board[_row][_col].is_bug:
                    _bugs += 1

            # Записываем кол-во багов в поле клетки
            self.board[row][col].num_of_bugs_around = _bugs

def place_bugs(self, row: int, col: int) -> None:
    """
    Размещает баги на поле случайным образом исключая указанную клетку.

    :param row: индекс строки
    :param col: индекс столбца
    :return: None
    """
    # Помечаем клетку открытой
    self.board[row][col].is_revealed = True

    # Заполняем поле багами случайным образом
    placed_bugs = 0
    while placed_bugs < self.bugs:
        random_row = randint(0, self.rows - 1)
        random_col = randint(0, self.cols - 1)

        # Ставим баг на клетку если на ней нет бага и она еще не открыта
        if not self.board[random_row][random_col].is_bug and not self.board[random_row][random_col].is_revealed:
            self.board[random_row][random_col].is_bug = True
            placed_bugs += 1

def get_neighbors(self, row: int, col: int) -> list[tuple[int, int]]:
    """
    Возвращает список соседних клеток по указанной клетке.

    :param row: индекс строки
    :param col: индекс столбца
    :return: список из кортежей (индекс строки, индекс столбца)
    """
    # Определяем границы вокруг клетки
    min_row = row - 1 if row > 0 else 0
    max_row = row + 1 if row < self.rows - 1 else self.rows - 1
    min_col = col - 1 if col > 0 else 0
    max_col = col + 1 if col < self.cols - 1 else self.cols - 1

    neighbors = []
    for _row in range(min_row, max_row + 1):
        for _col in range(min_col, max_col + 1):
            if (_row, _col) != (row, col):  # Исключаем текущую клетку
                neighbors.append((_row, _col))
    return neighbors

def reveal(self, row: int, col: int) -> None:
    """
    Открывает соседние клетки если в них нет багов в пределах указанной клетки.

    :param row: индекс строки
    :param col: индекс столбца
    :return: None
    """
    first_cell = True

    # Формируем стэк на базе списка
    stack = [(row, col)]
    while stack:
        current_cell = stack.pop()  # Берем последнюю клетку из стэка
        _row, _col = current_cell

        # Если клетка уже открыта, то игнорируем ее
        if self.board[_row][_col].is_set_flag:
            continue

        self.board[_row][_col].is_revealed = True  # Открываем текущую клетку

        # Ищем соседние клетки вокруг текущей клетки и если клетка не имеет вокруг багов
        if first_cell or self.board[_row][_col].num_of_bugs_around == 0:
            first_cell = False

            # Берем список всех соседних клеток (их индексы)
            neighbors = self.get_neighbors(_row, _col)
            for neighbor_row, neighbor_col in neighbors:
                # neighbor_row, neighbor_col = neighbor

                # Если соседняя клетка без багов и еще не открыта, то добавляем ее в стэк для открытия
                if (
                        not self.board[neighbor_row][neighbor_col].is_bug
                        and not self.board[neighbor_row][neighbor_col].is_revealed
                        and not self.board[neighbor_row][neighbor_col].is_set_flag
                ):
                    stack.append((neighbor_row, neighbor_col))

Графический интерфейс DebuggerGameGUI

В классе DebuggerGameGUI реализован графический интерфейс через Tkinter, который по модели MVC относится к Представлению, но также здесь реализован компонент Контроллер, т.к. это оказалось удобно.

При инициализации класса создается игровое поле с параметрами по умолчанию:

  • создается экземпляр класса ядра игры DebuggerGame

  • отрисовывается игровое поле по модели ядра игры DebuggerGame

  • добавляются меню

Каждая клетка игрового поля представляет собой экземпляр класса CellGUI.

class CellGUI:
    """Класс одной клетки поля"""

    def __init__(self, master, row: int, col: int, game_func):
        self.master = master
        self.row = row
        self.col = col

        self.button = ttk.Button(self.master, text=" ", width=2)
        self.button.grid(row=self.row, column=self.col)
        self.button.bind("<ButtonPress-1>", lambda e, r=self.row, c=self.col, mck=ActionType.OPEN: game_func(e, r, c, mck))
        self.button.bind("<ButtonPress-3>", lambda e, r=self.row, c=self.col, mck=ActionType.MARK: game_func(e, r, c, mck))

Через вызов метода self.button.bind происходит привязка нажатия по клетке к вызову переданной функции с параметрами. Тут используется лямбда функция чтобы можно было вызвать функцию только после нажатия на клетку, а не сразу.

Нажатие левой кнопки мыши открывает клетку, а правой (иногда средней) помечает клетку багом.

Вся логика DebuggerGameGUI сводится к тому что по нажатию на клетку будет вызван метод play_game, который передает координаты клетки и тип действия в ядро игры, а затем на основе ответа от ядра игры будет перерисовано игровое поле.

class DebuggerGameGUI:
    """Класс графической оболочки и интерфейса взаимодействия игры Дебаггер"""

    def __init__(self):
        self.root = tk.Tk()  # создаем главное окно игры
        self.root.title("Дебаггер")

        self.debugger_game: DebuggerGame | None = None  # ядро игры
        self.board_gui: list[list[CellGUI]] | None = None  # список клеток для отображения в окне

        self.mainmenu: tk.Menu | None = None
        self.filemenu: tk.Menu | None = None
        self.helpmenu: tk.Menu | None = None
        self.add_menu()

        self.info_button: ttk.Button | None = None  # кнопка для отображения текстовой информации
        self.init_game(10, 10, 10)

    def add_menu(self) -> None:
        """
        Добавляет меню.

        :return: None
        """
        self.mainmenu = tk.Menu(self.root)
        self.root.config(menu=self.mainmenu)

        self.filemenu = tk.Menu(self.mainmenu, tearoff=0)
        self.filemenu.add_command(label="Легко", command=lambda r=10, c=10, m=10: self.init_game(r, c, m))
        self.filemenu.add_command(label="Нормально", command=lambda r=10, c=15, m=30: self.init_game(r, c, m))
        self.filemenu.add_command(label="Сложно", command=lambda r=10, c=20, m=40: self.init_game(r, c, m))
        self.filemenu.add_command(label="Выход", command=lambda: self.root.destroy())

        self.helpmenu = tk.Menu(self.mainmenu, tearoff=0)
        self.helpmenu.add_command(label="О программе", command=self.gui_about)

        self.mainmenu.add_cascade(label="Сложность", menu=self.filemenu)
        self.mainmenu.add_cascade(label="Справка", menu=self.helpmenu)

    def run(self) -> None:
        """
        Запускает графический интерфейс игры Дебаггер.

        :return: None
        """
        try:
            self.root.mainloop()
        except KeyboardInterrupt:
            pass

    def play_game(self, event, row: int, col: int, action_type: ActionType) -> None:
        """
        Функция, которая вызывается при клике по игровой клетке.

        :param event: событие
        :param row: индекс строки клетки
        :param col: индекс столбца клетки
        :param action_type: тип действия открыть клетку или отметить флагом
        :return: None
        """
        # Не обрабатываем нажатие на выключенную кнопку
        if str(self.board_gui[row][col].button['state']) == tk.DISABLED:
            return

        # Обращаемся к ядру игры за результатом
        debugger_game_response = self.debugger_game.play_game(row=row, col=col, action_type=action_type)

        # Отображаем текст, если выиграли или проиграли
        if debugger_game_response.is_win:
            print(f"{debugger_game_response.is_win=}")
            self.info_button.configure(text="Победа! Вы нашли все баги!")

        elif debugger_game_response.is_gameover:
            print(f"{debugger_game_response.is_gameover=}")
            self.info_button.configure(text="Поражение! Баг сломал код!")

        # Обновляем отображение игрового поля
        self.update_gui(debugger_game_response)

    def update_gui(self, debugger_game_response: DebuggerGameResponse) -> None:
        """
        Функция визуального обновления игрового поля после клика.

        :param debugger_game_response: модель результата игры после клика по клетке
        :return: None
        """
        for board_row_gui in self.board_gui:
            for board_cell_gui in board_row_gui:
                debugger_game_cell = debugger_game_response.board[board_cell_gui.row][board_cell_gui.col]
                if debugger_game_cell.is_set_flag:
                    text = "?"
                    board_cell_gui.button.configure(text=text)
                    continue

                if debugger_game_cell.is_revealed:
                    if debugger_game_cell.is_bug:
                        text = "Б"
                    elif debugger_game_cell.num_of_bugs_around != 0:
                        text = str(debugger_game_cell.num_of_bugs_around)
                    else:
                        text = " "  # Если пустая ячейка

                    board_cell_gui.button.configure(state=tk.DISABLED)
                    board_cell_gui.button.configure(text=text)
                    continue

                text = " "  # Если пустая ячейка
                board_cell_gui.button.configure(text=text)

    @staticmethod
    def gui_about() -> None:
        """
        Выводит окно справки с сообщением об игре

        :return: None
        """
        messagebox.showinfo(
            title="О программе",
            message="Игра Дебаггер - нужно отметить все баги в коде.\n\n"
            "Для отметки бага нажмите правую клавишу мыши.\n"
            "Для открытия клетки нажмите левую клавишу мыши.\n\n"
            "Число показывает количество багов вокруг клетки.\n\n"
            "Если отметить все баги, то игра завершится победой.\n"
            "При попадании на баг игра завершится проигрышем.\n"
        )

    def init_game(self, rows: int, cols: int, bugs: int) -> None:
        """
        Метод создания всего необходимого для игры

        :param rows: кол-во строк игровых клеток
        :param cols: кол-во столбцов игровых клеток
        :param bugs: кол-во багов
        :return: None
        """
        self.uninit_game()  # Сначала все чистим от старых клеток

        self.debugger_game = DebuggerGame(rows, cols, bugs)  # Создаем ядро игры
        self.board_gui: list[list[CellGUI]] = [
            [CellGUI(self.root, row, col, self.play_game) for col in range(cols)] for row in range(rows)
        ] # Создаем графическое отображение клеток

        # Добавляем кнопку для отображения текстовой информации
        self.info_button = ttk.Button(self.root, text="Отметьте все баги", state=tk.DISABLED, width=3 * cols)
        self.info_button.grid(row=rows + 1, columnspan=cols)

    def uninit_game(self) -> None:
        """
        Метод приведения игры к начальному состоянию

        :return: None
        """
        # Удаляем кнопки игровых клеток
        if self.board_gui is not None:
            for row in self.board_gui:
                for col in row:
                    col.button.destroy()
            self.board_gui = None

        # Удаляем кнопку для отображения текстовой информации
        if self.info_button is not None:
            self.info_button.destroy()
            self.info_button = None

А для запуска игры достаточно создать экземпляр DebuggerGameGUI и вызвать метод run.

if __name__ == '__main__':
    game = DebuggerGameGUI()
    game.run()

Итоги проекта

Pet‑проект — это отличная возможность разобраться как что‑то работает, а также просто повеселиться и получить удовольствие от самого процесса. Мне удалось не только понять логику работы игры Сапера, но и добавить графический интерфейс, а также провести несколько бессонных ночей играя в игру Дебаггер 😉

Возможные улучшения для игры Дебаггер:

  • Добавить счет привязанный ко времени и доску лидеров

  • Добавить звуки и различные эффекты

  • Поменять Tkinter на pygame

  • Добавить больше игровых механик

Код проекта доступен по ссылке в git.

А как бы вы спроектировали и улучшили такую игру?

Only registered users can participate in poll. Log in, please.
Пробовали ли вы разрабатывать игры?
11.76% Да, до сих пор в геймдеве и неплохо получается!2
64.71% Да, было раз-два и забросил!11
5.88% Нет, не пробовал, но очень хочу!1
17.65% Нет и вам не советую!3
17 users voted. 3 users abstained.
Tags:
Hubs:
+7
Comments7

Articles