Practicing objected oriented design. This time, it's designing the TicTacToe game. One difference between the real TicTacToe game is that I've made my TicTacToe game to have variable sized board.
Please review in terms of OOD and I've also left some comments in my code where I want feedback in particular. Thank you very much in advance.
Misc
from enum import Enum
from typing import Union, Optional, Callable
class InvalidInput(Exception):
pass
class SpotTaken(Exception):
def __init__(self, pos_x: int, pos_y: int) -> None:
super().__init__(f'({pos_x}, {pos_y}) is already taken!')
class GameNotRunning(Exception):
def __init__(self) -> None:
super().__init__('Game is currently not running.')
class InvalidBoardSize(Exception):
def __iter__(self) -> None:
super().__init__('Board size must be a positive integer.')
TicTacToe
class TicTacToe:
DEFAULT_BOARD_SIZE = 3
# Should this be inside the TicTacToe class or outside?
class Turn(str, Enum):
X = 'X'
O = 'O'
def __str__(self) -> str:
return self.value
# Should this be inside the TicTacToe class or outside?
class State(Enum):
RUNNING = 0
TURN_WON = 1
TIE = 2
def __init__(self, board_size: Optional[int] = None) -> None:
if board_size is not None and board_size <= 0:
raise InvalidBoardSize()
self.board_size = board_size or self.DEFAULT_BOARD_SIZE
self.board: list[list[Union[None, str]]] = [[None] * self.board_size for _ in range(self.board_size)]
self.turn = self.Turn.X
self.state = self.State.RUNNING
self.spots_left = self.board_size * self.board_size
def do_turn(self, pos_x: int, pos_y: int) -> None:
if self.state != self.State.RUNNING:
raise GameNotRunning()
if pos_x < 0 or pos_x >= self.board_size or pos_y < 0 or pos_y >= self.board_size:
raise InvalidInput('pos_x or pos_y is out of bounds!')
if self.board[pos_x][pos_y]:
raise SpotTaken(pos_x=pos_x, pos_y=pos_y)
self.board[pos_x][pos_y] = self.turn
self.spots_left -= 1
self._check_state()
if self.state == self.State.RUNNING:
self._change_turn()
# Optimally, I don't have to check all 8 possible ways.
# Technically, I only need to check the vertical of the column pos_y, the horizontal of the row pos_x,
# and the diagonals if the (pos_x, pos_y) overlaps with any of the points on the diagonal path.
# However, just to have a simpler code, just do the check on all 8 ways always.
# Since, these are such simple operations, it should not affect the performance really, anyway.
def _check_state(self) -> None:
# vertical check
for row in range(self.board_size):
winning_line_found = True
for col in range(self.board_size):
if self.board[row][col] != self.turn:
winning_line_found = False
break
if winning_line_found:
self.state = self.State.TURN_WON
return
# horizontal check
for col in range(self.board_size):
winning_line_found = True
for row in range(self.board_size):
if self.board[row][col] != self.turn:
winning_line_found = False
break
if winning_line_found:
self.state = self.State.TURN_WON
return
# top left to bottom right diagonal check
winning_line_found = True
for i in range(self.board_size):
if self.board[i][i] != self.turn:
winning_line_found = False
if winning_line_found:
self.state = self.State.TURN_WON
return
# top right to bottom left diagonal check
winning_line_found = True
for i in range(self.board_size):
if self.board[i][self.board_size - i - 1] != self.turn:
winning_line_found = False
if winning_line_found:
self.state = self.State.TURN_WON
return
if self.spots_left == 0:
self.state = self.State.TIE
def _change_turn(self) -> None:
self.turn = self.Turn.X if self.turn == self.Turn.O else self.Turn.O
def _get_status_msg(self) -> str:
if self.state == self.State.RUNNING:
msg = f'{self.turn}\'s turn.'
elif self.state == self.State.TURN_WON:
msg = f'{self.turn} WON!'
else:
msg = 'It\'s a TIE'
return msg
def _get_board_display(self) -> str:
return '\n'.join(str([str(val) if val else ' ' for val in row]) for row in self.board)
def __str__(self) -> str:
return (f'{self._get_board_display()}\n'
f'{self._get_status_msg()}')
TicTacToe CLI Manager
class TicTacToeCLIManager:
class State(Enum):
DETERMINE_BOARD_SIZE = 0
RUN_GAME = 1
ASK_RESTART = 2
QUIT = 3
def __init__(self) -> None:
self.tic_tac_toe: Union[TicTacToe, None] = None
self.state = self.State.DETERMINE_BOARD_SIZE
def run(self) -> None:
while self.state != self.State.QUIT:
self.STATE_TO_HANDLER[self.state](self)
def _handle_determine_board_size_state(self) -> None:
print('Determine the size of the board')
board_size = None
while board_size is None:
user_input = input()
try:
board_size = int(user_input)
except ValueError:
print('The size of the board must be an integer')
try:
self.tic_tac_toe = TicTacToe(board_size=board_size)
except InvalidBoardSize:
print('The size of the board has to be a positive integer')
return
print(self.tic_tac_toe)
self.state = self.State.RUN_GAME
def _handle_run_game_state(self) -> None:
user_input = input()
user_input_split = user_input.split(' ')
try:
pos_x, pos_y = int(user_input_split[0]), int(user_input_split[1])
except (ValueError, IndexError):
print('Inputs must have at least two integers separated by a space')
print(self.tic_tac_toe)
return
try:
self.tic_tac_toe.do_turn(pos_x=pos_x, pos_y=pos_y)
except (InvalidInput, SpotTaken):
print('Pick the position correctly, please')
print(self.tic_tac_toe)
if self.tic_tac_toe.state != self.tic_tac_toe.State.RUNNING:
self.state = self.State.ASK_RESTART
def _handle_ask_restart_state(self) -> None:
print('Do you want to replay? If so, type y')
user_input = input()
if user_input == 'y':
self.state = self.State.DETERMINE_BOARD_SIZE
else:
self.state = self.State.QUIT
# I want to put this constant STATE_TO_HANDLER at the top of the class. I don't like constants being at the bottom.
# However, if I put this above these methods, it cannot find the references to the methods.
# What's the best practice regarding this?
#
# I wanted to put typing on the Callable's parameter and return types. Eg. Callable[[TicTacToeCLIManager], None]
# However, the parameter type would be TicTacToeCLIManager and it cannot find the reference here
# since the TicTacToeCLIManager definition is not finished.
# What's the best practice regarding this, too?
STATE_TO_HANDLER: dict[State, Callable] = {
State.DETERMINE_BOARD_SIZE: _handle_determine_board_size_state,
State.RUN_GAME: _handle_run_game_state,
State.ASK_RESTART: _handle_ask_restart_state,
}
runner
def test():
TicTacToeCLIManager().run()
1 Answer 1
This is an incomplete review up to the function TicTacToe.turn
Re: if pos_x < 0 or pos_x >= self.board_size or pos_y < 0 or pos_y >= self.board_size:
An alternative to if x < min_x or x > max_x
is if not min_x <= x <= max_x
. I think the latter is more readable, but that's up to opinion. In this case, if you wanted to make the change, you'd change if pos_x < 0 or pos_x >= self.board_size
to if not 0 <= pos_x < self.board_size
and likewise for pos_y
.
Suggest you rename self._check_state()
to self._update_state()
or something that indicates that the function might modify state
.
# Optimally, I don't have to check all 8 possible ways.
This comment is only valid for a board size of 3
. In general, an N x N
board would have 2N + 2
possible "ways", so it might be beneficial to implement the optimization you mentioned. I.e. this optimization:
# Technically, I only need to check the vertical of the column pos_y, the horizontal of the row pos_x,
# and the diagonals if the (pos_x, pos_y) overlaps with any of the points on the diagonal path.
Horizontal check
# vertical check
for row in range(self.board_size):
winning_line_found = True
for col in range(self.board_size):
if self.board[row][col] != self.turn:
winning_line_found = False
break
If you consider rows to be horizontal and columns to be vertical, then this is actually a # horizontal check
Second, the code can be simplified to
horiz_winning_line_found = any(row == [self.turn] * self.board_size for row in self.board)
or, better yet, if y_pos
is passed into self._check_state
as an argument, you can just do
horiz_winning_line_found = self.board[y_pos] == [self.turn] * self.board_size
If you decide to go with this, it would make sense to make [self.Turn.X] * self.board_size
and [self.Turn.O] * self.board_size
into class variables of the TicTocToe class.
This not the most OOP thing to do, but if you consider a different representation of X
and O
on the board where X is marked by -1
and O
is marked by +1
, then the check state code could be simplified further. For example,
horiz_winning_line_found = self.board[y_pos] == [self.turn] * self.board_size
would become
horiz_winning_line_found = abs(sum(self.board[y_pos])) == self.board_size
also self._change_turn
would simplify to
def _change_turn(self) -> None:
self.turn *= -1
Vertical check
If you pass x_pos
into self._check_state
, then you can simplify the vertical winning line check to just
vert_winning_line_found = all(self.board[r][x_pos] == self.turn for r in range(self.board_size))
or
vert_winning_line_found = ''.join(self.board[r][x_pos] for r in range(self.board_size) == [self.turn] * self.board_size
You are mixing terms like (rows, columns) with (x_pos
, y_pos
). I would suggest you rename (x_pos
, y_pos
) to (c
, r
), (col
, row
), (colNum
, rowNum
) or something to that effect so that everything is in terms of rows and columns.
If you do end up passing x_pos
and y_pos
to self._check_state
, here's a quick way to check whether x_pos, y_pos
lie on the diagonal
# NW to SE diagonal
check_main_diag = x_pos == y_pos
# SW to NE diagonal
check_minor_diag = x_pos == self.board_size - 1 - y_pos
then you can just do
main_diag_winning_line = check_main_diag * all(self.board[i][i] == self.turn for i in range(self.board_size))
and something similar for minor_diag_winning_line
-
1\$\begingroup\$ Awesome ways to check the result! Thank you very much. \$\endgroup\$whiteSkar– whiteSkar2022年06月14日 20:00:01 +00:00Commented Jun 14, 2022 at 20:00
Explore related questions
See similar questions with these tags.