feat: add maze generation functionality

Main feature:

- Added functionality to randomly generate a maze before the solver
  solves it.

Fixes:

- Add a public method in the Cell class called wall_exists() that
  returns true if a given cell wall exists (false otherwise).

Refactors:

- Added an enum type called CellWallLabel for labelling the four cell
  walls.
- Added a draw() function in the CellWall class to reduce repeated code.
- Move the custom exceptions to errors.py

Tests:

- Add tests for the custom exceptions.

CI:

- Added a workflow for Forgejo Actions.
This commit is contained in:
Dan Anglin 2024-02-14 22:50:46 +00:00
parent 6d919a99d6
commit d1c3ca6658
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
7 changed files with 228 additions and 79 deletions

View file

@ -0,0 +1,22 @@
---
name: test
on:
pull_request:
types:
- opened
- reopened
- synchronize
jobs:
test:
runs-on: docker
steps:
- name: Checkout Repository
uses: https://code.forgejo.org/actions/checkout@v3
- name: Install Python 3
uses: https://github.com/actions/setup-python@v5
with:
python-version: '3.12'
- name: Test Code
run: python tests.py

112
cell.py
View file

@ -1,4 +1,18 @@
from typing import Dict
from enum import Enum
from graphics import Window, Point, Line
import errors
class CellWallLabel(Enum):
"""
CellWallLabel is used to label a CellWall
"""
TOP = 0
BOTTOM = 1
LEFT = 2
RIGHT = 3
class CellWall:
@ -7,9 +21,17 @@ class CellWall:
a Cell's wall.
"""
def __init__(self, line: Line) -> None:
def __init__(self, line: Line, window: Window) -> None:
self.exists = True
self.line = line
self._window = window
def draw(self):
fill_colour = self._window.cell_grid_colour
if not self.exists:
fill_colour = self._window.background_colour
self._window.draw_line(self.line, fill_colour=fill_colour)
class Cell:
@ -25,13 +47,13 @@ class Cell:
) -> None:
# Validation
if (x2 < x1) or (y2 < y1):
raise CellInvalidError(x1, y1, x2, y2)
raise errors.CellInvalidError(x1, y1, x2, y2)
if (x2 - x1) < 2:
raise CellTooSmallError("horizontal", x2-x1)
raise errors.CellTooSmallError("horizontal", x2-x1)
if (y2 - y1) < 2:
raise CellTooSmallError("vertical", y2-y1)
raise errors.CellTooSmallError("vertical", y2-y1)
# Define the cell walls
top_wall = Line(Point(x1, y1), Point(x2, y1))
@ -39,10 +61,12 @@ class Cell:
left_wall = Line(Point(x1, y1), Point(x1, y2))
right_wall = Line(Point(x2, y1), Point(x2, y2))
self._top_wall = CellWall(top_wall)
self._bottom_wall = CellWall(bottom_wall)
self._left_wall = CellWall(left_wall)
self._right_wall = CellWall(right_wall)
self._walls: Dict[CellWallLabel, CellWall] = {
CellWallLabel.TOP: CellWall(top_wall, window),
CellWallLabel.BOTTOM: CellWall(bottom_wall, window),
CellWallLabel.LEFT: CellWall(left_wall, window),
CellWallLabel.RIGHT: CellWall(right_wall, window),
}
# Calculate the cell's central point
centre_x = x1 + ((x2 - x1) / 2)
@ -52,6 +76,8 @@ class Cell:
# A reference to the root Window class for drawing purposes.
self._window = window
self.visited = False
def configure_walls(
self,
top: bool = True,
@ -62,10 +88,10 @@ class Cell:
"""
configure_walls configures the existence of the Cell's walls.
"""
self._top_wall.exists = top
self._bottom_wall.exists = bottom
self._left_wall.exists = left
self._right_wall.exists = right
self._walls[CellWallLabel.TOP].exists = top
self._walls[CellWallLabel.BOTTOM].exists = bottom
self._walls[CellWallLabel.LEFT].exists = left
self._walls[CellWallLabel.RIGHT].exists = right
def centre(self) -> Point:
"""
@ -73,6 +99,12 @@ class Cell:
"""
return self._centre
def wall_exists(self, wall: CellWallLabel) -> bool:
"""
returns True if a given cell wall exists, or false otherwise.
"""
return self._walls[wall].exists
def draw(self) -> None:
"""
draw draws the cell onto the canvas
@ -80,25 +112,8 @@ class Cell:
if not self._window:
return
if self._top_wall.exists:
self._window.draw_line(self._top_wall.line, fill_colour=self._window.cell_grid_colour)
else:
self._window.draw_line(self._top_wall.line, fill_colour=self._window.background_colour)
if self._bottom_wall.exists:
self._window.draw_line(self._bottom_wall.line, fill_colour=self._window.cell_grid_colour)
else:
self._window.draw_line(self._bottom_wall.line, fill_colour=self._window.background_colour)
if self._left_wall.exists:
self._window.draw_line(self._left_wall.line, fill_colour=self._window.cell_grid_colour)
else:
self._window.draw_line(self._left_wall.line, fill_colour=self._window.background_colour)
if self._right_wall.exists:
self._window.draw_line(self._right_wall.line, fill_colour=self._window.cell_grid_colour)
else:
self._window.draw_line(self._right_wall.line, fill_colour=self._window.background_colour)
for label in CellWallLabel:
self._walls[label].draw()
def draw_move(self, to_cell: 'Cell', undo: bool = False) -> None:
"""
@ -113,38 +128,3 @@ class Cell:
fill_colour = "grey"
line = Line(self.centre(), to_cell.centre())
self._window.draw_line(line, fill_colour)
class CellInvalidError(Exception):
"""
CellInvalidError is returned when the program tries to create a Cell whose
values are invalid. The values are invalid when x2 is smaller than x1
and/or y2 is smaller than y1. When creating a Cell the x and y values
should always represent the top left and the bottom right of the cell's
walls (i.e. x1 < x2 and y1 < y2).
"""
def __init__(self, x1: int, y1: int, x2: int, y2: int, *args):
super().__init__(args)
self.x1 = x1
self.x2 = x2
self.y1 = y1
self.y2 = y2
def __str__(self):
return f"Invalid Cell values received. Please ensure that both: x1 ({self.x1}) < x2 ({self.x2}), and y1 ({self.y1}) < y2 ({self.y2})"
class CellTooSmallError(Exception):
"""
CellTooSmallError is returned when the program tries to create a Cell
which is too small to correctly draw it's central point.
"""
def __init__(self, size_type: str, size: int, *args):
super().__init__(args)
self.size_type = size_type
self.size = size
def __str__(self):
return f"The {self.size_type} size of the cell ({self.size}) is too small."

33
errors.py Normal file
View file

@ -0,0 +1,33 @@
class CellInvalidError(Exception):
"""
CellInvalidError is raised when the program tries to create a Cell whose
values are invalid. The values are invalid when x2 is smaller than x1
and/or y2 is smaller than y1. When creating a Cell the x and y values
should always represent the top left and the bottom right corners of
the cell (i.e. x1 < x2 and y1 < y2).
"""
def __init__(self, x1: int, y1: int, x2: int, y2: int, *args):
super().__init__(args)
self.x1 = x1
self.x2 = x2
self.y1 = y1
self.y2 = y2
def __str__(self):
return f"Invalid Cell values received. Please ensure that both: x1 ({self.x1}) < x2 ({self.x2}), and y1 ({self.y1}) < y2 ({self.y2})"
class CellTooSmallError(Exception):
"""
CellTooSmallError is raised when the program tries to create a Cell
which is too small to correctly draw it's central point.
"""
def __init__(self, size_type: str, size: int, *args):
super().__init__(args)
self.size_type = size_type
self.size = size
def __str__(self):
return f"The {self.size_type} size of the cell ({self.size}) is too small."

View file

@ -29,7 +29,7 @@ class Line:
self.point_b.x, self.point_b.y,
fill=fill_colour, width=2
)
canvas.pack()
canvas.pack(fill=BOTH, expand=1)
class Window:

View file

@ -5,7 +5,7 @@ from maze import Maze
def main():
window = Window(800, 800)
_ = Maze(10, 10, 3, 39, 20, 20, window)
_ = Maze(10, 10, 30, 30, 20, 20, window)
window.wait_for_close()

90
maze.py
View file

@ -1,5 +1,6 @@
from typing import List
from time import sleep
import random
from graphics import Window
from cell import Cell
@ -18,6 +19,7 @@ class Maze:
cell_size_x: int,
cell_size_y: int,
window: Window = None,
seed=None,
) -> None:
self._x_position = x_position
self._y_position = y_position
@ -27,12 +29,19 @@ class Maze:
self._cell_size_y = cell_size_y
self._window = window
# 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._create_cells()
self._break_entrance_and_exit()
self._open_entrance_and_exit()
self._break_walls_r(0, 0)
def _create_cells(self):
def _create_cells(self) -> None:
"""
creates all the cells and draws them.
"""
cursor_x = self._x_position
cursor_y = self._y_position
@ -57,20 +66,83 @@ class Maze:
if self._window:
self._draw_cells()
def _break_entrance_and_exit(self):
# break entrance and draw
def _open_entrance_and_exit(self) -> None:
"""
opens the maze's entrance and exit cells by breaking their respective
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[0][0].draw()
# break exit and draw
self._cells[self._num_cell_rows - 1][self._num_cells_per_row - 1].configure_walls(bottom=False)
self._cells[self._num_cell_rows - 1][self._num_cells_per_row - 1].draw()
def _draw_cells(self):
self._cells[self._num_cell_rows-1][self._num_cells_per_row-1].configure_walls(bottom=False)
self._cells[self._num_cell_rows-1][self._num_cells_per_row-1].draw()
def _break_walls_r(self, y: int, x: int) -> None:
current_cell = self._cells[y][x]
current_cell.visited = True
above, below, left, right = "above", "below", "left", "right"
while True:
adjacent_cells = {
above: (y-1, x),
below: (y+1, x),
left: (y, x-1),
right: (y, x+1),
}
to_visit: List[str] = []
for k, value in adjacent_cells.items():
if (value[0] < 0)or (value[1] < 0) or (value[0] > self._num_cell_rows-1) or (value[1] > self._num_cells_per_row-1):
continue
if self._cells[value[0]][value[1]].visited:
continue
to_visit.append(k)
if len(to_visit) == 0:
current_cell.draw()
break
next_direction = random.choice(to_visit)
next_cell = self._cells[adjacent_cells[next_direction][0]][adjacent_cells[next_direction][1]]
if next_direction is above:
current_cell.configure_walls(top=False)
next_cell.configure_walls(bottom=False)
elif next_direction is below:
current_cell.configure_walls(bottom=False)
next_cell.configure_walls(top=False)
elif next_direction is left:
current_cell.configure_walls(left=False)
next_cell.configure_walls(right=False)
elif next_direction is right:
current_cell.configure_walls(right=False)
next_cell.configure_walls(left=False)
current_cell.draw()
next_cell.draw()
self._animate()
self._break_walls_r(
adjacent_cells[next_direction][0],
adjacent_cells[next_direction][1],
)
def _draw_cells(self) -> None:
"""
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):
self._cells[i][j].draw()
self._animate()
def _animate(self):
def _animate(self) -> None:
"""
redraws the application and pauses for a short period of time to
provide an animation effect.
"""
self._window.redraw()
sleep(0.05)

View file

@ -1,5 +1,7 @@
import unittest
from cell import Cell, CellWallLabel
from maze import Maze
import errors
class Tests(unittest.TestCase):
@ -55,9 +57,49 @@ class Tests(unittest.TestCase):
2,
2,
)
self.assertFalse(maze._cells[0][0]._top_wall.exists)
self.assertFalse(maze._cells[0][0].wall_exists(CellWallLabel.TOP))
self.assertFalse(
maze._cells[number_of_cell_rows - 1][number_of_cells_per_row - 1]._bottom_wall.exists)
maze._cells[number_of_cell_rows - 1]
[number_of_cells_per_row - 1].wall_exists(CellWallLabel.BOTTOM)
)
def test_invalid_cell_exception(self):
"""
test_invalid_cell_exception tests the exception for when an attempt
is made to create an invalid Cell.
"""
cases = [
{"x1": 30, "y1": 50, "x2": 20, "y2": 100},
{"x1": 30, "y1": 50, "x2": 40, "y2": 25},
]
for case in cases:
with self.assertRaises(errors.CellInvalidError):
_ = Cell(
x1=case["x1"],
y1=case["y1"],
x2=case["x2"],
y2=case["y2"]
)
def test_cell_too_small_exception(self):
"""
test_cell_too_small_exception tests the excpetion for when an attempt
is made to create a Cell that's too small.
"""
cases = [
{"x1": 1, "y1": 50, "x2": 2, "y2": 100},
{"x1": 30, "y1": 25, "x2": 40, "y2": 25},
]
for case in cases:
with self.assertRaises(errors.CellTooSmallError):
_ = Cell(
x1=case["x1"],
y1=case["y1"],
x2=case["x2"],
y2=case["y2"]
)
if __name__ == "__main__":