На этом шаге мы рассмотрим реализацию управления этой игрой.
Давайте разработаем несколько структур, позволяющих отслеживать состояние игры в крестики-нолики по мере ее развития.
Прежде всего нужен способ представления каждой клетки на игровом поле. Будем использовать перечисление TTTPiece - подкласс класса Piece. Клетка в игре в крестики-нолики может иметь значение X, 0 или быть пустой (в перечислении такие обозначаются Е) (файл tictactoe.py).
from __future__ import annotations from typing import List from enum import Enum from board import Piece, Board, Move class TTTPiece(Piece, Enum): X = "X" O = "O" E = " " # обозначение пустой клетки @property def opposite(self) -> TTTPiece: if self == TTTPiece.X: return TTTPiece.O elif self == TTTPiece.O: return TTTPiece.X else : return TTTPiece.E def __str__(self) -> str: return self.value
В классе TTTPiece есть свойство opposite, которое возвращает другой экземпляр TTTPiece. Это будет полезно для передачи хода от одного игрока к другому после того, как очередной ход игры завершен. Для представления ходов используем обычное целое число, соответствующее клетке на поле, где был поставлен крестик или нолик. Как вы помните, Move был определен в файле board.ру именно как целое число.
В игре в крестики-нолики существует девять позиций, образующих три ряда по три столбца. Для простоты эти девять позиций можно представить в виде одномерного списка. То, какие клетки получат какие номера (другими словами, индексы в массиве), не имеет значения, но мы будем придерживаться схемы, показанной на рисунке 1.
Рис.1. Каждой клетке на игровом поле соответствует индекс одномерного списка
Главным хранилищем состояния игры будет класс TTTBoard. Он отслеживает два элемента состояния: позицию, представленную вышеупомянутым одномерным списком, и игрока, который сейчас делает ход (файл tictactoe.py).
class TTTBoard(Board): def __init__(self, position: List[TTTPiece] = [TTTPiece.E] * 9, turn: TTTPiece = TTTPiece.X) -> None: self.position: List[TTTPiece] = position self._turn: TTTPiece = turn @property def turn(self) -> Piece: return self._turn
Исходное состояние поля - такое, при котором еще не сделано ни одного хода (пустое поле). Конструктор Board имеет параметры по умолчанию, такую позицию, при которой первый игрок готовится поставить X (обычный первый ход в игре). Вы можете спросить: зачем нужны переменная экземпляра _turn и свойство turn()? Такой прием позволяет гарантировать, что все подклассы класса Board будут отслеживать, чья очередь ходить в данный момент. В Python нет четкого и очевидного способа указать в абстрактном базовом классе, что подклассы должны включать в себя конкретную переменную экземпляра, но для их свойств такой механизм существует.
TTTBoard - это по соглашению неизменяемая структура данных: структуры TTTBoard не должны изменяться. Вместо этого каждый раз, когда необходимо сделать ход, будет генерироваться новая структура TTTBoard, позиция которой изменена с учетом хода. Впоследствии это пригодится в алгоритме поиска. При ветвлении поиска мы не сможем случайно изменить положение на поле, начиная с которого все еще анализируются потенциально возможные ходы (файл tictactoe.py).
def move(self, location: Move) -> Board: temp_position: List[TTTPiece] = self.position.copy() temp_position[location] = self._turn return TTTBoard(temp_position, self._turn.opposite)
Допустимым ходом в игре является любая пустая клетка. В показанном далее свойстве legal_moves() используется генератор списков для генерации возможных ходов из данной позиции (файл tictactoe.py).
@property def legal_moves(self) -> List[Move]: return [Move(l) for l in range(len(self.position)) if self.position[l] == TTTPiece.E]
Индексы, на которые воздействует генератор списков, являются индексами типа int в списке позиций. То, что Move также определяется как тип int, сделано намеренно (и это удобно), так как обеспечивает краткость определения legal_moves().
Существует множество способов просмотреть строки, столбцы и диагонали на поле игры, чтобы проверить, завершилась ли она победой. В показанной далее реализации свойства is_win() это сделано посредством жестко закодированной конструкции из and, or и ==, которая кажется бесконечной (файл tictactoe.py). Это не самый красивый код, но он простой и выполняет свою работу.
@property def is_win(self) -> bool: # проберяем три строки, три столбца и дбе диагонали return self.position[0] == self.position[1] and self.position[0] == self.position[2] and \ self.position[0] != TTTPiece.E or self.position[3] == self.position[4] and \ self.position[3] == self.position[5] and self.position[3] != TTTPiece.E or \ self.position[6] == self.position[7] and self.position[6] == self.position[8] and \ self.position[6] != TTTPiece.E or self.position[0] == self.position[3] and \ self.position[0] == self.position[6] and self.position[0] != TTTPiece.E or \ self.position[1] == self.position[4] and self.position[1] == self.position[7] and \ self.position[1] != TTTPiece.E or self.position[2] == self.position[5] and \ self.position[2] == self.position[8] and self.position[2] != TTTPiece.E or \ self.position[0] == self.position[4] and self.position[0] == self.position[8] and \ self.position[0] != TTTPiece.E or self.position[2] == self.position[4] and \ self.position[2] == self.position[6] and self.position[2] != TTTPiece.E
Если все клетки строки, столбца или диагонали не пустые и содержат одинаковые элементы, то игра выиграна.
Игра закончена вничью, если она не выиграна и не осталось допустимых ходов (это свойство уже описано в абстрактном базовом классе Board). Наконец, нам нужен способ оценки конкретной позиции и структурного вывода состояния поля (файл tictactoe.py).
def evaluate(self, player: Piece) -> float: if self.is_win and self.turn == player: return -1 elif self.is_win and self.turn != player: return 1 else: return 0 def __repr__(self) -> str: return f"""{self.position[0]}|{self.position[1]}|{self.position[2]} ----- {self.position[3]}|{self.position[4]}|{self.position[5]} ----- {self.position[6]}|{self.position[7]}|{self.position[8]}"""
В большинстве игр приходится вычислять позицию приблизительно, поскольку мы не можем пройти игру до самого конца, чтобы точно определить, кто выиграет или проиграет в зависимости от того, какие ходы будут сделаны. Но в крестиках-ноликах довольно малое пространство поиска, так что мы можем пройти его от любой позиции до конца игры. Следовательно, метод evaluate() может просто возвращать некое число, если игрок выиграл, меньшее число - в случае ничьей и совсем малое - в случае проигрыша.
На следующем шаге мы рассмотрим минимакс.