Шаг 103.
Задачи ComputerScience на Python. Состязательный поиск. Крестики-нолики. Управление состоянием игры в крестики-нолики

    На этом шаге мы рассмотрим реализацию управления этой игрой.

    Давайте разработаем несколько структур, позволяющих отслеживать состояние игры в крестики-нолики по мере ее развития.

    Прежде всего нужен способ представления каждой клетки на игровом поле. Будем использовать перечисление 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() может просто возвращать некое число, если игрок выиграл, меньшее число - в случае ничьей и совсем малое - в случае проигрыша.

    На следующем шаге мы рассмотрим минимакс.




Предыдущий шаг Содержание Следующий шаг