mirror of
https://github.com/maoakeEnterprise/amazing.git
synced 2026-04-29 00:14:34 +02:00
doing the wheel
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cell:
|
||||
"""Represent a maze cell encoded as a bitmask of surrounding walls.
|
||||
|
||||
The cell value is stored as an integer where each bit represents the
|
||||
presence of a wall in one cardinal direction:
|
||||
|
||||
- bit 0 (1): north wall
|
||||
- bit 1 (2): east wall
|
||||
- bit 2 (4): south wall
|
||||
- bit 3 (8): west wall
|
||||
"""
|
||||
|
||||
def __init__(self, value: int) -> None:
|
||||
"""Initialize a cell with its encoded wall value.
|
||||
|
||||
Args:
|
||||
value: Integer bitmask representing the cell walls.
|
||||
"""
|
||||
self.value = value
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return the hexadecimal representation of the cell value.
|
||||
|
||||
Returns:
|
||||
The uppercase hexadecimal form of the cell value without the
|
||||
``0x`` prefix.
|
||||
"""
|
||||
return hex(self.value).removeprefix("0x").upper()
|
||||
|
||||
def set_value(self, value: int) -> None:
|
||||
"""Set the encoded value of the cell.
|
||||
|
||||
Args:
|
||||
value: Integer bitmask representing the cell walls.
|
||||
"""
|
||||
self.value = value
|
||||
|
||||
def get_value(self) -> int:
|
||||
"""Return the encoded value of the cell.
|
||||
|
||||
Returns:
|
||||
The integer bitmask representing the cell walls.
|
||||
"""
|
||||
return self.value
|
||||
|
||||
def set_north(self, is_wall: bool) -> None:
|
||||
"""Set or clear the north wall.
|
||||
|
||||
Args:
|
||||
is_wall: ``True`` to add the north wall, ``False`` to remove it.
|
||||
"""
|
||||
if (not is_wall and self.value | 14 == 15) or (
|
||||
is_wall and self.value | 14 != 15
|
||||
):
|
||||
self.value = self.value ^ (1)
|
||||
|
||||
def get_north(self) -> bool:
|
||||
"""Return whether the north wall is present.
|
||||
|
||||
Returns:
|
||||
``True`` if the north wall is set, otherwise ``False``.
|
||||
"""
|
||||
return self.value & 1 == 1
|
||||
|
||||
def set_est(self, is_wall: bool) -> None:
|
||||
"""Set or clear the east wall.
|
||||
|
||||
Args:
|
||||
is_wall: ``True`` to add the east wall, ``False`` to remove it.
|
||||
"""
|
||||
if (not is_wall and self.value | 13 == 15) or (
|
||||
is_wall and self.value | 13 != 15
|
||||
):
|
||||
self.value = self.value ^ (2)
|
||||
|
||||
def get_est(self) -> bool:
|
||||
"""Return whether the east wall is present.
|
||||
|
||||
Returns:
|
||||
``True`` if the east wall is set, otherwise ``False``.
|
||||
"""
|
||||
return self.value & 2 == 2
|
||||
|
||||
def set_south(self, is_wall: bool) -> None:
|
||||
"""Set or clear the south wall.
|
||||
|
||||
Args:
|
||||
is_wall: ``True`` to add the south wall, ``False`` to remove it.
|
||||
"""
|
||||
if (not is_wall and self.value | 11 == 15) or (
|
||||
is_wall and self.value | 11 != 15
|
||||
):
|
||||
self.value = self.value ^ (4)
|
||||
|
||||
def get_south(self) -> bool:
|
||||
"""Return whether the south wall is present.
|
||||
|
||||
Returns:
|
||||
``True`` if the south wall is set, otherwise ``False``.
|
||||
"""
|
||||
return self.value & 4 == 4
|
||||
|
||||
def set_west(self, is_wall: bool) -> None:
|
||||
"""Set or clear the west wall.
|
||||
|
||||
Args:
|
||||
is_wall: ``True`` to add the west wall, ``False`` to remove it.
|
||||
"""
|
||||
if (not is_wall and self.value | 7 == 15) or (
|
||||
is_wall and self.value | 7 != 15
|
||||
):
|
||||
self.value = self.value ^ (8)
|
||||
|
||||
def get_west(self) -> bool:
|
||||
"""Return whether the west wall is present.
|
||||
|
||||
Returns:
|
||||
``True`` if the west wall is set, otherwise ``False``.
|
||||
"""
|
||||
return self.value & 8 == 8
|
||||
@@ -0,0 +1,76 @@
|
||||
from dataclasses import dataclass
|
||||
from numpy.typing import NDArray
|
||||
from typing import Optional, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Maze:
|
||||
"""Represent a maze as a two-dimensional array of cells."""
|
||||
|
||||
maze: Optional[NDArray[Any]] = None
|
||||
|
||||
def get_maze(self) -> Optional[NDArray[Any]]:
|
||||
"""Return the underlying maze array.
|
||||
|
||||
Returns:
|
||||
The two-dimensional array representing the maze, or ``None`` if no
|
||||
maze has been set.
|
||||
"""
|
||||
return self.maze
|
||||
|
||||
def set_maze(self, new_maze: NDArray[Any]) -> None:
|
||||
"""Set the maze array.
|
||||
|
||||
Args:
|
||||
new_maze: A two-dimensional array containing the maze cells.
|
||||
"""
|
||||
self.maze = new_maze
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return a string representation of the maze.
|
||||
|
||||
Each cell is converted to its string representation and concatenated
|
||||
line by line.
|
||||
|
||||
Returns:
|
||||
A multiline string representation of the maze, or ``"None"`` if the
|
||||
maze is not set.
|
||||
"""
|
||||
if self.maze is None:
|
||||
return "None"
|
||||
res = ""
|
||||
for line in self.maze:
|
||||
for cell in line:
|
||||
res += cell.__str__()
|
||||
res += "\n"
|
||||
return res
|
||||
|
||||
def ascii_print(self) -> None:
|
||||
"""Print an ASCII representation of the maze.
|
||||
|
||||
The maze is rendered using underscores and vertical bars to show the
|
||||
walls of each cell. If no maze is set, ``"None"`` is printed.
|
||||
"""
|
||||
if self.maze is None:
|
||||
print("None")
|
||||
return
|
||||
for cell in self.maze[0]:
|
||||
print("_", end="")
|
||||
if cell.get_north():
|
||||
print("__", end="")
|
||||
else:
|
||||
print(" ", end="")
|
||||
print("_")
|
||||
for line in self.maze:
|
||||
for cell in line:
|
||||
if cell is line[0] and cell.get_west():
|
||||
print("|", end="")
|
||||
if cell.get_south() is True:
|
||||
print("__", end="")
|
||||
else:
|
||||
print(" ", end="")
|
||||
if cell.get_est() is True:
|
||||
print("|", end="")
|
||||
else:
|
||||
print("_", end="")
|
||||
print()
|
||||
@@ -0,0 +1,591 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Generator, Any
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from .Cell import Cell
|
||||
import math
|
||||
import random
|
||||
|
||||
|
||||
class MazeGenerator(ABC):
|
||||
"""Define the common interface and helpers for maze generators."""
|
||||
|
||||
def __init__(
|
||||
self, start: tuple[int, int], end: tuple[int, int], perfect: bool
|
||||
) -> None:
|
||||
"""Initialize the maze generator.
|
||||
|
||||
Args:
|
||||
start: Starting cell coordinates, using 1-based indexing.
|
||||
end: Ending cell coordinates, using 1-based indexing.
|
||||
perfect: Whether to generate a perfect maze with no loops.
|
||||
"""
|
||||
self.start = (start[0] - 1, start[1] - 1)
|
||||
self.end = (end[0] - 1, end[1] - 1)
|
||||
self.perfect = perfect
|
||||
|
||||
@abstractmethod
|
||||
def generator(
|
||||
self, height: int, width: int, seed: int | None = None
|
||||
) -> Generator[NDArray[Any], None, NDArray[Any]]:
|
||||
"""Generate a maze step by step.
|
||||
|
||||
Args:
|
||||
height: Number of rows in the maze.
|
||||
width: Number of columns in the maze.
|
||||
seed: Optional random seed for reproducibility.
|
||||
|
||||
Yields:
|
||||
Intermediate maze states during generation.
|
||||
|
||||
Returns:
|
||||
The final generated maze.
|
||||
"""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def get_cell_ft(width: int, height: int) -> set[tuple[int, int]]:
|
||||
"""Return the coordinates used to reserve the '42' pattern.
|
||||
|
||||
Args:
|
||||
width: Number of columns in the maze.
|
||||
height: Number of rows in the maze.
|
||||
|
||||
Returns:
|
||||
A set of cell coordinates belonging to the reserved pattern.
|
||||
"""
|
||||
forty_two = set()
|
||||
y, x = (int(height / 2), int(width / 2))
|
||||
forty_two.add((y, x - 1))
|
||||
forty_two.add((y, x - 2))
|
||||
forty_two.add((y, x - 3))
|
||||
forty_two.add((y - 1, x - 3))
|
||||
forty_two.add((y - 2, x - 3))
|
||||
forty_two.add((y + 1, x - 1))
|
||||
forty_two.add((y + 2, x - 1))
|
||||
forty_two.add((y, x + 1))
|
||||
forty_two.add((y, x + 2))
|
||||
forty_two.add((y, x + 3))
|
||||
forty_two.add((y - 1, x + 3))
|
||||
forty_two.add((y - 2, x + 3))
|
||||
forty_two.add((y - 2, x + 2))
|
||||
forty_two.add((y - 2, x + 1))
|
||||
forty_two.add((y + 1, x + 1))
|
||||
forty_two.add((y + 2, x + 1))
|
||||
forty_two.add((y + 2, x + 2))
|
||||
forty_two.add((y + 2, x + 3))
|
||||
return forty_two
|
||||
|
||||
@staticmethod
|
||||
def unperfect_maze(
|
||||
width: int,
|
||||
height: int,
|
||||
maze: NDArray[Any],
|
||||
forty_two: set[tuple[int, int]] | None,
|
||||
prob: float = 0.1,
|
||||
) -> Generator[NDArray[Any], None, NDArray[Any]]:
|
||||
"""Add extra openings to transform a perfect maze into an imperfect
|
||||
one.
|
||||
|
||||
Random walls are removed while optionally preserving the reserved
|
||||
``forty_two`` area.
|
||||
|
||||
Args:
|
||||
width: Number of columns in the maze.
|
||||
height: Number of rows in the maze.
|
||||
maze: The maze to modify.
|
||||
forty_two: Optional set of reserved coordinates that must not be
|
||||
altered.
|
||||
prob: Probability of breaking an eligible wall.
|
||||
|
||||
Yields:
|
||||
Intermediate maze states after each wall removal.
|
||||
|
||||
Returns:
|
||||
The modified maze.
|
||||
"""
|
||||
|
||||
directions = {"N": (0, -1), "S": (0, 1), "W": (-1, 0), "E": (1, 0)}
|
||||
|
||||
reverse = {"N": "S", "S": "N", "W": "E", "E": "W"}
|
||||
min_break = 2
|
||||
while True:
|
||||
count = 0
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
if forty_two and (x, y) in forty_two:
|
||||
continue
|
||||
for direc, (dx, dy) in directions.items():
|
||||
nx, ny = x + dx, y + dy
|
||||
if forty_two and (
|
||||
(y, x) in forty_two or (ny, nx) in forty_two
|
||||
):
|
||||
continue
|
||||
if not (0 <= nx < width and 0 < ny < height):
|
||||
continue
|
||||
if direc in ["S", "E"]:
|
||||
continue
|
||||
if np.random.random() < prob:
|
||||
count += 1
|
||||
cell = maze[y][x]
|
||||
cell_n = maze[ny][nx]
|
||||
cell = DepthFirstSearch.broken_wall(cell, direc)
|
||||
cell_n = DepthFirstSearch.broken_wall(
|
||||
cell_n,
|
||||
reverse[direc],
|
||||
)
|
||||
maze[y][x] = cell
|
||||
maze[ny][nx] = cell_n
|
||||
yield maze
|
||||
if count > min_break:
|
||||
break
|
||||
return maze
|
||||
|
||||
|
||||
class Kruskal(MazeGenerator):
|
||||
"""Generate a maze using a Kruskal-based algorithm."""
|
||||
|
||||
class KruskalSet:
|
||||
"""Represent a connected component of maze cells."""
|
||||
|
||||
def __init__(self, cells: list[int]) -> None:
|
||||
"""Initialize a set of connected cells.
|
||||
|
||||
Args:
|
||||
cells: List of cell indices belonging to the set.
|
||||
"""
|
||||
self.cells: list[int] = cells
|
||||
|
||||
class Sets:
|
||||
"""Store all connected components used during generation."""
|
||||
|
||||
def __init__(self, sets: list["Kruskal.KruskalSet"]) -> None:
|
||||
"""Initialize the collection of connected components.
|
||||
|
||||
Args:
|
||||
sets: List of disjoint cell sets.
|
||||
"""
|
||||
self.sets = sets
|
||||
|
||||
@staticmethod
|
||||
def walls_to_maze(
|
||||
walls: list[tuple[int, int]], height: int, width: int
|
||||
) -> NDArray[Any]:
|
||||
"""Convert a list of remaining walls into a maze grid.
|
||||
|
||||
Args:
|
||||
walls: Collection of wall pairs between adjacent cells.
|
||||
height: Number of rows in the maze.
|
||||
width: Number of columns in the maze.
|
||||
|
||||
Returns:
|
||||
A two-dimensional array of :class:`Cell` instances representing the
|
||||
maze.
|
||||
"""
|
||||
|
||||
maze: NDArray[Any] = np.array(
|
||||
[[Cell(value=0) for _ in range(width)] for _ in range(height)]
|
||||
)
|
||||
for wall in walls:
|
||||
x, y = wall
|
||||
match y - x:
|
||||
case 1:
|
||||
maze[math.trunc((x / width))][x % width].set_est(True)
|
||||
maze[math.trunc((y / width))][y % width].set_west(True)
|
||||
case width:
|
||||
maze[math.trunc((x / width))][x % width].set_south(True)
|
||||
maze[math.trunc((y / width))][y % width].set_north(True)
|
||||
for x in range(height):
|
||||
for y in range(width):
|
||||
if x == 0:
|
||||
maze[x][y].set_north(True)
|
||||
if x == height - 1:
|
||||
maze[x][y].set_south(True)
|
||||
if y == 0:
|
||||
maze[x][y].set_west(True)
|
||||
if y == width - 1:
|
||||
maze[x][y].set_est(True)
|
||||
return maze
|
||||
|
||||
@staticmethod
|
||||
def is_in_same_set(sets: Sets, wall: tuple[int, int]) -> bool:
|
||||
"""Check whether both cells connected by a wall are in the same set.
|
||||
|
||||
Args:
|
||||
sets: Current collection of connected components.
|
||||
wall: Pair of adjacent cell indices.
|
||||
|
||||
Returns:
|
||||
``True`` if both cells belong to the same set, otherwise ``False``.
|
||||
"""
|
||||
a, b = wall
|
||||
for set in sets.sets:
|
||||
if a in set.cells and b in set.cells:
|
||||
return True
|
||||
elif a in set.cells or b in set.cells:
|
||||
return False
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def merge_sets(sets: Sets, wall: tuple[int, int]) -> None:
|
||||
"""Merge the two sets connected by the given wall.
|
||||
|
||||
Args:
|
||||
sets: Current collection of connected components.
|
||||
wall: Pair of adjacent cell indices.
|
||||
|
||||
Raises:
|
||||
Exception: If the two corresponding sets cannot be found.
|
||||
"""
|
||||
a, b = wall
|
||||
base_set = None
|
||||
for i in range(len(sets.sets)):
|
||||
if base_set is None and (
|
||||
a in sets.sets[i].cells or b in sets.sets[i].cells
|
||||
):
|
||||
base_set = sets.sets[i]
|
||||
elif base_set and (
|
||||
a in sets.sets[i].cells or b in sets.sets[i].cells
|
||||
):
|
||||
base_set.cells += sets.sets[i].cells
|
||||
sets.sets.pop(i)
|
||||
return
|
||||
raise Exception("two sets not found")
|
||||
|
||||
@staticmethod
|
||||
def touch_ft(
|
||||
width: int,
|
||||
wall: tuple[int, int],
|
||||
cells_ft: None | set[tuple[int, int]],
|
||||
) -> bool:
|
||||
"""Check whether a wall touches the reserved '42' pattern.
|
||||
|
||||
Args:
|
||||
width: Number of columns in the maze.
|
||||
wall: Pair of adjacent cell indices.
|
||||
cells_ft: Reserved coordinates, or ``None``.
|
||||
|
||||
Returns:
|
||||
``True`` if either endpoint of the wall belongs to the reserved
|
||||
pattern, otherwise ``False``.
|
||||
"""
|
||||
if cells_ft is None:
|
||||
return False
|
||||
s1 = (math.trunc(wall[0] / width), wall[0] % width)
|
||||
s2 = (math.trunc(wall[1] / width), wall[1] % width)
|
||||
return s1 in cells_ft or s2 in cells_ft
|
||||
|
||||
def generator(
|
||||
self, height: int, width: int, seed: int | None = None
|
||||
) -> Generator[NDArray[Any], None, NDArray[Any]]:
|
||||
"""Generate a maze using a Kruskal-based approach.
|
||||
|
||||
Args:
|
||||
height: Number of rows in the maze.
|
||||
width: Number of columns in the maze.
|
||||
seed: Optional random seed for reproducibility.
|
||||
|
||||
Yields:
|
||||
Intermediate maze states during generation.
|
||||
|
||||
Returns:
|
||||
The final generated maze.
|
||||
"""
|
||||
cells_ft = None
|
||||
if height > 10 and width > 10:
|
||||
cells_ft = self.get_cell_ft(width, height)
|
||||
if cells_ft and (self.start in cells_ft or self.end in cells_ft):
|
||||
cells_ft = None
|
||||
|
||||
if seed is not None:
|
||||
np.random.seed(seed)
|
||||
sets = self.Sets([self.KruskalSet([i]) for i in range(height * width)])
|
||||
walls = []
|
||||
for h in range(height):
|
||||
for w in range(width - 1):
|
||||
walls += [(w + (width * h), w + (width * h) + 1)]
|
||||
for h in range(height - 1):
|
||||
for w in range(width):
|
||||
walls += [(w + (width * h), w + (width * (h + 1)))]
|
||||
np.random.shuffle(walls)
|
||||
|
||||
yield self.walls_to_maze(walls, height, width)
|
||||
while (len(sets.sets) != 1 and cells_ft is None) or (
|
||||
len(sets.sets) != 19 and cells_ft is not None
|
||||
):
|
||||
for wall in walls:
|
||||
if not self.is_in_same_set(sets, wall) and not self.touch_ft(
|
||||
width, wall, cells_ft
|
||||
):
|
||||
self.merge_sets(sets, wall)
|
||||
walls.remove(wall)
|
||||
yield self.walls_to_maze(walls, height, width)
|
||||
if (len(sets.sets) == 1 and cells_ft is None) or (
|
||||
len(sets.sets) == 19 and cells_ft is not None
|
||||
):
|
||||
break
|
||||
print(f"nb sets: {len(sets.sets)}")
|
||||
maze = self.walls_to_maze(walls, height, width)
|
||||
if self.perfect is False:
|
||||
gen = Kruskal.unperfect_maze(width, height, maze, cells_ft)
|
||||
for res in gen:
|
||||
maze = res
|
||||
yield maze
|
||||
return maze
|
||||
|
||||
|
||||
class DepthFirstSearch(MazeGenerator):
|
||||
"""Generate a maze using a depth-first search backtracking algorithm."""
|
||||
|
||||
def __init__(
|
||||
self, start: tuple[int, int], end: tuple[int, int], perfect: bool
|
||||
) -> None:
|
||||
"""Initialize the depth-first search generator.
|
||||
|
||||
Args:
|
||||
start: Starting cell coordinates, using 1-based indexing.
|
||||
end: Ending cell coordinates, using 1-based indexing.
|
||||
perfect: Whether to generate a perfect maze with no loops.
|
||||
"""
|
||||
self.start = (start[0] - 1, start[1] - 1)
|
||||
self.end = (end[0] - 1, end[1] - 1)
|
||||
self.perfect = perfect
|
||||
self.forty_two: set[tuple[int, int]] | None = None
|
||||
|
||||
def generator(
|
||||
self, height: int, width: int, seed: int | None = None
|
||||
) -> Generator[NDArray[Any], None, NDArray[Any]]:
|
||||
"""Generate a maze using depth-first search.
|
||||
|
||||
Args:
|
||||
height: Number of rows in the maze.
|
||||
width: Number of columns in the maze.
|
||||
seed: Optional random seed for reproducibility.
|
||||
|
||||
Yields:
|
||||
Intermediate maze states during generation.
|
||||
|
||||
Returns:
|
||||
The final generated maze.
|
||||
"""
|
||||
if seed is not None:
|
||||
np.random.seed(seed)
|
||||
maze = self.init_maze(width, height)
|
||||
if width > 9 and height > 9:
|
||||
self.forty_two = self.get_cell_ft(width, height)
|
||||
visited: NDArray[np.object_] = np.zeros((height, width), dtype=bool)
|
||||
if (
|
||||
self.forty_two
|
||||
and self.start not in self.forty_two
|
||||
and self.end not in self.forty_two
|
||||
):
|
||||
visited = self.lock_cell_ft(visited, self.forty_two)
|
||||
path: list[tuple[int, int]] = list()
|
||||
w_h = (width, height)
|
||||
coord = (0, 0)
|
||||
x, y = coord
|
||||
first_iteration = True
|
||||
|
||||
while path or first_iteration:
|
||||
first_iteration = False
|
||||
|
||||
visited[y, x] = True
|
||||
path = self.add_cell_visited(coord, path)
|
||||
|
||||
random_c = self.random_cells(visited, coord, w_h)
|
||||
|
||||
if not random_c:
|
||||
path = self.back_on_step(path, w_h, visited)
|
||||
if not path:
|
||||
break
|
||||
coord = path[-1]
|
||||
random_c = self.random_cells(visited, coord, w_h)
|
||||
x, y = coord
|
||||
|
||||
wall = self.next_step(random_c)
|
||||
maze[y][x] = self.broken_wall(maze[y][x], wall)
|
||||
|
||||
coord = self.next_cell(x, y, wall)
|
||||
wall_r = self.reverse_path(wall)
|
||||
x, y = coord
|
||||
maze[y][x] = self.broken_wall(maze[y][x], wall_r)
|
||||
yield maze
|
||||
if self.perfect is False:
|
||||
gen = DepthFirstSearch.unperfect_maze(
|
||||
width,
|
||||
height,
|
||||
maze,
|
||||
self.forty_two,
|
||||
)
|
||||
for res in gen:
|
||||
maze = res
|
||||
yield maze
|
||||
return maze
|
||||
|
||||
@staticmethod
|
||||
def init_maze(width: int, height: int) -> NDArray[Any]:
|
||||
"""Create a fully walled maze grid.
|
||||
|
||||
Args:
|
||||
width: Number of columns in the maze.
|
||||
height: Number of rows in the maze.
|
||||
|
||||
Returns:
|
||||
A two-dimensional array of cells initialized with all
|
||||
walls present.
|
||||
"""
|
||||
maze = np.array(
|
||||
[[Cell(value=15) for _ in range(width)] for _ in range(height)]
|
||||
)
|
||||
return maze
|
||||
|
||||
@staticmethod
|
||||
def add_cell_visited(
|
||||
coord: tuple[int, int], path: list[tuple[int, int]]
|
||||
) -> list[tuple[int, int]]:
|
||||
"""Append a visited coordinate to the current traversal path.
|
||||
|
||||
Args:
|
||||
coord: Coordinate of the visited cell.
|
||||
path: Current traversal path.
|
||||
|
||||
Returns:
|
||||
The updated path.
|
||||
"""
|
||||
path.append(coord)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def random_cells(
|
||||
visited: NDArray[Any], coord: tuple[int, int], w_h: tuple[int, int]
|
||||
) -> list[str]:
|
||||
"""Return the list of unvisited neighboring directions.
|
||||
|
||||
Args:
|
||||
visited: Boolean array marking visited cells.
|
||||
coord: Current cell coordinate.
|
||||
w_h: Tuple containing maze width and height.
|
||||
|
||||
Returns:
|
||||
A list of direction strings among ``"N"``, ``"S"``, ``"W"``, and
|
||||
``"E"``.
|
||||
"""
|
||||
rand_cell: list[str] = []
|
||||
x, y = coord
|
||||
width, height = w_h
|
||||
|
||||
if y - 1 >= 0 and not visited[y - 1][x]:
|
||||
rand_cell.append("N")
|
||||
|
||||
if y + 1 < height and not visited[y + 1][x]:
|
||||
rand_cell.append("S")
|
||||
|
||||
if x - 1 >= 0 and not visited[y][x - 1]:
|
||||
rand_cell.append("W")
|
||||
|
||||
if x + 1 < width and not visited[y][x + 1]:
|
||||
rand_cell.append("E")
|
||||
return rand_cell
|
||||
|
||||
@staticmethod
|
||||
def next_step(rand_cell: list[str]) -> str:
|
||||
"""Select the next direction at random.
|
||||
|
||||
Args:
|
||||
rand_cell: List of candidate directions.
|
||||
|
||||
Returns:
|
||||
A randomly selected direction.
|
||||
"""
|
||||
return random.choice(rand_cell)
|
||||
|
||||
@staticmethod
|
||||
def broken_wall(cell: Cell, wall: str) -> Cell:
|
||||
"""Remove the specified wall from a cell.
|
||||
|
||||
Args:
|
||||
cell: The cell to modify.
|
||||
wall: Direction of the wall to remove.
|
||||
|
||||
Returns:
|
||||
The modified cell.
|
||||
"""
|
||||
if wall == "N":
|
||||
cell.set_north(False)
|
||||
elif wall == "S":
|
||||
cell.set_south(False)
|
||||
elif wall == "W":
|
||||
cell.set_west(False)
|
||||
elif wall == "E":
|
||||
cell.set_est(False)
|
||||
return cell
|
||||
|
||||
@staticmethod
|
||||
def next_cell(x: int, y: int, next: str) -> tuple[int, int]:
|
||||
"""Return the coordinates of the adjacent cell in the given direction.
|
||||
|
||||
Args:
|
||||
x: Current column index.
|
||||
y: Current row index.
|
||||
next: Direction to move.
|
||||
|
||||
Returns:
|
||||
The coordinates of the next cell.
|
||||
"""
|
||||
next_step = {"N": (0, -1), "S": (0, 1), "W": (-1, 0), "E": (1, 0)}
|
||||
add_x, add_y = next_step[next]
|
||||
return (x + add_x, y + add_y)
|
||||
|
||||
@staticmethod
|
||||
def reverse_path(direction: str) -> str:
|
||||
"""Return the opposite cardinal direction.
|
||||
|
||||
Args:
|
||||
direction: Input direction.
|
||||
|
||||
Returns:
|
||||
The opposite direction.
|
||||
"""
|
||||
return {"N": "S", "S": "N", "W": "E", "E": "W"}[direction]
|
||||
|
||||
@staticmethod
|
||||
def back_on_step(
|
||||
path: list[tuple[int, int]],
|
||||
w_h: tuple[int, int],
|
||||
visited: NDArray[Any],
|
||||
) -> list[tuple[int, int]]:
|
||||
"""Backtrack through the path until a cell with unvisited neighbors
|
||||
is found.
|
||||
|
||||
Args:
|
||||
path: Current traversal path.
|
||||
w_h: Tuple containing maze width and height.
|
||||
visited: Boolean array marking visited cells.
|
||||
|
||||
Returns:
|
||||
The truncated path after backtracking.
|
||||
"""
|
||||
while path:
|
||||
last = path[-1]
|
||||
if DepthFirstSearch.random_cells(visited, last, w_h):
|
||||
break
|
||||
path.pop()
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def lock_cell_ft(
|
||||
visited: NDArray[Any], forty_two: set[tuple[int, int]]
|
||||
) -> NDArray[Any]:
|
||||
"""Mark the reserved '42' pattern cells as already visited.
|
||||
|
||||
Args:
|
||||
visited: Boolean array marking visited cells.
|
||||
forty_two: Set of reserved cell coordinates.
|
||||
|
||||
Returns:
|
||||
The updated visited array.
|
||||
"""
|
||||
tab = [cell for cell in forty_two]
|
||||
for cell in tab:
|
||||
visited[cell] = True
|
||||
return visited
|
||||
@@ -0,0 +1,427 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from .Maze import Maze
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
import random
|
||||
|
||||
|
||||
class MazeSolver(ABC):
|
||||
"""Define the common interface for maze-solving algorithms."""
|
||||
|
||||
def __init__(self, start: tuple[int, int], end: tuple[int, int]) -> None:
|
||||
"""Initialize the maze solver.
|
||||
|
||||
Args:
|
||||
start: Start coordinates using 1-based indexing.
|
||||
end: End coordinates using 1-based indexing.
|
||||
"""
|
||||
self.start = (start[1] - 1, start[0] - 1)
|
||||
self.end = (end[1] - 1, end[0] - 1)
|
||||
|
||||
@abstractmethod
|
||||
def solve(
|
||||
self, maze: Maze, height: int | None = None, width: int | None = None
|
||||
) -> str:
|
||||
"""Solve the maze and return the path as direction letters.
|
||||
|
||||
Args:
|
||||
maze: The maze to solve.
|
||||
height: Optional maze height.
|
||||
width: Optional maze width.
|
||||
|
||||
Returns:
|
||||
A string representing the path using cardinal directions.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class AStar(MazeSolver):
|
||||
"""Solve a maze using the A* pathfinding algorithm."""
|
||||
|
||||
class Node:
|
||||
"""Represent a node used during A* exploration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinate: tuple[int, int],
|
||||
g: int,
|
||||
h: int,
|
||||
f: int,
|
||||
parent: Any,
|
||||
) -> None:
|
||||
"""Initialize a search node.
|
||||
|
||||
Args:
|
||||
coordinate: Coordinates of the node.
|
||||
g: Cost from the start node.
|
||||
h: Heuristic cost to the goal.
|
||||
f: Total estimated cost.
|
||||
parent: Parent node in the reconstructed path.
|
||||
"""
|
||||
self.coordinate = coordinate
|
||||
self.g = g
|
||||
self.h = h
|
||||
self.f = f
|
||||
self.parent = parent
|
||||
|
||||
def __eq__(self, value: object, /) -> bool:
|
||||
"""Compare a node to a coordinate.
|
||||
|
||||
Args:
|
||||
value: Object to compare with.
|
||||
|
||||
Returns:
|
||||
``True`` if the value equals the node coordinate, otherwise
|
||||
``False``.
|
||||
"""
|
||||
return value == self.coordinate
|
||||
|
||||
def __init__(self, start: tuple[int, int], end: tuple[int, int]) -> None:
|
||||
"""Initialize the A* solver.
|
||||
|
||||
Args:
|
||||
start: Start coordinates using 1-based indexing.
|
||||
end: End coordinates using 1-based indexing.
|
||||
"""
|
||||
super().__init__(start, end)
|
||||
|
||||
def h(self, n: tuple[int, int]) -> int:
|
||||
"""Compute the Manhattan distance heuristic to the goal.
|
||||
|
||||
Args:
|
||||
n: Coordinates of the current node.
|
||||
|
||||
Returns:
|
||||
The heuristic distance to the end coordinate.
|
||||
"""
|
||||
return (
|
||||
max(n[0], self.end[0])
|
||||
- min(n[0], self.end[0])
|
||||
+ max(n[1], self.end[1])
|
||||
- min(n[1], self.end[1])
|
||||
)
|
||||
|
||||
def get_paths(
|
||||
self,
|
||||
maze: NDArray[Any],
|
||||
actual: tuple[int, int],
|
||||
close: list["Node"],
|
||||
) -> list[tuple[int, int]]:
|
||||
"""Return all reachable neighboring coordinates.
|
||||
|
||||
Args:
|
||||
maze: Maze grid to inspect.
|
||||
actual: Current coordinate.
|
||||
close: List of already explored nodes.
|
||||
|
||||
Returns:
|
||||
A list of reachable adjacent coordinates not yet closed.
|
||||
"""
|
||||
path = [
|
||||
(
|
||||
(actual[0], actual[1] - 1)
|
||||
if not maze[actual[1]][actual[0]].get_north()
|
||||
and actual[1] > 0
|
||||
and (actual[0], actual[1] - 1)
|
||||
not in [n.coordinate for n in close]
|
||||
else None
|
||||
),
|
||||
(
|
||||
(actual[0] + 1, actual[1])
|
||||
if not maze[actual[1]][actual[0]].get_est()
|
||||
and actual[0] < len(maze[0]) - 1
|
||||
and (actual[0] + 1, actual[1])
|
||||
not in [n.coordinate for n in close]
|
||||
else None
|
||||
),
|
||||
(
|
||||
(actual[0], actual[1] + 1)
|
||||
if not maze[actual[1]][actual[0]].get_south()
|
||||
and actual[1] < len(maze) - 1
|
||||
and (actual[0], actual[1] + 1)
|
||||
not in [n.coordinate for n in close]
|
||||
else None
|
||||
),
|
||||
(
|
||||
(actual[0] - 1, actual[1])
|
||||
if not maze[actual[1]][actual[0]].get_west()
|
||||
and actual[0] > 0
|
||||
and (actual[0] - 1, actual[1])
|
||||
not in [n.coordinate for n in close]
|
||||
else None
|
||||
),
|
||||
]
|
||||
return [p for p in path if p is not None]
|
||||
|
||||
def get_path(self, maze: NDArray[Any]) -> list["Node"]:
|
||||
"""Perform A* exploration until the destination is reached.
|
||||
|
||||
Args:
|
||||
maze: Maze grid to solve.
|
||||
|
||||
Returns:
|
||||
The closed list ending with the goal node.
|
||||
|
||||
Raises:
|
||||
Exception: If no path can be found.
|
||||
"""
|
||||
open: list[AStar.Node] = []
|
||||
close: list[AStar.Node] = []
|
||||
|
||||
open.append(
|
||||
AStar.Node(
|
||||
self.start,
|
||||
0,
|
||||
self.h(self.start),
|
||||
self.h(self.start),
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
while len(open) > 0:
|
||||
to_check = sorted(open, key=lambda x: x.f)[0]
|
||||
open.remove(to_check)
|
||||
close.append(to_check)
|
||||
if to_check.coordinate == self.end:
|
||||
return close
|
||||
paths = self.get_paths(maze, to_check.coordinate, close)
|
||||
for path in paths:
|
||||
open.append(
|
||||
self.Node(
|
||||
path,
|
||||
to_check.g + 1,
|
||||
self.h(path),
|
||||
self.h(path) + to_check.g + 1,
|
||||
to_check,
|
||||
)
|
||||
)
|
||||
raise Exception("Path not found")
|
||||
|
||||
def get_rev_dir(self, current: Node) -> str:
|
||||
"""Determine the direction taken from the parent to the current node.
|
||||
|
||||
Args:
|
||||
current: Current node in the reconstructed path.
|
||||
|
||||
Returns:
|
||||
A cardinal direction letter.
|
||||
|
||||
Raises:
|
||||
Exception: If the parent-child relationship cannot be translated.
|
||||
"""
|
||||
if current.parent.coordinate == (
|
||||
current.coordinate[0],
|
||||
current.coordinate[1] - 1,
|
||||
):
|
||||
return "S"
|
||||
elif current.parent.coordinate == (
|
||||
current.coordinate[0] + 1,
|
||||
current.coordinate[1],
|
||||
):
|
||||
return "W"
|
||||
elif current.parent.coordinate == (
|
||||
current.coordinate[0],
|
||||
current.coordinate[1] + 1,
|
||||
):
|
||||
return "N"
|
||||
elif current.parent.coordinate == (
|
||||
current.coordinate[0] - 1,
|
||||
current.coordinate[1],
|
||||
):
|
||||
return "E"
|
||||
else:
|
||||
raise Exception("Translate error: AStar path not found")
|
||||
|
||||
def translate(self, close: list["Node"]) -> str:
|
||||
"""Translate a node chain into a path string.
|
||||
|
||||
Args:
|
||||
close: Closed list ending with the goal node.
|
||||
|
||||
Returns:
|
||||
A string of direction letters from start to end.
|
||||
"""
|
||||
current = close[-1]
|
||||
res = ""
|
||||
while True:
|
||||
res = self.get_rev_dir(current) + res
|
||||
current = current.parent
|
||||
if current.coordinate == self.start:
|
||||
break
|
||||
return res
|
||||
|
||||
def solve(
|
||||
self, maze: Maze, height: int | None = None, width: int | None = None
|
||||
) -> str:
|
||||
"""Solve the maze using A*.
|
||||
|
||||
Args:
|
||||
maze: The maze to solve.
|
||||
height: Unused optional maze height.
|
||||
width: Unused optional maze width.
|
||||
|
||||
Returns:
|
||||
A string representing the path using cardinal directions.
|
||||
"""
|
||||
|
||||
maze_arr = maze.get_maze()
|
||||
if maze_arr is None:
|
||||
raise Exception("Maze is not initialized")
|
||||
path: list[AStar.Node] = self.get_path(maze_arr)
|
||||
return self.translate(path)
|
||||
|
||||
|
||||
class DepthFirstSearchSolver(MazeSolver):
|
||||
"""Solve a maze using depth-first search with backtracking."""
|
||||
|
||||
def __init__(self, start: tuple[int, int], end: tuple[int, int]):
|
||||
"""Initialize the depth-first search solver.
|
||||
|
||||
Args:
|
||||
start: Start coordinates using 1-based indexing.
|
||||
end: End coordinates using 1-based indexing.
|
||||
"""
|
||||
super().__init__(start, end)
|
||||
|
||||
def solve(
|
||||
self, maze: Maze, height: int | None = None, width: int | None = None
|
||||
) -> str:
|
||||
"""Solve the maze using depth-first search.
|
||||
|
||||
Args:
|
||||
maze: The maze to solve.
|
||||
height: Maze height.
|
||||
width: Maze width.
|
||||
|
||||
Returns:
|
||||
A string representing the path using cardinal directions.
|
||||
|
||||
Raises:
|
||||
Exception: If no path can be found.
|
||||
"""
|
||||
path_str = ""
|
||||
if height is None or width is None:
|
||||
raise Exception("We need Height and Width in the arg")
|
||||
visited: NDArray[Any] = np.zeros((height, width), dtype=bool)
|
||||
path: list[tuple[int, int]] = list()
|
||||
move: list[str] = list()
|
||||
maze_s = maze.get_maze()
|
||||
if maze_s is None:
|
||||
raise Exception("Maze is not initializef")
|
||||
coord = self.start
|
||||
h_w: tuple[int, int] = (height, width)
|
||||
while coord != self.end:
|
||||
visited[coord] = True
|
||||
path.append(coord)
|
||||
rand_p: list[str] = self.random_path(visited, coord, maze_s, h_w)
|
||||
|
||||
if not rand_p:
|
||||
path, move = self.back_on_step(
|
||||
path, visited, maze_s, h_w, move
|
||||
)
|
||||
if not path:
|
||||
break
|
||||
coord = path[-1]
|
||||
rand_p = self.random_path(visited, coord, maze_s, h_w)
|
||||
next = self.next_path(rand_p)
|
||||
move.append(next)
|
||||
coord = self.next_cell(coord, next)
|
||||
for m in move:
|
||||
path_str += m
|
||||
if not path:
|
||||
raise Exception("Path not found")
|
||||
return path_str
|
||||
|
||||
@staticmethod
|
||||
def random_path(
|
||||
visited: NDArray[Any],
|
||||
coord: tuple[int, int],
|
||||
maze: NDArray[Any],
|
||||
h_w: tuple[int, int],
|
||||
) -> list[str]:
|
||||
"""Return all valid unvisited directions from the current cell.
|
||||
|
||||
Args:
|
||||
visited: Boolean array marking visited cells.
|
||||
coord: Current coordinate.
|
||||
maze: Maze grid to inspect.
|
||||
h_w: Tuple containing maze height and width.
|
||||
|
||||
Returns:
|
||||
A list of valid direction letters.
|
||||
"""
|
||||
random_p = []
|
||||
h, w = h_w
|
||||
y, x = coord
|
||||
|
||||
if y - 1 >= 0 and not maze[y][x].get_north() and not visited[y - 1][x]:
|
||||
random_p.append("N")
|
||||
|
||||
if y + 1 < h and not maze[y][x].get_south() and not visited[y + 1][x]:
|
||||
random_p.append("S")
|
||||
|
||||
if x - 1 >= 0 and not maze[y][x].get_west() and not visited[y][x - 1]:
|
||||
random_p.append("W")
|
||||
|
||||
if x + 1 < w and not maze[y][x].get_est() and not visited[y][x + 1]:
|
||||
random_p.append("E")
|
||||
return random_p
|
||||
|
||||
@staticmethod
|
||||
def next_path(rand_path: list[str]) -> str:
|
||||
"""Select the next move at random.
|
||||
|
||||
Args:
|
||||
rand_path: List of available directions.
|
||||
|
||||
Returns:
|
||||
A randomly selected direction.
|
||||
"""
|
||||
|
||||
return random.choice(rand_path)
|
||||
|
||||
@staticmethod
|
||||
def back_on_step(
|
||||
path: list[tuple[int, int]],
|
||||
visited: NDArray[Any],
|
||||
maze: NDArray[Any],
|
||||
h_w: tuple[int, int],
|
||||
move: list[str],
|
||||
) -> tuple[list[Any], list[Any]]:
|
||||
"""Backtrack until a cell with an unexplored path is found.
|
||||
|
||||
Args:
|
||||
path: Current path of visited coordinates.
|
||||
visited: Boolean array marking visited cells.
|
||||
maze: Maze grid to inspect.
|
||||
h_w: Tuple containing maze height and width.
|
||||
move: List of moves made so far.
|
||||
|
||||
Returns:
|
||||
A tuple containing the updated path and move list.
|
||||
"""
|
||||
|
||||
while path:
|
||||
last = path[-1]
|
||||
if DepthFirstSearchSolver.random_path(visited, last, maze, h_w):
|
||||
break
|
||||
path.pop()
|
||||
move.pop()
|
||||
return path, move
|
||||
|
||||
@staticmethod
|
||||
def next_cell(coord: tuple[int, int], next: str) -> tuple[int, int]:
|
||||
"""Return the coordinates of the next cell in the given direction.
|
||||
|
||||
Args:
|
||||
coord: Current coordinate.
|
||||
next: Direction to move.
|
||||
|
||||
Returns:
|
||||
The coordinates of the next cell.
|
||||
"""
|
||||
y, x = coord
|
||||
next_step = {"N": (-1, 0), "S": (1, 0), "W": (0, -1), "E": (0, 1)}
|
||||
add_y, add_x = next_step[next]
|
||||
return (y + add_y, x + add_x)
|
||||
@@ -0,0 +1,18 @@
|
||||
from mazegen.Cell import Cell
|
||||
from mazegen.Maze import Maze
|
||||
from mazegen.MazeGenerator import MazeGenerator, DepthFirstSearch
|
||||
from mazegen.MazeGenerator import Kruskal
|
||||
from mazegen.MazeSolver import MazeSolver, AStar, DepthFirstSearchSolver
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "us"
|
||||
__all__ = [
|
||||
"Cell",
|
||||
"Maze",
|
||||
"MazeGenerator",
|
||||
"DepthFirstSearchSolver",
|
||||
"MazeSolver",
|
||||
"AStar",
|
||||
"Kruskal",
|
||||
"DepthFirstSearch",
|
||||
]
|
||||
Reference in New Issue
Block a user