From d1c3ca6658129c11fe8bb66d80e74fda7acada03 Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Wed, 14 Feb 2024 22:50:46 +0000 Subject: [PATCH] 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. --- .forgejo/workflows/workflow.yaml | 22 ++++++ cell.py | 112 +++++++++++++------------------ errors.py | 33 +++++++++ graphics.py | 2 +- main.py | 2 +- maze.py | 90 ++++++++++++++++++++++--- tests.py | 46 ++++++++++++- 7 files changed, 228 insertions(+), 79 deletions(-) create mode 100644 .forgejo/workflows/workflow.yaml create mode 100644 errors.py diff --git a/.forgejo/workflows/workflow.yaml b/.forgejo/workflows/workflow.yaml new file mode 100644 index 0000000..5e87cd2 --- /dev/null +++ b/.forgejo/workflows/workflow.yaml @@ -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 diff --git a/cell.py b/cell.py index 87a4cd2..dbdc137 100644 --- a/cell.py +++ b/cell.py @@ -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." diff --git a/errors.py b/errors.py new file mode 100644 index 0000000..a646514 --- /dev/null +++ b/errors.py @@ -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." diff --git a/graphics.py b/graphics.py index 87c6521..828d3fb 100644 --- a/graphics.py +++ b/graphics.py @@ -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: diff --git a/main.py b/main.py index 025973c..9337fed 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/maze.py b/maze.py index 0edd612..98b0976 100644 --- a/maze.py +++ b/maze.py @@ -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) diff --git a/tests.py b/tests.py index e5ddcce..55b8c24 100644 --- a/tests.py +++ b/tests.py @@ -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__":