refactor: add a dedicated class for the solver

Create a separate class to represent the solver of the maze.
Additional search algorithms will be implemented here.

Additional changes:

- Add public methods to mark cells as visited and to get results of
  the visits.
- Renamed MazeDirections to MazeDirection.
- Renamed _num_cell_rows to _height.
- Renamed _num_cells_per_row to _width.
- Created a dedicated method to configure a cell's walls in the Maze
  class.
- created a dedicated public method to draw a path between two cells in
  the Maze class.
This commit is contained in:
Dan Anglin 2024-02-16 23:27:09 +00:00
parent 5c48f47a0a
commit a091f0a68d
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
5 changed files with 333 additions and 170 deletions

24
cell.py
View file

@ -76,8 +76,10 @@ class Cell:
# A reference to the root Window class for drawing purposes.
self._window = window
self.visited_by_maze_generator = False
self.visited_by_maze_solver = False
self._visited: Dict[str, bool] = {
"generator": False,
"solver": False,
}
def configure_walls(
self,
@ -137,3 +139,21 @@ class Cell:
fill_colour = "grey"
line = Line(self.centre(), to_cell.centre())
self._window.draw_line(line, fill_colour)
def was_visited_by(self, visitor: str) -> bool:
"""
returns True if the cell was visited by the
specified visitor.
"""
if visitor not in ("solver", "generator"):
raise ValueError(f"This is an unknown visitor ({visitor})")
return self._visited[visitor]
def mark_as_visited_by(self, visitor: str) -> None:
"""
marks the cell as visited by the specified visitor.
"""
if visitor not in ("solver", "generator"):
raise ValueError(f"This is an unknown visitor ({visitor})")
self._visited[visitor] = True

14
main.py
View file

@ -1,5 +1,6 @@
from graphics import Window
from maze import Maze
from solver import Solver
def main():
@ -8,15 +9,16 @@ def main():
game = Maze(
x_position=10,
y_position=10,
num_cell_rows=30,
num_cells_per_row=30,
cell_size_x=20,
cell_size_y=20,
height=16,
width=16,
cell_height=40,
cell_width=40,
window=window
)
solved = game.solve()
if solved:
solver = Solver(game)
if solver.solve():
print("Maze solved successfully :)")
else:
print("I'm unable to solve the maze :(")

308
maze.py
View file

@ -1,4 +1,4 @@
from typing import List, Dict
from typing import List
from time import sleep
import random
from enum import Enum
@ -6,7 +6,7 @@ from graphics import Window
from cell import Cell, CellWallLabels
class MazeDirections(Enum):
class MazeDirection(Enum):
"""
MazeDirection represents the directions you can
take in the maze.
@ -22,53 +22,59 @@ class MazePosition:
MazePosition represents a position on the maze grid.
"""
def __init__(self, i: int, j: int, max_i: int, max_j: int):
def __init__(self, i: int, j: int, last_i: int, last_j: int):
self.i = i
self.j = j
self.max_i = max_i
self.max_j = max_j
self.last_i = last_i
self.last_j = last_j
def __eq__(self, other) -> bool:
if (self.i == other.i) and (self.j == other.j) and (self.max_i == other.max_i) and (self.max_j == other.max_j):
if (self.i == other.i) and (self.j == other.j) and (self.last_i == other.last_i) and (self.last_j == other.last_j):
return True
return False
def get_adjacent_position(
self,
direction: MazeDirections
direction: MazeDirection
) -> 'MazePosition':
if direction not in MazeDirections:
"""
calculate and return the position on the maze that is directly adjacent
to this maze position in the specified direction. If the adjacent
position is outside the boundaries of the maze then a value of None
is returned.
"""
if direction not in MazeDirection:
raise TypeError(
"The argument does not appear to be a valid maze direction."
)
if direction is MazeDirections.ABOVE and (self.i-1 >= 0):
if direction is MazeDirection.ABOVE and (self.i-1 >= 0):
return MazePosition(
i=self.i-1,
j=self.j,
max_i=self.max_i,
max_j=self.max_j,
last_i=self.last_i,
last_j=self.last_j,
)
if direction is MazeDirections.BELOW and (self.i+1 <= self.max_i):
if direction is MazeDirection.BELOW and (self.i+1 <= self.last_i):
return MazePosition(
i=self.i+1,
j=self.j,
max_i=self.max_i,
max_j=self.max_j,
last_i=self.last_i,
last_j=self.last_j,
)
if direction is MazeDirections.LEFT and (self.j-1 >= 0):
if direction is MazeDirection.LEFT and (self.j-1 >= 0):
return MazePosition(
i=self.i,
j=self.j-1,
max_i=self.max_i,
max_j=self.max_j,
last_i=self.last_i,
last_j=self.last_j,
)
if direction is MazeDirections.RIGHT and (self.j+1 <= self.max_j):
if direction is MazeDirection.RIGHT and (self.j+1 <= self.last_j):
return MazePosition(
i=self.i,
j=self.j+1,
max_i=self.max_i,
max_j=self.max_j,
last_i=self.last_i,
last_j=self.last_j,
)
return None
@ -83,27 +89,27 @@ class Maze:
self,
x_position: int,
y_position: int,
num_cell_rows: int,
num_cells_per_row: int,
cell_size_x: int,
cell_size_y: int,
height: int,
width: int,
cell_height: int,
cell_width: int,
window: Window = None,
seed=None,
) -> None:
self._x_position = x_position
self._y_position = y_position
self._num_cell_rows = num_cell_rows
self._num_cells_per_row = num_cells_per_row
self._cell_size_x = cell_size_x
self._cell_size_y = cell_size_y
self._height = height
self._width = width
self._cell_height = cell_height
self._cell_width = cell_width
self._window = window
self._generator = "generator"
# initialise the random number generator
random.seed(seed)
# Create the Maze's cells
self._cells: List[List[Cell]] = [
None for i in range(self._num_cell_rows)]
self._cell_grid: List[List[Cell]] = []
self._create_cell_grid()
# Open up the maze's entrance and exit.
@ -112,37 +118,49 @@ class Maze:
start_position = MazePosition(
i=0,
j=0,
max_i=self._num_cell_rows-1,
max_j=self._num_cells_per_row-1,
last_i=self._height-1,
last_j=self._width-1,
)
# Generate the maze.
self._break_walls_r(start_position)
def get_last_i(self) -> int:
"returns the last position of the Maze's outer list."
return self._height-1
def get_last_j(self) -> int:
"returns the last position of the Maze's inner list."
return self._width-1
def _create_cell_grid(self) -> None:
"""
creates all the cells and draws them.
"""
self._cell_grid: List[List[Cell]] = [None for i in range(self._height)]
cursor_x = self._x_position
cursor_y = self._y_position
for i in range(self._num_cell_rows):
cells: List[Cell] = [None for j in range(self._num_cells_per_row)]
for j in range(self._num_cells_per_row):
for i in range(self._height):
cells: List[Cell] = [None for j in range(self._width)]
for j in range(self._width):
cell = Cell(
cursor_x,
cursor_y,
(cursor_x + self._cell_size_x),
(cursor_y + self._cell_size_y),
(cursor_x + self._cell_width),
(cursor_y + self._cell_height),
self._window
)
cells[j] = cell
if j == self._num_cells_per_row - 1:
if j == self._width - 1:
cursor_x = self._x_position
else:
cursor_x += self._cell_size_x
self._cells[i] = cells
cursor_y += self._cell_size_y
cursor_x += self._cell_width
self._cell_grid[i] = cells
cursor_y += self._cell_height
if self._window:
self._draw_cell_grid()
@ -152,8 +170,9 @@ class Maze:
draws all the cells on the maze with a short pause between each cell
for animation purposes.
"""
for i in range(self._num_cell_rows):
for j in range(self._num_cells_per_row):
for i in range(self._height):
for j in range(self._width):
self._draw_cell(i=i, j=j)
def _open_entrance_and_exit(self) -> None:
@ -162,15 +181,16 @@ class Maze:
walls. The entrance is located at the top left and the exit is located
at the bottom right of the maze.
"""
self._cells[0][0].configure_walls(top=False)
self._cells[self._num_cell_rows -
1][self._num_cells_per_row-1].configure_walls(bottom=False)
self._cell_grid[0][0].configure_walls(top=False)
self._cell_grid[self._height-1][self._width -
1].configure_walls(bottom=False)
if self._window:
self._draw_cell(0, 0)
self._draw_cell(
i=self._num_cell_rows-1,
j=self._num_cells_per_row-1
i=self._height-1,
j=self._width-1
)
def _break_walls_r(self, current_position: MazePosition) -> None:
@ -179,19 +199,25 @@ class Maze:
cells and randomly knocking down the walls to create the maze's paths.
"""
current_cell = self._cells[current_position.i][current_position.j]
current_cell.visited_by_maze_generator = True
self.mark_cell_as_visited(
i=current_position.i,
j=current_position.j,
visitor=self._generator,
)
while True:
possible_directions: List[MazeDirections] = []
possible_directions: List[MazeDirection] = []
for direction in MazeDirections:
for direction in MazeDirection:
adjacent_position = current_position.get_adjacent_position(
direction)
if adjacent_position is None:
continue
adjacent_cell = self._cells[adjacent_position.i][adjacent_position.j]
if adjacent_cell.visited_by_maze_generator:
if self.cell_was_visited_by(
i=adjacent_position.i,
j=adjacent_position.j,
visitor=self._generator,
):
continue
possible_directions.append(direction)
@ -201,90 +227,124 @@ class Maze:
break
chosen_direction = random.choice(possible_directions)
next_position = current_position.get_adjacent_position(
chosen_direction)
next_cell = self._cells[next_position.i][next_position.j]
next_position = current_position.get_adjacent_position(chosen_direction)
if chosen_direction is MazeDirections.ABOVE:
current_cell.configure_walls(top=False)
next_cell.configure_walls(bottom=False)
elif chosen_direction is MazeDirections.BELOW:
current_cell.configure_walls(bottom=False)
next_cell.configure_walls(top=False)
elif chosen_direction is MazeDirections.LEFT:
current_cell.configure_walls(left=False)
next_cell.configure_walls(right=False)
elif chosen_direction is MazeDirections.RIGHT:
current_cell.configure_walls(right=False)
next_cell.configure_walls(left=False)
if chosen_direction is MazeDirection.ABOVE:
self._configure_cell_walls(
i=current_position.i,
j=current_position.j,
top=False,
)
self._configure_cell_walls(
i=next_position.i,
j=next_position.j,
bottom=False,
)
elif chosen_direction is MazeDirection.BELOW:
self._configure_cell_walls(
i=current_position.i,
j=current_position.j,
bottom=False,
)
self._configure_cell_walls(
i=next_position.i,
j=next_position.j,
top=False,
)
elif chosen_direction is MazeDirection.LEFT:
self._configure_cell_walls(
i=current_position.i,
j=current_position.j,
left=False,
)
self._configure_cell_walls(
i=next_position.i,
j=next_position.j,
right=False,
)
elif chosen_direction is MazeDirection.RIGHT:
self._configure_cell_walls(
i=current_position.i,
j=current_position.j,
right=False,
)
self._configure_cell_walls(
i=next_position.i,
j=next_position.j,
left=False,
)
if self._window:
self._draw_cell(i=current_position.i, j=current_position.j)
self._break_walls_r(next_position)
def solve(self) -> bool:
"""
solve attempts to solve the generated maze.
"""
start_position = MazePosition(
i=0,
j=0,
max_i=self._num_cell_rows-1,
max_j=self._num_cells_per_row-1,
)
end_position = MazePosition(
i=self._num_cell_rows-1,
j=self._num_cells_per_row-1,
max_i=self._num_cell_rows-1,
max_j=self._num_cells_per_row-1,
)
return self._solve_r(start_position, end_position)
def _solve_r(
self,
current_position: MazePosition,
end_position: MazePosition,
) -> bool:
if current_position == end_position:
return True
current_cell = self._cells[current_position.i][current_position.j]
current_cell.visited_by_maze_solver = True
wall_map: Dict[MazeDirections, CellWallLabels] = {
MazeDirections.ABOVE: CellWallLabels.BOTTOM,
MazeDirections.BELOW: CellWallLabels.TOP,
MazeDirections.LEFT: CellWallLabels.RIGHT,
MazeDirections.RIGHT: CellWallLabels.LEFT,
}
for direction in MazeDirections:
adjacent_position = current_position.get_adjacent_position(
direction)
if adjacent_position is None:
continue
adjacent_cell = self._cells[adjacent_position.i][adjacent_position.j]
if adjacent_cell.visited_by_maze_solver or adjacent_cell.wall_exists(wall_map[direction]):
continue
self._draw_path(current_cell, adjacent_cell)
result = self._solve_r(adjacent_position, end_position)
if result is True:
return True
self._draw_path(current_cell, adjacent_cell, undo=True)
return False
def _draw_cell(self, i: int, j: int) -> None:
"""
_draw_cell draws the cells in an animated way.
draws the cells in an animated way.
"""
self._cells[i][j].draw()
self._cell_grid[i][j].draw()
self._window.redraw()
sleep(0.05)
def _draw_path(self, current_cell: Cell, next_cell: Cell, undo: bool = False) -> None:
"""
draws a path between two cells in an animated way.
"""
current_cell.draw_move(next_cell, undo)
self._window.redraw()
sleep(0.05)
def mark_cell_as_visited(self, i: int, j: int, visitor: str) -> None:
"""
marks the cell at the specified position by the specified visitor.
"""
self._cell_grid[i][j].mark_as_visited_by(visitor)
def cell_was_visited_by(self, i: int, j: int, visitor: str) -> bool:
"""
returns True if the cell at the specified position was visited by
the specified visitor.
"""
return self._cell_grid[i][j].was_visited_by(visitor)
def cell_wall_exists(self, i: int, j: int, wall: CellWallLabels) -> bool:
"""
returns true if a specified cell's wall exists.
"""
return self._cell_grid[i][j].wall_exists(wall)
def draw_path_between(self, a: MazePosition, b: MazePosition, undo: bool = False) -> None:
"""
draws a path between position A and position B
"""
cell_a = self._cell_grid[a.i][a.j]
cell_b = self._cell_grid[b.i][b.j]
self._draw_path(cell_a, cell_b, undo)
def _configure_cell_walls(
self,
i: int,
j: int,
top: bool = None,
bottom: bool = None,
left: bool = None,
right: bool = None,
) -> None:
"""
(re)configures the walls of the specified cell.
"""
self._cell_grid[i][j].configure_walls(
top=top,
bottom=bottom,
left=left,
right=right,
)

81
solver.py Normal file
View file

@ -0,0 +1,81 @@
from typing import Dict
from maze import Maze, MazeDirection, MazePosition
from cell import CellWallLabels
class Solver:
def __init__(self, game: Maze):
self._game = game
self._solver = "solver"
# This is a dictionary mapping the direction to the maze to the
# wall of the adjacent cell. It is used to identify the wall that could
# potentially block the solver's path.
# For example if the solver wants to move to the right, it's path
# could be blocked by the adjacent cell's left wall.
self._wall_map: Dict[MazeDirection, CellWallLabels] = {
MazeDirection.ABOVE: CellWallLabels.BOTTOM,
MazeDirection.BELOW: CellWallLabels.TOP,
MazeDirection.LEFT: CellWallLabels.RIGHT,
MazeDirection.RIGHT: CellWallLabels.LEFT,
}
def solve(self) -> bool:
"""
solve attempts to solve the generated maze.
"""
start_position = MazePosition(
i=0,
j=0,
last_i=self._game.get_last_i(),
last_j=self._game.get_last_j(),
)
end_position = MazePosition(
i=self._game.get_last_i(),
j=self._game.get_last_j(),
last_i=self._game.get_last_i(),
last_j=self._game.get_last_j(),
)
return self._solve_r(start_position, end_position)
def _solve_r(
self,
current_position: MazePosition,
end_position: MazePosition,
) -> bool:
if current_position == end_position:
return True
self._game.mark_cell_as_visited(
i=current_position.i,
j=current_position.j,
visitor=self._solver,
)
for direction in MazeDirection:
adjacent_position = current_position.get_adjacent_position(
direction
)
if adjacent_position is None:
continue
if self._game.cell_was_visited_by(
i=adjacent_position.i,
j=adjacent_position.j,
visitor=self._solver,
) or self._game.cell_wall_exists(
i=adjacent_position.i,
j=adjacent_position.j,
wall=self._wall_map[direction],
):
continue
self._game.draw_path_between(current_position, adjacent_position)
result = self._solve_r(adjacent_position, end_position)
if result is True:
return True
self._game.draw_path_between(current_position, adjacent_position, undo=True)
return False

View file

@ -15,16 +15,16 @@ class Tests(unittest.TestCase):
"""
cases = [
{
"number_of_cell_rows": 6,
"number_of_cells_per_row": 9,
"height": 6,
"width": 9,
},
{
"number_of_cell_rows": 3,
"number_of_cells_per_row": 12,
"height": 3,
"width": 12,
},
{
"number_of_cell_rows": 4,
"number_of_cells_per_row": 4,
"height": 4,
"width": 4,
},
]
@ -32,20 +32,20 @@ class Tests(unittest.TestCase):
m = maze.Maze(
0,
0,
case["number_of_cell_rows"],
case["number_of_cells_per_row"],
case["height"],
case["width"],
2,
2,
None,
None,
)
self.assertEqual(
len(m._cells),
case["number_of_cell_rows"],
len(m._cell_grid),
case["height"],
)
self.assertEqual(
len(m._cells[0]),
case["number_of_cells_per_row"],
len(m._cell_grid[0]),
case["width"],
)
def test_break_entrance_and_exit(self):
@ -65,9 +65,9 @@ class Tests(unittest.TestCase):
None,
None,
)
self.assertFalse(m._cells[0][0].wall_exists(CellWallLabels.TOP))
self.assertFalse(m._cell_grid[0][0].wall_exists(CellWallLabels.TOP))
self.assertFalse(
m._cells[number_of_cell_rows - 1]
m._cell_grid[number_of_cell_rows - 1]
[number_of_cells_per_row - 1].wall_exists(CellWallLabels.BOTTOM)
)
@ -112,13 +112,13 @@ class Tests(unittest.TestCase):
def test_maze_position_equality(self):
cases = [
{
"m1": maze.MazePosition(i=1, j=3, max_i=10, max_j=100),
"m2": maze.MazePosition(i=1, j=3, max_i=10, max_j=100),
"m1": maze.MazePosition(i=1, j=3, last_i=10, last_j=100),
"m2": maze.MazePosition(i=1, j=3, last_i=10, last_j=100),
"expected": True,
},
{
"m1": maze.MazePosition(i=1, j=3, max_i=10, max_j=100),
"m2": maze.MazePosition(i=100, j=30, max_i=200, max_j=100),
"m1": maze.MazePosition(i=1, j=3, last_i=10, last_j=100),
"m2": maze.MazePosition(i=100, j=30, last_i=200, last_j=100),
"expected": False,
}
]
@ -130,43 +130,43 @@ class Tests(unittest.TestCase):
def test_maze_position_adjacent_positition(self):
cases = [
{
"position": maze.MazePosition(i=3, j=4, max_i=10, max_j=10),
"direction": maze.MazeDirections.ABOVE,
"expected": maze.MazePosition(i=2, j=4, max_i=10, max_j=10),
"position": maze.MazePosition(i=3, j=4, last_i=10, last_j=10),
"direction": maze.MazeDirection.ABOVE,
"expected": maze.MazePosition(i=2, j=4, last_i=10, last_j=10),
},
{
"position": maze.MazePosition(i=9, j=4, max_i=10, max_j=10),
"direction": maze.MazeDirections.BELOW,
"expected": maze.MazePosition(i=10, j=4, max_i=10, max_j=10),
"position": maze.MazePosition(i=9, j=4, last_i=10, last_j=10),
"direction": maze.MazeDirection.BELOW,
"expected": maze.MazePosition(i=10, j=4, last_i=10, last_j=10),
},
{
"position": maze.MazePosition(i=1, j=1, max_i=10, max_j=10),
"direction": maze.MazeDirections.LEFT,
"expected": maze.MazePosition(i=1, j=0, max_i=10, max_j=10),
"position": maze.MazePosition(i=1, j=1, last_i=10, last_j=10),
"direction": maze.MazeDirection.LEFT,
"expected": maze.MazePosition(i=1, j=0, last_i=10, last_j=10),
},
{
"position": maze.MazePosition(i=3, j=9, max_i=10, max_j=10),
"direction": maze.MazeDirections.RIGHT,
"expected": maze.MazePosition(i=3, j=10, max_i=10, max_j=10),
"position": maze.MazePosition(i=3, j=9, last_i=10, last_j=10),
"direction": maze.MazeDirection.RIGHT,
"expected": maze.MazePosition(i=3, j=10, last_i=10, last_j=10),
},
{
"position": maze.MazePosition(i=0, j=4, max_i=10, max_j=10),
"direction": maze.MazeDirections.ABOVE,
"position": maze.MazePosition(i=0, j=4, last_i=10, last_j=10),
"direction": maze.MazeDirection.ABOVE,
"expected": None,
},
{
"position": maze.MazePosition(i=10, j=4, max_i=10, max_j=10),
"direction": maze.MazeDirections.BELOW,
"position": maze.MazePosition(i=10, j=4, last_i=10, last_j=10),
"direction": maze.MazeDirection.BELOW,
"expected": None,
},
{
"position": maze.MazePosition(i=1, j=0, max_i=10, max_j=10),
"direction": maze.MazeDirections.LEFT,
"position": maze.MazePosition(i=1, j=0, last_i=10, last_j=10),
"direction": maze.MazeDirection.LEFT,
"expected": None,
},
{
"position": maze.MazePosition(i=3, j=10, max_i=10, max_j=10),
"direction": maze.MazeDirections.RIGHT,
"position": maze.MazePosition(i=3, j=10, last_i=10, last_j=10),
"direction": maze.MazeDirection.RIGHT,
"expected": None,
},
]