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:
parent
6d919a99d6
commit
d1c3ca6658
7 changed files with 228 additions and 79 deletions
22
.forgejo/workflows/workflow.yaml
Normal file
22
.forgejo/workflows/workflow.yaml
Normal 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
112
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."
|
||||
|
|
33
errors.py
Normal file
33
errors.py
Normal 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."
|
|
@ -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:
|
||||
|
|
2
main.py
2
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()
|
||||
|
||||
|
|
86
maze.py
86
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):
|
||||
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)
|
||||
|
|
46
tests.py
46
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__":
|
||||
|
|
Loading…
Reference in a new issue