Всем привет! В статье расскажу, как я написал игру Дебаггер на Python и добавил к ней графический интерфейс на Tkinter. Мне хотелось сделать простую игру на IT тематику, поэтому я скопировал игровую механику из игры Сапер и теперь нам нужно отметить все баги на игровом поле или наш код сломается.
Статья может быть интересна начинающим разработчикам для изучения чужого опыта и наработки своего или же опытным программистам, которые просто хотят поиграть в игру, а может даже предложить, как еще улучшить архитектуру проекта.
Оглавление
Начало Pet-проекта
Вдохновением для проекта стала интересная статья Головоломка «Сапёр» на Python в 66 строк и ее решение вероятностным алгоритмом.
Чтобы лучше разобраться, я взял за основу код из статьи и написал свою текстовую версию игры Сапер. А потом решил добавить графический интерфейс на Tkinter, чтобы было просто и без лишних зависимостей.
Текстовая версия игры была написана в виде монолита, а вот в графической версии решил применить паттерн MVC, что позволило сделать игру модульной. В будущем можно легко заменить графическую оболочку Tkinter на другую, например, pygame, Unity или встроить игру на сайт во frontend.
Паттерн Model‑View‑Controller (MVC, Модель‑Представление‑Контроллер) — это такая схема разделения данных и логики управления, в ней есть 3 отдельных компонента:
-
Модель (Model) — хранит данные
-
Представление (View) — отображает данные из модели на экране
-
Контроллер (Controller) — реагирует на действия пользователя и изменяет данные модели
У меня в коде компонент Модель представляет собой ядро игры, которое реализовано в классе DebuggerGame, но у меня это не просто данные, а данные с некоторой логикой. Стоит рассматривать этот класс как черный ящик, который реагирует на входящие данные и возвращает некоторый результат на выходе.
Компоненты Представление и Контроллер реализованы в одном классе DebuggerGameGUI, потому что отображение и обработка кнопок реализуется через библиотеку Tkinter, но они написаны как отдельные методы.
Получилась такая схема:
Ядро игры 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.
А как бы вы спроектировали и улучшили такую игру?
ссылка на оригинал статьи https://habr.com/ru/articles/917382/
Добавить комментарий