Крестики-нолики для питонистов: сначала пишем, потом играем
Всем привет! Меня зовут Кирилл Быков, и я — наставник на курсе «Python-разработчик» в Яндекс.Практикуме. Тема передачи знаний меня интересовала всегда, ещё со школьных олимпиад, продолжилась в вузе и не оставила на позиции лида, где прямой интерес — делать всех разработчиков в команде, от сеньоров до джунов, максимально эффективными.
Сегодня, юный падаван, на примере простой игры «крестики-нолики» мы разберём, как создают подобные приложения, продумывают для них требования и создают игровую логику. Мы не будем устанавливать много требований, в идеале поведение нашего кода должно соответствовать правилам игры на 100%. Если я вдруг где-то с этим не справился, прошу в комменты. Цель — сделать вас более уверенными при кодировании в парадигме ООП.
Открывайте любимую IDE и пробуйте шаги, которые прошёл я, не стесняясь шагать в сторону, если любопытно или хочется что-то изменить или улучшить.
План
Определим правила игры текстом, это будет мини-ТЗ для дальнейшей работы.
Сделаем игровую доску, универсальную, для любой игры.
Специализируем её под нашу игру «крестики-нолики».
Закодируем правила.
Организуем диалоговое взаимодействие с пользователем — игровой процесс.
Правила
Пустая доска 3 × 3 клетки.
Два игрока ходят по очереди, ставя свою фигуру в пустую ячейку.
Любой горизонтальный, вертикальный или диагональный ряд, заполненный фигурой игрока, приносит ему выигрыш и заканчивает игру.
Когда больше нет свободных полей — игра тоже заканчивается, ничьёй.
Для простоты отображения выберем заглавные латинские «X» и «O» в качестве фигур игроков и пробел для пустой ячейки.
Пусть «X» ходит первым.
Не будем создавать машинный интеллект, реализовать игру для двух игроков проще. Просто сделаем так, чтобы потом можно было легко заменить живого игрока роботом.
Универсальная игровая доска
Я люблю делать универсальные вещи, поэтому сначала описа́л абстрактную игровую доску, чтоб в дальнейшем быстро делать любую игру на её основе.
Описывая доску, я предположил, что синтаксис установки ячейки — это:
board[idx] = value
где board — это доска, и будет удобно как читать, так и изменять ячейку доски таким образом: по индексу этой ячейки.
Так как игры бывают разные, вплоть до количества измерений, то я сделал нашу доску настраиваемой: idx — это не число, а набор чисел, по одному на каждое измерение, размерность — тоже набор чисел: количество ячеек доски по каждому из измерений.
Поскольку доска должна уметь в достаточно непростую логику, разумно будет сделать её в виде класса. Исходя из этого, конструктор принимает ряд размерностей, а магические методы для обращения к ячейке — ряд координат:
class Board:
def __init__(self, sizes: Iterable[int]):
val = self.get_empty_cell_value()
self.cells = ?
def __getitem__(self, idx: Iterable[int]):
pass
def __setitem__(self, idx: Iterable[int], val):
pass
@classmethod
def get_empty_cell_value(cls):
return None
Нужно определиться, как хранить данные ячеек доски. В зависимости от логики игры там может храниться что угодно, например свойства ячейки или просто фигура, поставленная игроком. По логике, у нас намечается многомерный массив ячеек, но я счёл, что массив, хранимый в виде линейного списка, будет проще в обращении, его и использовал:
def __init__(self, sizes: Iterable[int]):
val = self.get_empty_cell_value()
self.cells = [val] * ?
Здесь размерности доски обозначены как sizes, и пока непонятно, чем заполнять наш список и сколько ячеек создавать — это мы рассмотрим позже. Сначала разберёмся, как обращаться с линейным списком, предполагая многомерность. Без этого мы не сумеем написа́ть код для __getitem__ и __setitem__ — «магических» методов, которые Python вызовет, когда мы обратимся к нашей доске с координатами для чтения или записи ячейки.Задача уже давно решённая, но мы её разберём.
Имея размерности (S0, S1, …, Sn-1) и координаты (i0, i1, …, in-1), индекс в линейном массиве будем получать по формуле (i0⋅S1⋅…⋅Sn-1) + (i1⋅S2⋅…⋅Sn-1) + … + (in-2⋅Sn-1) + in-1. Такая процедура понадобится и в __getitem__ и __setitem__, а может, и ещё где-то, поэтому напрашивается отдельный метод. Я написал такой:
def calc_idx(self, idx: Tuple[int]) -> int:
"""
Пересчитывает индексы матрицы в индекс линейного массива.
"""
sizes = self.get_sizes()
return reduce(lambda acc, szidx: accszidx[0] + szidx[1], zip(sizes, idx), 0)
но тут потребуются объяснения. Перейдём от математики к программированию и подумаем, как будет реализована формула (i0⋅S1⋅…⋅Sn-1) + (i1⋅S2⋅…⋅Sn-1) + … + (in-2⋅Sn-1) + in-1. Очевидно, напрашивается цикл, а то и два — вложенный для умножений, от последнего хотелось бы избавиться, поэтому давайте запишем эту формулу иначе:
i0 + i1⋅S0 + i2⋅S0⋅S1+ i3⋅S0⋅S1⋅S2+ … + in-1⋅S0⋅…⋅Sn-2 = ((…(i0⋅S1 + i1)⋅S2 + i2 …)⋅Sn-2+ in-2)⋅Sn-1+ in-1.
Что мы тут сделали: по максимуму сократили цепочки умножений, внеся общие множители в скобки. Кроме того, если приглядеться, можно увидеть итеративность в формуле: взяв i0 в качестве начального значения, мы домножаем его на S1, прибавляем i1, затем результат домножаем на S2, прибавляем i2, и так далее. Можно было записа́ть это циклом, но я предпочёл использовать готовую функцию reduce, которая позволяет выполнять свёртки последовательностей, в нашем случае это последовательность пар (Sj + ij), где j пробегает от 1 до n-1. Взяв за начальное значение не i0, а 0, можно пробегать от 0 до n-1, что и отражено в данном методе.
Теперь вернёмся к нашему конструктору. Там до сих пор не определён размер линейного списка. Будем рассуждать так: размер на единицу больше максимального индекса.
По сути, максимальный индекс рассчитывается так:
((…(S0 - 1)⋅S1 + S1 - 1)⋅S2 + S2 - 1 …)⋅Sn-2 + Sn-2 - 1)⋅Sn-1+ Sn-1 - 1.
То есть взяли максимальные значения по всем измерениям и засунули в формулу. Это сработает, но нам потребуется где-то взять кортеж (S0 - 1, S1 - 1, … Sn-1 - 1), я подумал, что мы можем сократить формулу, если добавим единицу:
((…(S0 - 1)⋅S1 + S1 - 1)⋅S2 + S2 - 1 …)⋅Sn-2 + Sn-2 - 1)⋅Sn-1+ Sn-1 - 1 + 1 =
= ((…(S0 - 1)⋅S1 + S1 - 1)⋅S2 + S2 - 1 …)⋅Sn-2 + Sn-2 - 1)⋅Sn-1+ Sn-1 =
= ((…(S0 - 1)⋅S1 + S1 - 1)⋅S2 + S2 - 1 …)⋅Sn-2 + Sn-2)⋅Sn-1 =
…
= S0 ⋅ S1 ⋅ S2 ⋅… ⋅ Sn-1,
Это просто произведение измерений, что, в общем-то, ожидаемо, ведь чтоб найти площадь Ленина, как знают даже младшеклассники, нужно умножить длину Ленина на ширину Ленина. В трёхмерном случае добавляется третье измерение, и ряд измерений можно продолжать, пока не надоест.
Зачем мы пришли к произведению таким сложным путём? Чтобы переиспользовать функцию calc_idx для нахождения размера линейного списка, который хранит ячейки нашей доски. Ведь лучше сразу создать все необходимые ячейки, чтобы не писа́ть сложный код для увеличения этого списка при доступе к ячейке, которой там ещё нет.
Кто-то может посчитать произведение через отдельный reduce, я сделал так:
S0 ⋅ S1 ⋅ S2 ⋅… ⋅ Sn-1 = calc_idx((0, 0, 0, …0, Sn-1)),
если свериться с формулой, мы увидим, что Sn-1 нужно умножить на все размерности, кроме последней, что и даст требуемый результат.
Итак, дописываем «магические» методы с использованием calc_idx:
def __getitem__(self, idx: Iterable[int]):
return self.cells[self.calc_idx(tuple(idx))]
def __setitem__(self, idx: Iterable[int], val):
self.cells[self.calc_idx(tuple(idx))] = val
Внимательный читатель заметит, что у нас появился метод get_sizes, который и возвращает эти размерности, а значит, нужно их для начала запомнить в поле класса Board:
def __init__(self, sizes: Iterable[int]):
val = self.get_empty_cell_value()
self.sizes = sizes
self.cells = [val] * self.calc_idx((sizes[0],)+(0,)*(len(sizes)-1))
def get_sizes(self) -> Tuple[int]:
"""
Возвращает размерности матрицы.
"""
return self.sizes
На данном этапе наш класс уже достаточно хорош для использования. Если же рассматривать с точки зрения поставленной задачи, то сделано следующее:
есть класс доски;
доску можно сделать с любым числом измерений с указанием размерности по каждой из них;
доска создаётся с пустыми ячейками, что такое пустая ячейка — тоже определено;
к доске можно обратиться по координатам, как board[(i0, i1, …, in-1)], чтоб считать или записать ячейку.
Всё это достаточно общие манипуляции, идём дальше.
Специализированная игровая доска
Теперь мы значительно приблизимся к изначальной задаче — созданию игры «крестики-нолики». Создадим доску, которая будет следить за правилами игры. Для этого нужно зафиксировать размерность поля: оно должно быть двумерным и квадратным. Кроме того, будем считать любую запись в ячейки ходом и не будем принимать ход, который идёт вразрез с правилами игры.
Для этого отнаследуемся от абстрактной доски, внеся некоторые дополнения:
class TTTBoard(Board):
def __init__(self, s):
self.size = s
super().__init__((s, s))
def get_sizes(self) -> Tuple[int]:
return (self.size, self.size)
@classmethod
def get_empty_cell_value(cls):
return ' '
Мы решили ограничиться двумя измерениями, причём размерности одинаковые и поэтому достаточно получить единственное число на вход. Решили переопределить пустую ячейку согласно принятым выше правилам.
Ещё на данном этапе мне стало понятно, что хранение списка измерений стало избыточным, ведь наследники могут иметь константные размерности, которые незачем хранить, поэтому:
class TTTBoard(Board):
def __init__(self, s):
self.size = s
super().__init__((s, s))
class Board:
def __init__(self, sizes: Iterable[int]):
val = self.get_empty_cell_value()
sizes = self.get_sizes()
self.cells = [val] * self.calc_idx((sizes[0],)+(0,)*(len(sizes)-1))
def get_sizes(self) -> Tuple[int]:
raise NotImplementedError("Создайте метод get_sizes для определения геометрии доски.")
Теперь определение размеров полностью делегировано наследникам.
На данном этапе уже можно проверить код нашей доски. Создадим её и будем выполнять ходы пользователей путём записи в ячейки и проверять состояние доски, чтобы убедиться, что она меняется согласно нашим ожиданиям:
>>> from games.tictactoe.tictactoe import TTTBoard
>>> b = TTTBoard(3)
>>> b.cells
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']
Пока всё по плану и согласно правилам — поле состоит из девяти пустых ячеек, пробелов.
Проверим установку ячейки по индексу:
>>> b[(2, 1)] = 'X'
>>> b[(1, 2)] = 'O'
>>> b[(0, 0)] = 'X'
>>> print(b.cells[0:3], b.cells[3:6], b.cells[6:9], sep='\n')
['X', ' ', ' ']
[' ', ' ', 'O']
[' ', 'X', ' ']
Тут тоже без неожиданностей: мы поставили фигуру, и она попала в заданную ячейку. Можно выполнить строчку с print и между обращениями к доске, чтобы посмотреть промежуточные результаты.
Проверить чтение ячейки я оставлю на самостоятельное изучение читателю.
В данном примере мы действовали по правилам игры: крестики ходят первыми, потом нолики, потом снова крестики. Но игровая логика не помешает нам давать ходить игрокам вне очереди, по два и более ходов подряд. По сути, это просто двумерный массив три на три, а не игра в крестики-нолики. Неопытным читателям стоит взять себе на заметку тестировать всё более смело, заведомо нарушая правила. Например, пусть нолики попробуют сходить первыми, а заодно и вторыми и третьими:
>>> b[(2, 1)] = 'O'
>>> b[(1, 2)] = 'O'
>>> b[(0, 0)] = 'O'
>>> print(b.cells[0:3], b.cells[3:6], b.cells[6:9], sep='\n')
['O', ' ', ' ']
[' ', ' ', 'O']
[' ', 'O', ' ']
И даже можно поменять уже поставленную фигуру на другую, что я тоже оставляю на проверку читателю.
Больше правил!
Значит, пора написать код, который будет следить за выполнением правил игры.Я предпочитаю сначала писать всё, что думаю, а потом уже корректировать в соответствии с обратной связью от мира. Поэтому добавляем в конец конструктора TTTBoard:
self.current_player = 'X' # Крестики начинают
И парочку методов
def switch_to_next_player(self):
"""
Переключаем игрока.
"""
self.current_player = 'O' if self.current_player == 'X' else 'X'
def check_rules(self, idx: Tuple[int], val):
pass
Ещё изменим метод установки значения в базовом классе Board, чтобы проверялись наши правила:
def __getitem__(self, idx: Iterable[int]):
return self.cells[self.calc_idx(tuple(idx))]
def __setitem__(self, idx: Iterable[int], val):
idx = tuple(idx)
if self.check_rules(idx, val):
self.cells[self.calc_idx(tuple(idx))] = val
def check_rules(self, idx: Tuple[int], val):
raise NotImplementedError("Задайте валидацию, определив функцию check_rules, чтобы разрешить запись на доску.")
Абстрактный метод check_rules
нужен. Благодаря ему не получится написать новую игру без определения правил: попытка записи в ячейку будет приводить к ошибке NotImplementedError.
А вот проверка правил — это уже сложнее, эта проверка определит всю логику игры.
Попробуем опять написа́ть просто, как думается, что нельзя ходить не в очередь и занимать уже занятую клетку — перепишем метод check_rules класса TTTBoard:
def check_rules(self, idx: Tuple[int], val):
if val != self.current_player:
raise WrongPlayerMoveError('Такой ход не разрешён.')
if self[(idx)] != self.get_empty_cell_value():
raise WrongCellChosenError('Клетка уже занята.')
return True
Вот так просто, если значок не соответствует текущему игроку, выдаём ошибку о том, что игрок сходил не в очередь, и другую ошибку — если попытался занять чужую клетку. Код, который использует нашу доску, должен быть готов к этим ошибкам и обрабатывать их, уведомляя пользователя, что его ход не принимается.
Эти ошибки тоже надо определить, но рядом с базовым классом, ведь по смыслу они могут быть использованы и в других играх:
class WrongPlayerMoveError(ValueError):
pass
class WrongCellChosenError(ValueError):
pass
Достаточно полное объявление, не думаю, что стоит добавить сюда ещё что-то.
Однако нужно автоматизировать переход хода в нашей игре, поэтому в классе TTTBoard переопределяем метод:
def __setitem__(self, key, value):
super().__setitem__(key, value)
self.switch_to_next_player()
К поведению базового класса, реализованному здесь вызовом __setitem__ из Board с помощью super, добавляем смену игрока вызовом нашего метода switch_to_next_player.
Посмотрим, как это всё работает теперь? Создадим доску, и пусть нолики попробуют снова ходить первыми:
>>> b = TTTBoard(3)
>>> b[(2, 1)] = 'O'
File
"…/python-simple-board-games/commons/board.py", line
…, in __setitem__
if self.check_rules(idx, val):
File
"…/python-simple-board-games/games/tictactoe/tictactoe.py", line
…, in check_rules
raise WrongPlayerMoveError('Wrong player move.')
commons.board.WrongPlayerMoveError: Wrong player move.
>>> b.cells
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']
Всё правильно, крестики ходят первыми:
>>> b[(1, 0)] = 'X'
>>> b.cells
[' ', ' ', ' ', 'X', ' ', ' ', ' ', ' ', ' ']
>>> b.current_player
'O'
И после их хода мы видим изменение на доске и переход хода. Всё хорошо. Я тестировал чуть больше, но не вижу смысла обременять статью подробностями.
Далее я на самом деле переключился на процесс взаимодействия с пользователем, но здесь, в статье, буду более последователен.
Определяем победителя
В игровом процессе необходимо будет определять выигрышную ситуацию или ситуацию ничьей, и желательно делать это так, чтобы при желании сделать красивый интерфейс можно было показать выигрышный ряд, будь то горизонталь, вертикаль или диагональ. Я предпочёл возвращать значок выигравшего игрока, координаты одного из краёв ряда и вектор приращения координат до следующей ячейки ряда. А если игра не окончена, то возвращать None. Эта функция действительно сложная, сложнее, чем проверка правил. И уж точно более содержательная: ведь надо проверить все горизонтали, вертикали и диагонали, да ещё и сформировать результат анализа. Однако она скорее многословная, чем сложная и информативная, cуть сводится к тому, чтобы взять первую ячейку проверяемого ряда и, если она не пустая, сравнить с остальными. Если все оказались равны, то ряд приносит выигрыш, если нет — проверяем следующий. Если больше нечего проверять, то ничья будет при полностью занятом поле, а иначе — вернём None как признак продолжения игры.
def get_winner(self) -> None | Tuple[str, Tuple[int, ...], Tuple[int, ...]]:
"""
Определить ситуацию окончания игры, если её нет, вернуть None.
Ситуация кодируется корте́жем из фигуры игрока, координат выигрышной комбинации
и направления выигрышной комбинации. Если ничья, то фигура пустая.
"""
size = self.size
e = self.get_empty_cell_value()
# Главная диагональ
v = self[(0, 0)]
if v != e:
for i in range(1, size):
if self[(i, i)] != v:
break
else:
return v, (0, 0), (1, 1)
# Побочная диагональ
v = self[(0, size-1)]
if v != e:
for i in range(1, size):
if self[(i, size-1-i)] != v:
break
else:
return v, (0, size-1), (1, -1)
# Горизонтали/вертикали
for j in range(size):
v = self[(0, j)]
if v != e:
for i in range(1, size):
if self[(i, j)] != v:
break
else:
return v, (0, j), (1, 0)
v = self[(j, 0)]
if v != e:
for i in range(1, size):
if self[(j, i)] != v:
break
else:
return v, (j, 0), (0, 1)
# Есть ещё место для фигуры — игра не окончена
if e in self.cells:
return None
# Нет места, и не сработали условия для выигрыша — ничья
return e, (0, 0), (0, 0)
Игровой процесс
Продолжим в духе ООП, но небольшой фрагмент кода будет в функциональном стиле, это главный файл, который мы будем запускать. Здесь я опускаю импорты и прочие шаблонные вещи, описывая только главную функцию.
def main():
board = TTTBoard(3)
gameplay = TTTPlay(board)
# Цикл обработки событий
msg = None
while True:
msg = msg or gameplay.get_default_input_message()
gameplay.draw_board()
if gameplay.draw_state():
break
inp = input(msg)
msg = gameplay.handle_input(inp)
По сути, эта функция достаточно шаблонна сама по себе: создаются необходимые объекты, затем идёт игровой цикл. В ходе игрового цикла происходит выяснение и отображение состояния игры (draw_state и draw_board), выход в случае выигрыша или ничьей, и ожидание ввода пользователя с сообщением, который тоже генерирует наш новый класс TTTPlay.
В качестве ввода пользователя ожидаем цифру от 1 до 9, это номер клетки, в которую он хочет поставить свою фигуру. Нумеровать клетки будем очевидным способом, как читаем книгу: слева направо, сверху вниз. Игрок, получивший приглашение к вводу, должен будет нажать цифру и затем клавишу «Ввод». Хороший тон — дать возможность досрочно выйти из игры, пусть это будет «q» или «в» в любом регистре, введённая вместо цифры.
class TTTPlay:
def __init__(self, board: TTTBoard):
self.board = board
self.quit = False
def draw_board(self):
print('Доска:')
s = self.board.get_sizes()
for i in range(s[0]):
for j in range(s[1]):
print(self.board[(i, j)], end='')
print()
def draw_state(self) -> None | Tuple[str, Tuple[int, ...], Tuple[int, ...]]:
???
def get_default_input_message(self) -> str:
return "Введите номер ячейки (1-9) или q/в для выхода: "
def handle_input(self, inp) -> None | str:
???
Методы, которые мы тут видим, имеют достаточно разную сложность, и если get_default_input_message() — это просто строчка с объяснением пользователю, какой ввод от него ожидается, а draw_board — простой вывод матрицы доски двумя циклами, и недостойны разбора, то handle_input и особенно draw_state достойны более глубокого рассмотрения.
Рассмотрим эти два метода по порядку возрастания сложности.
def handle_input(self, inp) -> None | str:
if inp.lower() in {'q', 'в'}:
self.quit = True
return "Выход из игры."
if inp not in '123456789':
return self.get_default_input_message()
inp = int(inp) - 1
s = self.board.get_sizes()
try:
self.board[divmod(inp, s[0])] = self.board.current_player
except WrongCellChosenError:
return 'Ячейка уже занята, выберите другую.'
return None
Сначала проверяем условие выхода из игры, ведь если пользователь решил закончить игру, то далее что-то делать бесполезно. Далее ожидается цифра от 1 до 9, по номеру ячейки, куда будет установлена фигура текущего игрока. Здесь мы опираемся на готовую игровую логику и возвращаем сообщение об ошибке, если доска её сгенерирует.
Если всё прошло удачно, возвращаем None, чтобы внешняя логика могла выбрать своё приглашение для пользователя.
И, как всегда, сложное на десерт. Анализ игровой ситуации, несмотря на то, что уже выполнен в игровой логике, нуждается в дополнении, чтобы мы выдали сообщение, адекватное сложившейся игровой ситуации. Этот метод, я думаю, был бы проще в случае более творческого подхода к визуализации.
def draw_state(self) -> None | Tuple[str, Tuple[int, ...], Tuple[int, ...]]:
if self.quit:
return self.board.get_empty_cell_value(), (0, 0), (0, 0)
win_info = self.board.get_winner()
e = self.board.get_empty_cell_value()
if win_info:
if win_info[0] == e:
print('Ничья.')
else:
print('Выиграли', win_info[0])
if win_info[2][0] and win_info[2][1]:
if win_info[2][0] == win_info[2][1]:
print('Главная диагональ.')
else:
print('Побочная диагональ')
elif win_info[2][0]:
print('Строка', win_info[1][1] + 1)
else:
print('Столбец', win_info[1][0] + 1)
return win_info
else:
print('Ходит:', self.board.current_player)
return None
Тип результата аналогичен методу, который анализирует игровую ситуацию, да и сам результат, за исключением выхода из игры по инициативе пользователя, тот же.
Дальше проверяется первый элемент кортежа (win_info[0]), и если там пусто, значит, ничья и дальнейшего анализа не требуется.
Если же там фигура, то выведем, что игрок этой фигуры выиграл, и, в зависимости от содержимого третьего элемента кортежа (win_info[2], там направление выигрышного ряда), пишем главная или побочная диагональ, а для горизонтального или вертикального ряда из второго элемента кортежа достаём номер (одна из координат win_info[1]).
Код игры уже в репозитории, можно сверить с тем, что получилось. Дальше, используя библиотеку curses или что-то из GUI-библиотек, можно существенно улучшить внешний вид игры и сделать её интерактивной.
Как запустить игру из репозитория
Склонируй репозиторий куда-то, где будет удобно с ним работать:
$ git clone https://github.com/brawaga/python-simple-board-games
Смени директорию:
$ cd python-simple-board-games
Дальше нужно занести текущий путь в переменную PYTHONPATH, если у тебя другая операционная система, обратись к справочной информации, чтоб сформировать команду для своей ОС, у меня Linux и команда простая:
$ export PYTHONPATH=pwd
Теперь можем запустить игру, для версии, описанной в этой статье, дополнительные пакеты не нужны:
$ python3 games/tictactoe/main_console.py
А теперь…
Играем!
Доска:
Ходит: X
Введите номер ячейки (1-9) или q/в для выхода: 8
Доска:
X
Ходит: O
Введите номер ячейки (1-9) или q/в для выхода: 1
Доска:
O
X
Ходит: X
Введите номер ячейки (1-9) или q/в для выхода: 7
Доска:
O
XX
Ходит: O
Введите номер ячейки (1-9) или q/в для выхода: 9
Доска:
O
XXO
Ходит: X
Введите номер ячейки (1-9) или q/в для выхода: 3
Доска:
O X
XXO
Ходит: O
Введите номер ячейки (1-9) или q/в для выхода: 5
Доска:
O X
O
XXO
Выиграли O
Главная диагональ.
Выглядит не очень наглядно, но в репозитории есть улучшенная версия, использующая библиотеку curses, изучение которой (и версии, и библиотеки) я оставляю читателю в качестве самостоятельной работы.