Last active
February 2, 2024 18:41
-
-
Save thomasmarwitz/028a0b4c8578010b06899dd15a617bbc to your computer and use it in GitHub Desktop.
Simple Tic Tac Toe
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from typing import Protocol, TypeAlias, Callable | |
from dataclasses import dataclass | |
from enum import Enum | |
import random | |
import re | |
class CellContent(Enum): | |
EMPTY = " " | |
CROSS = "X" | |
CIRCLE = "O" | |
@dataclass(frozen=True) | |
class Coordinate: | |
x: int | |
y: int | |
def __repr__(self) -> str: | |
return f"(x={self.x}, y={self.y})" | |
@dataclass(frozen=True) | |
class Cell: | |
coordinate: Coordinate | |
content: CellContent | |
Line: TypeAlias = tuple[Cell] | |
@dataclass(frozen=True) | |
class Grid: | |
_rows: tuple[Line] | |
@classmethod | |
def init_empty(cls, dimension: int) -> "Grid": | |
_rows = [] | |
for y in range(dimension): | |
_rows.append( | |
tuple( | |
Cell(Coordinate(x=x, y=y), CellContent.EMPTY) | |
for x in range(dimension) | |
) | |
) | |
return cls(tuple(_rows)) | |
def insert(self, cell: Cell) -> "Grid": | |
rows = [] | |
for y in range(self.dim): | |
row = [] | |
for x in range(self.dim): | |
to_insert = self._rows[y][x] | |
if to_insert.coordinate == cell.coordinate: | |
to_insert = cell | |
row.append(to_insert) | |
rows.append(tuple(row)) | |
return Grid(tuple(rows)) | |
def is_coordinate_within(self, coordinate: Coordinate) -> bool: | |
return 0 <= coordinate.x < self.dim and 0 <= coordinate.y < self.dim | |
def is_cell_empty(self, coordinate: Coordinate) -> bool: | |
return self[coordinate].content == CellContent.EMPTY | |
def get_diagonals(self) -> tuple[Line, Line]: | |
# 1. diagonal (0,0), (1,1), (2,2) | |
first_diagonal = tuple(self._rows[i][i] for i in range(self.dim)) | |
# 2. diagonal: (0,2), (1,1), (2,0) | |
second_diagonal = tuple( | |
self._rows[i][self.dim - i - 1] for i in range(self.dim) | |
) | |
return first_diagonal, second_diagonal | |
def get_rows(self) -> tuple[Line]: | |
return self._rows | |
def get_columns(self) -> tuple[Line]: | |
# Transpose the rows | |
return tuple(zip(*self._rows)) | |
def get_all_lines(self) -> tuple[Line]: | |
return self.get_rows() + self.get_columns() + self.get_diagonals() | |
def __repr__(self) -> str: | |
return self.__str__() | |
def __str__(self) -> str: | |
representation = [] | |
COL_SEPARATOR = "|" | |
for row in self._rows: | |
representation.append( | |
COL_SEPARATOR.join(cell.content.value for cell in row) | |
) | |
ROW_SEPARATOR = "+".join("-" for _ in range(self.dim)) | |
return f"\n{ROW_SEPARATOR}\n".join(representation) | |
def __getitem__(self, coordinate: Coordinate) -> Cell: | |
return self._rows[coordinate.y][coordinate.x] | |
@property | |
def dim(self) -> int: | |
return len(self._rows) | |
def is_full(self) -> bool: | |
return all( | |
not self.is_cell_empty(Coordinate(x, y)) | |
for y in range(self.dim) | |
for x in range(self.dim) | |
) | |
class Player(Protocol): | |
name: str | |
def make_move(self, grid: Grid) -> Coordinate: | |
... | |
class HumanPlayer(Player): | |
def __init__(self, name: str, input_func: Callable[[str], str] = input) -> None: | |
self.name = name | |
self._input_func = input_func | |
def make_move(self, grid: Grid) -> Coordinate: | |
return self._get_input() | |
def _get_input(self) -> Coordinate: | |
# We only care about retrieving two valid numbers | |
# The rest should be validated by the Game itself | |
while True: | |
user_input = self._input_func("x y: ") | |
try: | |
mo = re.match(r"(\d+)\s+(\d+)", user_input) | |
if mo: | |
x, y = mo.groups() | |
return Coordinate(int(x), int(y)) | |
except Exception: | |
... | |
print("Invalid input, try again") | |
class Game: | |
@dataclass(frozen=True) | |
class PlayerWrapper: # Keep implementation detail of the game here | |
player: Player | |
mark: CellContent | |
player_pos: int | |
def __init__( | |
self, | |
dimension: int, | |
players: tuple[Player, Player], | |
zero_index_mode=False, | |
invert_y_axis=True, | |
) -> None: | |
assert dimension > 0 | |
self._dimension = dimension | |
self._zero_index_mode = zero_index_mode | |
self._invert_y_axis = invert_y_axis | |
self._grid = Grid.init_empty(dimension) | |
marks = [CellContent.CROSS, CellContent.CIRCLE] | |
random.shuffle(marks) # Assign marks randomly | |
self._players = tuple( | |
Game.PlayerWrapper(player, mark, i) | |
for i, (player, mark) in enumerate(zip(players, marks)) | |
) | |
self._history: list[Grid] = [self._grid] | |
def history(self) -> tuple[Grid]: | |
return tuple(self._history) | |
def play(self) -> None: | |
if self._is_game_over(): | |
print("Game is already over") | |
return | |
print("Welcome to a round of Tic Tac Toe!\n") | |
upper_left_coordinate = self._translate_back_coordinate(Coordinate(0, 0)) | |
print(f"Info: The upper left corner is {upper_left_coordinate}") | |
current_player = random.choice(self._players) | |
self.print_grid() | |
while not self._is_game_over(): | |
coordinate = self._get_move(current_player) | |
self._grid = self._grid.insert(Cell(coordinate, current_player.mark)) | |
self.print_grid() | |
self._history.append(self._grid) | |
current_player = self._determine_next_player(current_player) | |
self._determine_result() | |
def congratulate_winner(self, winner: PlayerWrapper) -> None: | |
print(f"Congratulations, {winner.player.name} won!") | |
def determine_winner(self, line: Line) -> PlayerWrapper: | |
for player in self._players: | |
if player.mark == line[0].content: | |
return player | |
def print_grid(self) -> None: | |
print("\n" + str(self._grid) + "\n") | |
def _determine_result(self) -> None: | |
winning_line = self._find_winning_line() | |
if winning_line is None: | |
print("It's a draw!") | |
return | |
winner = self.determine_winner(winning_line) | |
self.congratulate_winner(winner) | |
def _is_game_over(self) -> bool: | |
return self._find_winning_line() is not None or self._grid.is_full() | |
def _translate_coordinate(self, coordinate: Coordinate) -> Coordinate: | |
if not self._zero_index_mode: | |
coordinate = Coordinate(coordinate.x - 1, coordinate.y - 1) | |
if self._invert_y_axis: | |
coordinate = Coordinate(coordinate.x, self._dimension - 1 - coordinate.y) | |
return coordinate | |
def _translate_back_coordinate(self, coordinate: Coordinate) -> Coordinate: | |
if self._invert_y_axis: | |
coordinate = Coordinate(coordinate.x, self._dimension - 1 - coordinate.y) | |
if not self._zero_index_mode: | |
coordinate = Coordinate(coordinate.x + 1, coordinate.y + 1) | |
return coordinate | |
def _get_move(self, wrapper: PlayerWrapper) -> Coordinate: | |
print( | |
f"\nIt's your turn, {wrapper.player.name} ({wrapper.mark.value}):", | |
) | |
while True: | |
coordinate = self._translate_coordinate( # Depends on zero_index_mode | |
wrapper.player.make_move(self._grid) | |
) | |
if self._check_move_valid(coordinate): | |
break | |
print("Forbidden move, try again") | |
return coordinate | |
def _check_move_valid(self, coordinate: Coordinate) -> bool: | |
if not self._grid.is_coordinate_within(coordinate): | |
# important to check this first, otherwise checking if a cell is empty | |
# will fail. | |
return False | |
if not self._grid.is_cell_empty(coordinate): | |
return False | |
return True | |
def _determine_next_player(self, current_player: PlayerWrapper) -> PlayerWrapper: | |
# Cycle through the players | |
return self._players[(current_player.player_pos + 1) % len(self._players)] | |
def _find_winning_line(self) -> Line: | |
for line in self._grid.get_all_lines(): | |
if self._is_line_complete(line) and self._is_line_equal(line): | |
return line | |
return None | |
def _is_line_complete(self, line: Line) -> bool: | |
# all cells are non-empty | |
return all(cell.content != CellContent.EMPTY for cell in line) | |
def _is_line_equal(self, line: Line) -> bool: | |
# all cells have the same content if only one type is found per line | |
return len(set(cell.content for cell in line)) == 1 | |
if __name__ == "__main__": | |
game = Game( | |
3, | |
(HumanPlayer("A"), HumanPlayer("B")), | |
zero_index_mode=False, | |
invert_y_axis=True, | |
) | |
game.play() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment