Сапёр – это такая одиночная игра, суть которой заключается в том, чтобы исследовать территорию вокруг вашей ракеты на луне и избегать контакта с пришельцами. В разных версиях игры разные сценарии.
Это простая альтернативная версия классического Сапёра, где вам приходилось переворачивать плитки для поиска спрятанных мин. Наша версия использует пользовательские объекты QWidget для плиток, которые индивидуально сохраняют свое состояние в качестве мин, статус и смежное количество мин. В данной версии мины заменены на инопланетян, но здесь вы уже можете придумывать что угодно.
Есть вопросы по Python?
На нашем форуме вы можете задать любой вопрос и получить ответ от всего нашего сообщества!
Паблик VK
Одно из самых больших сообществ по Python в социальной сети ВК. Видео уроки и книги для вас!
В различных вариациях Сапёра первый ход — полностью безопасный. Если вы попадаете на мину в свой первый ход, она сменит свое расположение. На этом моменте мы немного смухлюем, отдавая первый ход игроку предупредив, что мин на нём не будет. Это дает нам возможность не беспокоиться о том, что первый ход будет неудачным и требовать пересчета смежности.
Скачать исходный код
Полный исходный код игры сапёр доступен в пятнадцатиминутном репозитории на Github. Вы можете скачать или клонировать его для получения рабочей копии, после это вам нужно установить все необходимое при помощи:
1 |
pip3 install -r requirements.txt |
После этого, вы можете запустить игру при помощи:
1 |
python3 minesweeper.py |
Читайте дальше, что бы узнать , как работает код
Игровое поле
Игровая зона для сапёра представляет собой сетку NxN, которая содержит определенное количество мин. Размеры и количество мин, которые мы используем, берутся из значений по умолчанию из игры Windows, Сапёр. Используемые значения указаны ниже:
Уровень |
Размеры |
Количество мин |
Легко |
8 x 8 |
10 |
Средне |
16 x 16 |
40 |
Тяжело |
24 x 24 |
99 |
Мы сохраняем эти значения как постоянные УРОВНИ, определенные в верхней части файла. Так как игровое поле имеет квадратную форму, мы сохраняем значение только один раз (8, 16 или 24).
1 2 3 4 5 |
LEVELS = [ ("Easy", 8, 10), ("Medium", 16, 40), ("Hard", 24, 99) ] |
Игровая сетка может быть представлена несколькими способами, включая, например, двухмерный «список списков», который представляет различные состояния игровых позиций (мины, раскрытые мины, помеченные мины).
Однако, этот вариант использует объектно-ориентированный подход. Отдельные квадраты на карте содержат релевантные данные о своем нынешнем состоянии и отвечают за прорисовку. В Qt мы можем сделать это просто, создав дочерний класс QWidget и использовав простую функцию рисования.
Поскольку наши объекты плиток являются дочерними классами QWidget, мы можем расположить их как любой другой виджет. Мы сделаем это, настроив QGridLayout.
1 2 3 |
self.grid = QGridLayout() self.grid.setSpacing(5) self.grid.setSizeConstraint(QLayout.SetFixedSize) |
Мы можем обыграть создание позиции наших плиточных виджетов и добавить их в нашу сетку. Изначальная настройка уровня считывается из LEVELS и присваивает количество переменных в окне.
1 2 3 4 5 6 7 8 9 |
def set_level(self, level): self.level_name, self.b_size, self.n_mines = LEVELS[level] self.setWindowTitle("Moonsweeper - %s" % (self.level_name)) self.mines.setText("%03d" % self.n_mines) self.clear_map() self.init_map() self.reset_map() |
Рассмотрим функции настройки!
Класс Pos представляет плитку и содержит всю необходимую информацию о своей позиции на карте, включая, например, является ли она миной, открытой миной или отмеченной миной, а также количество мин в непосредственной близости.
Каждый объект Pos также имеет три пользовательских сигнала: на него можно кликнуть, раскрыть и расширить, что мы и подключаем к пользовательским слотам. Наконец, мы можем вызвать resize для настройки размера окна в соответствии с новым содержимым. Это нужно в тех случаях, когда окно сжимается, в остальных случаях Qt увеличивает его размер автоматически.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def init_map(self): # Добавляем позиции на карте. for x in range(0, self.b_size): for y in range(0, self.b_size): w = Pos(x,y) self.grid.addWidget(w, y, x) # Подключаем сигнал для обработки расширения. w.clicked.connect(self.trigger_start) w.revealed.connect(self.on_reveal) w.expandable.connect(self.expand_reveal) # Размещаем resize в очереди событий, передав контроль Qt заранее. QTimer.singleShot(0, lambda: self.resize(1,1)) # <1> |
Таймер singleShot нужен для того, чтобы убедиться в том, что resize запускается после того, как мы вернулись к циклу событий и Qt уведомлен о новом содержимом.
Теперь у нас есть сетка позиционных объектов плиток, и мы можем приступить к созданию начальных условий игрового поля. Это делится на несколько функций. Мы назовем их _reset (низкое подчеркивание является условным обозначением частной функции, не предназначенной для внешнего использования). Главная функция reset_map вызывает эти функции для настройки.
Процесс заключается в следующем:
- Убрать все мины (и обновить данные) с поля;
- Добавить новые мины на поле;
- Подсчитать количество мин, смежных с каждой позицией;
- Добавить стартовый маркер (ракету) и запустить начальную проверку;
- Сбросить таймер.
Как это выглядит в коде:
1 2 3 4 5 6 |
def reset_map(self): self._reset_position_data() self._reset_add_mines() self._reset_calculate_adjacency() self._reset_add_starting_marker() self.update_timer() |
Мы детально рассмотрим разделение шагов от 1 до 5 ниже, с кодом для каждого шага.
Первый шаг – это сброс данных для каждой позиции на карте. Мы перебираем каждую позицию на доске, вызываем .reset() в виджете для каждой точки. Код для функции .reset() определен в нашем классе Pos. Мы детальнее рассмотрим этот момент позже. На данный момент достаточно знать, что он чистит мины, флажки и настраивает позицию в изначальную, т.е., плитки не раскрыты.
1 2 3 4 5 6 |
def _reset_position_data(self): # Очистка всех позиций мин. for x in range(0, self.b_size): for y in range(0, self.b_size): w = self.grid.itemAtPosition(y, x).widget() w.reset() |
Теперь все позиции пустые, и мы можем начать процесс добавления мин на карту. Максимальное количество мин n_mines определяется настройками уровня, упомянутых раньше.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def _reset_add_mines(self): # Добавляем позиции мин. positions = [] while len(positions) < self.n_mines: x, y = random.randint(0, self.b_size-1), random.randint(0, self.b_size-1) if (x ,y) not in positions: w = self.grid.itemAtPosition(y,x).widget() w.is_mine = True positions.append((x, y)) # Подсчет итоговых позиций. self.end_game_n = (self.b_size * self.b_size) - (self.n_mines + 1) return positions |
С минами на позиции, мы теперь можем подсчитать «смежное» количество для каждой позиции – просто берем количество мин в непосредственной близости, используя сетку 3х3 вокруг данной точки. Функция get_surrounding легко возвращает результаты этих позиций вокруг заданного расположения х и у. Мы подсчитаем их количество, где мина это is_mine == True и сохраняем.
Такой способ предварительного подсчета смежных чисел помогает упростить логику обнаружения мин в будущем.
1 2 3 4 5 6 7 8 9 10 11 |
def _reset_calculate_adjacency(self): def get_adjacency_n(x, y): positions = self.get_surrounding(x, y) return sum(1 for w in positions if w.is_mine) # Добавляем смежности на позициях. for x in range(0, self.b_size): for y in range(0, self.b_size): w = self.grid.itemAtPosition(y, x).widget() w.adjacent_n = get_adjacency_n(x, y) |
Начальный маркер используется, чтобы убедиться в том, что первый шаг всегда валидный. Поиск выполняется по принципу BruteForce в пространстве сетки, где пробуются различные позиции до тех пор, пока не будут найдены позиции, не являющиеся минами. Так как мы не знаем, сколько попыток это займет, нам нужно завернуть это все в вечный цикл.
После того как мы найдем локацию, мы помечаем ее как стартовое расположение, после чего запускаем исследование всех близлежащих позиций. Мы разрываем цикл и обновляем статус готовности.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
def _reset_add_starting_marker(self): # Размещение стартового маркера. # Устанавливаем начальный статус (нужно для функции .click) self.update_status(STATUS_READY) while True: x, y = random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1) w = self.grid.itemAtPosition(y, x).widget() # Нам не нужно начинать на мине. if not w.is_mine: w.is_start = True w.is_revealed = True w.update() # Раскрываем все позиции вокруг данной, если они также не являются минами for w in self.get_surrounding(x, y): if not w.is_mine: w.click() break # Обновляем статус до следующих начальных кликов. self.update_status(STATUS_READY) |
Позиции плиток
Игра является структурной так что индивидуальные позиции плиток содержат собственную информацию состояния. Это значит, что плитки Pos могут обрабатывать собственную логику игры.
Так как класс Pos относительно сложный, мы разобьем его разбор на несколько частей и обсудим поочередно. Начальный блок настройки __init__ достаточно простой, принимает позицию х и у и сохраняет её в объект. Позиции Pos никогда не меняются после создания.
Для завершения настройки вызывается функция функции .reset() , которая сбрасывает все атрибуты объектов до значения по умолчанию, т.е. нулевые значения. Это значит, что мина не будет на стартовой позиции, с нее снимается значение мина, она не раскрыта и не отмечена флагом. Мы также сбрасываем смежный подсчет.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class Pos(QWidget): expandable = pyqtSignal(int,int) revealed = pyqtSignal(object) clicked = pyqtSignal() def __init__(self, x, y, *args, **kwargs): super(Pos, self).__init__(*args, **kwargs) self.setFixedSize(QSize(20, 20)) self.x = x self.y = y self.reset() def reset(self): self.is_start = False self.is_mine = False self.adjacent_n = 0 self.is_revealed = False self.is_flagged = False self.update() |
Игровой процесс сосредоточен вокруг взаимодействия мыши с плитками на игровом поле, так что обнаружение и реакция на нажатия мыши являются приоритетными. В PyQt мы ловим нажатия мыши благодаря mouseReleaseEvent. Чтобы сделать это для нашего виджета Pos, мы определяем обработчик класса. Он получит QMouseEvent с информацией о том, что случилось. В данном случае мы заинтересованы только в том, происходили ли нажатия левой или правой кнопки мыши.
При нажатии левой кнопки мыши мы проверяем, отмечена ли плитка флажком, или уже открыта. Если это так, мы игнорируем нажатие – делая отмеченные флажком плитки «безопасными» и не давая возможности случайного нажатия. Если плитка не отмечена флажком, мы просто инициируем метод .click() (см. далее).
Для нажатия правой кнопки мыши на плитку, которая не является раскрытой, мы вызываем наш метод .toggle_flag() для включения и выключения флага.
1 2 3 4 5 6 7 8 9 |
def mouseReleaseEvent(self, e): if (e.button() == Qt.RightButton and not self.is_revealed): self.toggle_flag() elif (e.button() == Qt.LeftButton): # Блокировка нажатий на мины, отмеченные флажком. if not self.is_flagged and not self.is_revealed: self.click() |
Методы, вызываемые обработчиком mouseReleaseEvent указаны ниже.
Обработчик .toggle_flag просто настраивает .is_flagged таким образом, чтобы он стал инверсией самого себя (True становится False, False становится True) с эффектом включения и выключения. Обратите внимание на то, что нам нужно вызывать .update() для того, чтобы перерисовка изменила свое состояние. Мы также выдаем наш пользовательский сигнал .clicked, который используется для запуска таймера, так как размещение флага должно также считаться как начало, а не просто для раскрытия квадрата.
Метод .click() обрабатывает нажатие левой кнопкой мыши, и в свою очередь, приводит к раскрытию квадрата. Если количество смежных мин в нашем Pos является нулем, мы запускаем сигнал .expandable для начала процесса автоматического расширения исследованного региона (см. далее). Наконец, мы снова выдаем .clicked в качестве сигнала о начале игры.
Наконец, метод .reveal() проверяет, является ли плитка раскрытой. Если нет, то .is_revealed указывается как True. Мы снова вызываем .update() для вызова перерисовки виджета.
Опциональная выдача сигнала .revealed используется только в конечном раскрытии всей карты. Так как каждое раскрытие приводит к дальнейшему поиску, который находит плитки, которые еще не раскрыты, раскрытие всей карты приведет к созданию избыточного количества обратных вызовов. Подавив сигнал в этом случае, мы избежим этой ситуации.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
def toggle_flag(self): self.is_flagged = not self.is_flagged self.update() self.clicked.emit() def click(self): self.reveal() if self.adjacent_n == 0: self.expandable.emit(self.x, self.y) self.clicked.emit() def reveal(self, emit=True): if not self.is_revealed: self.is_revealed = True self.update() if emit: self.revealed.emit(self) |
Наконец, мы определяем пользовательский метод paintEvent для нашего виджета Pos чтобы обработать изображение состояния нынешней позиции. Чтобы использовать пользовательское рисование в виджете, мы используем QPainter и event.rect(), которые предоставляют границы, в которых мы можем рисовать. В данном случае, во внешней границе виджета Pos.
Раскрытые плитки рисуются по-другому, в зависимости от того, является ли конкретная плитка стартовой позицией, миной или пустым пространством. Первые две представлены в виде иконок ракеты и бомбы соответственно. Их рисуют в плитке QRect, используя .drawPixmap. Обратите внимание на то, что нам нужно конвертировать содержимое QImage в pixmaps, путем передачи через QPixmap.
Вы можете подумать:
Почему просто не хранить их в качестве объектов QPixmap, так как это то, что мы используем?
К сожалению, вы не можете создавать объекты QPixmap до того, как запустится QApplication.
Для пустых позиций (которые не являются ни ракетой, ни бомбой) мы можем указать количество смежностей, если это количество больше нуля. Для рисовки текста в нашем QPainter, мы используем .drawText(), который передается в QRect, флажки выравнивания и количество для рисовки в виде строки. Мы определили стандартный цвет для каждого числа (хранится в NUM_COLORS) для использования.
Для плиток, которые еще не выявлены, мы рисуем плитку путем заполнения прямоугольника светлым серым и добавляем темно-серые границы толщиной в 1 пиксель. Если .is_flagged настроен, мы также рисуем иконку флага поверх плитки используя drawPixmap и плитку QRect.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) r = event.rect() if self.is_revealed: if self.is_start: p.drawPixmap(r, QPixmap(IMG_START)) elif self.is_mine: p.drawPixmap(r, QPixmap(IMG_BOMB)) elif self.adjacent_n > 0: pen = QPen(NUM_COLORS[self.adjacent_n]) p.setPen(pen) f = p.font() f.setBold(True) p.setFont(f) p.drawText(r, Qt.AlignHCenter | Qt.AlignVCenter, str(self.adjacent_n)) else: p.fillRect(r, QBrush(Qt.lightGray)) pen = QPen(Qt.gray) pen.setWidth(1) p.setPen(pen) p.drawRect(r) if self.is_flagged: p.drawPixmap(r, QPixmap(IMG_FLAG)) |
Механика
Как правило, нам нужно получить все плитки вокруг заданной точки, так что у нас есть пользовательская функция для этой задачи. Она просто выполняет итерацию в сетке 3х3 вокруг точки с проверкой, чтобы убедиться в том, что мы не выходим за границы сетки (0 ≥ x ≤ self.b_size). Полученный список содержит виджет Pos со всей окружающей локации.
1 2 3 4 5 6 7 8 9 |
def get_surrounding(self, x, y): positions = [] for xi in range(max(0, x - 1), min(x + 2, self.b_size)): for yi in range(max(0, y - 1), min(y + 2, self.b_size)): if not (xi == x and yi == y): positions.append( self.grid.itemAtPosition(yi, xi).widget() ) return positions |
Метод expand_reveal вызывается в ответ на нажатие на плитку, вокруг которой нет мин. В этом случае, нам нужно расширять зону вокруг клика до тех пор, пока количество мин в этой области равно нулю, а также раскрыть все квадраты вокруг границы этой расширенной зоны (которые не являются минами).
Мы начнем со списка to_expand, который содержит позиции для проверки следующей итерации, а также списка to_reveal, который содержит виджеты плиток, которые нужно раскрыть, а также флаг any_added для определения того момента, когда нужно выйти из цикла. Цикл останавливается в тот первый раз, когда ни один виджет не был добавлен в to_reveal.
Внутри цикла мы обнуляем any_added до False и чистим список to_expand, оставив временное хранилище в l для итерации.
Для каждой локации х и у мы получаем 8 окружающих виджетов. Если какой-либо из этих виджетов не является миной и еще не являются в добавленном списке to_reveal. Это дает понять, что все границы разведанной области раскрыты. Если на позиции нет смежных мин, мы добавляем координаты в to_expand для проверки в следующей итерации.
Добавив плитки (которые не являются минами) в to_reveal и добавляя только плитки, которые еще не находятся в to_reveal, мы обеспечиваем себя гарантией того, что не сможем использовать плитку более одного раза.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
def expand_reveal(self, x, y): """ Снаружи итерируем с начальной точки, добавив новые локации вочередь. Это позволяет нам расширить все за раз, а не за несколько колбеков. """ to_expand = [(x,y)] to_reveal = [] any_added = True while any_added: any_added = False to_expand, l = [], to_expand for x, y in l: positions = self.get_surrounding(x, y) for w in positions: if not w.is_mine and w not in to_reveal: to_reveal.append(w) if w.adjacent_n == 0: to_expand.append((w.x,w.y)) any_added = True # Итерация и раскрытие всех позиций, которые мы нашли. for w in to_reveal: w.reveal() |
Конец игры
Состояния конца игры обнаруживаются во время процесса раскрытия, следующего за нажатием на плитку. После этого возможны два развития:
- Это мина, игра заканчивается;
- Это не мина, декрементируем self.end_game_n.
Это будет продолжаться, пока self.end_game_n не достигнет нуля, что приведет к процессу победы, путем вызова либо game_over, либо game_won. Победа\поражение вызывается путем раскрытия карты и вывода соответствующего статуса для обоих классов.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
def on_reveal(self, w): if w.is_mine: self.game_over() else: self.end_game_n -= 1 # decrement remaining empty spaces if self.end_game_n == 0: self.game_won() def game_over(self): self.reveal_map() self.update_status(STATUS_FAILED) def game_won(self): self.reveal_map() self.update_status(STATUS_SUCCESS) if __name__ == '__main__': app = QApplication([]) window = MainWindow() app.exec_() |
Больше идей!
Если вы хотите расширить игру сапёр, есть несколько идей:
- Позвольте игроку выбрать свой первый ход. Попробуйте включить расчет позиций мин после первого нажатия игрока, после чего генерируйте позиции, пока не промахнетесь;
- Добавьте усилители, такие как сканер для разведки определенной части стола в автоматическом режиме;
- Дайте возможность спрятанным минам двигаться по полю между ходами. Храните список доступных нераскрытых позиций и позвольте минам двигаться по ним. Вам нужно будет пересчитывать смежности после каждого нажатия.
Являюсь администратором нескольких порталов по обучению языков программирования Python, Golang и Kotlin. В составе небольшой команды единомышленников, мы занимаемся популяризацией языков программирования на русскоязычную аудиторию. Большая часть статей была адаптирована нами на русский язык и распространяется бесплатно.
E-mail: vasile.buldumac@ati.utm.md
Образование
Universitatea Tehnică a Moldovei (utm.md)
- 2014 — 2018 Технический Университет Молдовы, ИТ-Инженер. Тема дипломной работы «Автоматизация покупки и продажи криптовалюты используя технический анализ»
- 2018 — 2020 Технический Университет Молдовы, Магистр, Магистерская диссертация «Идентификация человека в киберпространстве по фотографии лица»