26 Commits

Author SHA1 Message Date
da7e 68c40be144 add(docstring): doc string on every class and functions 2026-04-01 12:34:19 +02:00
maoake 40e25757c7 starting mypy with maze 2026-03-31 22:31:48 +02:00
maoake b1eda06fa5 fixing flake8 2026-03-31 22:01:45 +02:00
maoake 769198c06b adding the blink on the 42 2026-03-31 21:03:10 +02:00
maoake 2c7b565137 give a checkpoint to the project blink the 42 2026-03-31 20:29:01 +02:00
maoake d23959ce74 fix conflict 2026-03-31 20:17:08 +02:00
maoake 4cb678b5be something is up 2026-03-31 19:59:09 +02:00
da7e b520210d58 fix(MazeMLX): margin calculation, big maze are now display fully 2026-03-30 16:36:52 +02:00
da7e bdb1056d69 fix(AmazMLX): draw_ft margin 2026-03-30 15:57:16 +02:00
da7e b2aa93e04d add color to put block 2026-03-30 15:47:39 +02:00
da7e 56ebb2823a code refactor(AmazMLX) 2026-03-30 15:45:15 +02:00
da7e 150eaedc94 Merge branch 'main' of github.com:maoakeEnterprise/amazing 2026-03-30 15:41:35 +02:00
da7e 6f4699c29f wip(entry exit) 2026-03-30 15:37:45 +02:00
Maoake Teriierooiterai 5913f5267d trying to get the blink on the 42 2026-03-30 15:36:52 +02:00
Maoake Teriierooiterai d4251dc8b7 fixing the conflict 2026-03-30 14:47:16 +02:00
Maoake Teriierooiterai 282fbd6867 poop the conflict 2026-03-30 14:39:05 +02:00
da7e 0f77e0c6e4 fix buffer overflow in put pixel + margin calculation 2026-03-30 14:37:33 +02:00
Maoake Teriierooiterai cfac4bed25 need to add the color 2026-03-30 13:53:14 +02:00
Maoake Teriierooiterai cd3c75fb1e set up the path print with the button 2026-03-30 12:01:23 +02:00
Maoake Teriierooiterai 628bb8a94b put the functions color and need to refactor the code 2026-03-30 08:26:53 +02:00
mteriier dc19b526fa testing colors on the project cause we need to test it out 2026-03-29 23:35:42 +02:00
Maoake Teriierooiterai 68d710e313 color 42 2026-03-29 18:47:29 +02:00
da7e 92c6237f06 fix(astar): the actual astar wasn't the real astar algoritm 2026-03-29 15:38:40 +02:00
Maoake Teriierooiterai b682274102 opti path 2026-03-29 14:31:04 +02:00
da7e fa38f7a311 Merge branch 'mlx' 2026-03-27 21:53:06 +01:00
da7e 16d97e9912 fix(astar): function f() miscalculate the best path 2026-03-27 21:51:49 +01:00
10 changed files with 1140 additions and 254 deletions
+354 -85
View File
@@ -3,15 +3,24 @@ from src.AMazeIng import AMazeIng
from src.parsing import Parsing from src.parsing import Parsing
from mlx import Mlx from mlx import Mlx
import numpy as np import numpy as np
import math
import time import time
class MazeMLX: class MazeMLX:
"""Render, animate, and interact with a maze using an MLX window."""
def __init__(self, height: int, width: int) -> None: def __init__(self, height: int, width: int) -> None:
"""Initialize the MLX renderer and create the window and image buffer.
Args:
height: Height of the rendering area in pixels.
width: Width of the rendering area in pixels.
"""
self.mlx = Mlx() self.mlx = Mlx()
self.height = height self.height = height
self.width = width self.width = width
self.print_path = False
self.color = [0x00, 0x00, 0xFF, 0xFF]
self.mlx_ptr = self.mlx.mlx_init() self.mlx_ptr = self.mlx.mlx_init()
self.win_ptr = self.mlx.mlx_new_window( self.win_ptr = self.mlx.mlx_new_window(
self.mlx_ptr, width, height + 200, "A-Maze-Ing" self.mlx_ptr, width, height + 200, "A-Maze-Ing"
@@ -24,9 +33,23 @@ class MazeMLX:
self.generator = None self.generator = None
def close(self) -> None: def close(self) -> None:
"""Destroy the image used by the renderer."""
self.mlx.mlx_destroy_image(self.mlx_ptr, self.img_ptr) self.mlx.mlx_destroy_image(self.mlx_ptr, self.img_ptr)
def close_loop(self, _: Any):
"""Stop the MLX event loop.
Args:
_: Unused callback argument.
"""
self.mlx.mlx_loop_exit(self.mlx_ptr)
def clear_image(self) -> None:
"""Clear the image buffer."""
self.buf[:] = b"\x00" * len(self.buf)
def redraw_image(self) -> None: def redraw_image(self) -> None:
"""Redraw the window contents and display the control help text."""
self.mlx.mlx_clear_window(self.mlx_ptr, self.win_ptr) self.mlx.mlx_clear_window(self.mlx_ptr, self.win_ptr)
self.mlx.mlx_put_image_to_window( self.mlx.mlx_put_image_to_window(
self.mlx_ptr, self.win_ptr, self.img_ptr, 0, 0 self.mlx_ptr, self.win_ptr, self.img_ptr, 0, 0
@@ -40,47 +63,146 @@ class MazeMLX:
"1: regen; 2: path; 3: color; 4: quit;", "1: regen; 2: path; 3: color; 4: quit;",
) )
def put_pixel(self, x, y) -> None: def put_pixel(self, x, y, color: list | None = None) -> None:
"""Draw a single pixel into the image buffer.
Args:
x: Horizontal pixel position.
y: Vertical pixel position.
color: Optional RGBA color list. If omitted, the current renderer
color is used.
"""
if x < 0 or y < 0 or x >= self.width or y >= self.height:
return
offset = y * self.size_line + x * (self.bpp // 8) offset = y * self.size_line + x * (self.bpp // 8)
self.buf[offset + 0] = 0xFF if color:
self.buf[offset + 1] = 0xFF self.buf[offset + 0] = color[0]
self.buf[offset + 2] = 0xFF self.buf[offset + 1] = color[1]
if self.bpp >= 32: self.buf[offset + 2] = color[2]
self.buf[offset + 3] = 0xFF if self.bpp >= 32:
self.buf[offset + 3] = color[3]
else:
self.buf[offset + 0] = self.color[0]
self.buf[offset + 1] = self.color[1]
self.buf[offset + 2] = self.color[2]
if self.bpp >= 32:
self.buf[offset + 3] = self.color[3]
def clear_image(self) -> None: def put_line(
self.buf[:] = b"\x00" * len(self.buf) self,
start: tuple[int, int],
end: tuple[int, int],
color: list | None = None,
) -> None:
"""Draw a horizontal or vertical line.
def put_line(self, start: tuple[int, int], end: tuple[int, int]) -> None: Args:
start: Starting pixel coordinates.
end: Ending pixel coordinates.
color: Optional RGBA color list.
"""
sx, sy = start sx, sy = start
ex, ey = end ex, ey = end
if sy == ey: if sy == ey:
for x in range(min(sx, ex), max(sx, ex) + 1): for x in range(min(sx, ex), max(sx, ex) + 1):
self.put_pixel(x, sy) self.put_pixel(x, sy, color)
if sx == ex: if sx == ex:
for y in range(min(sy, ey), max(sy, ey) + 1): for y in range(min(sy, ey), max(sy, ey) + 1):
self.put_pixel(sx, y) self.put_pixel(sx, y, color)
def put_block(
self,
ul: tuple[int, int],
dr: tuple[int, int],
color: list | None = None,
) -> None:
"""Draw a filled rectangular block.
Args:
ul: Upper-left corner coordinates.
dr: Lower-right corner coordinates.
color: Optional RGBA color list.
"""
for y in range(min(ul[1], dr[1]), max(dr[1], ul[1])):
self.put_line(
(min(ul[0], dr[0]), y), (max(ul[0], dr[0]), y), color
)
@staticmethod
def random_color_ft() -> Any:
"""Yield colors in a repeating sequence for the reserved pattern.
Yields:
RGBA color lists.
"""
colors = [
[0xFF, 0xBF, 0x00, 0xFF], # blue
[0x00, 0xFF, 0x40, 0xFF], # green
[0xFF, 0x00, 0xFF, 0xFF], # pink
[0x00, 0xFF, 0xFF, 0xFF], # yellow
]
while True:
for color in colors:
yield color
@staticmethod
def random_color() -> Any:
"""Yield colors in a repeating sequence for maze rendering.
Yields:
RGBA color lists.
"""
colors = [
[0xFF, 0x00, 0xFF, 0xFF], # pink
[0x00, 0xFF, 0xFF, 0xFF], # yellow
[0x00, 0xFF, 0x40, 0xFF], # green
[0xFF, 0xBF, 0x00, 0xFF], # blue
[0xFF, 0x00, 0x80, 0xFF], # purple
[0x00, 0x00, 0xFF, 0xFF], # red
]
while True:
for color in colors:
yield color
def get_margin_line_len(self, maze: np.ndarray) -> tuple[int, int, int]:
"""Compute the cell size and margins for centering the maze.
Args:
maze: Maze grid to render.
Returns:
A tuple containing the cell side length, horizontal margin, and
vertical margin.
"""
rows = len(maze)
cols = len(maze[0])
line_len = min(self.width // cols, self.height // rows) - 1
maze_width = cols * line_len
maze_height = rows * line_len
margin_x = ((self.width - maze_width) // 2) + 1
margin_y = ((self.height - maze_height) // 2) + 1
return (line_len, margin_x, margin_y)
def update_maze(self, maze: np.ndarray) -> None: def update_maze(self, maze: np.ndarray) -> None:
"""Render the maze walls into the image buffer.
Args:
maze: Maze grid to render.
"""
self.clear_image() self.clear_image()
margin = math.trunc(
math.sqrt(self.width if self.width > self.height else self.height) line_len, margin_x, margin_y = self.get_margin_line_len(maze)
// 2
)
line_len = math.trunc(
(
(self.height - margin) // len(maze)
if self.height > self.width
else (self.width - margin) // len(maze[0])
)
)
for y in range(len(maze)): for y in range(len(maze)):
for x in range(len(maze[0])): for x in range(len(maze[0])):
x0 = x * line_len + margin x0 = x * line_len + margin_x
y0 = y * line_len + margin y0 = y * line_len + margin_y
x1 = x * line_len + line_len + margin x1 = x * line_len + line_len + margin_x
y1 = y * line_len + line_len + margin y1 = y * line_len + line_len + margin_y
if maze[y][x].get_north(): if maze[y][x].get_north():
self.put_line((x0, y0), (x1, y0)) self.put_line((x0, y0), (x1, y0))
@@ -90,13 +212,17 @@ class MazeMLX:
self.put_line((x0, y1), (x1, y1)) self.put_line((x0, y1), (x1, y1))
if maze[y][x].get_west(): if maze[y][x].get_west():
self.put_line((x0, y0), (x0, y1)) self.put_line((x0, y0), (x0, y1))
self.redraw_image()
def put_block(self, ul: tuple[int, int], dr: tuple[int, int]) -> None: def put_path(self, amazing: AMazeIng) -> Any:
for y in range(min(ul[1], dr[1]), max(dr[1], ul[1])): """Animate the solution path inside the maze.
self.put_line((min(ul[0], dr[0]), y), (max(ul[0], dr[0]), y))
def put_path(self, amazing: AMazeIng): Args:
amazing: Maze container with generation and solving logic.
Yields:
Control after each path segment so the animation can be rendered
progressively.
"""
path = amazing.solve_path() path = amazing.solve_path()
print(path) print(path)
actual = amazing.entry actual = amazing.entry
@@ -104,33 +230,23 @@ class MazeMLX:
maze = amazing.maze.get_maze() maze = amazing.maze.get_maze()
if maze is None: if maze is None:
return return
margin = math.trunc(
math.sqrt(self.width if self.width > self.height else self.height) line_len, margin_x, margin_y = self.get_margin_line_len(maze)
// 2
)
cell_size = math.trunc(
(
(self.height - margin) // len(maze)
if self.height > self.width
else (self.width - margin) // len(maze[0])
)
)
self.update_maze(maze)
for i in range(len(path)): for i in range(len(path)):
ul = ( ul = (
(actual[0]) * cell_size + margin + 12, (actual[0]) * line_len + margin_x + 12,
(actual[1]) * cell_size + 12 + margin, (actual[1]) * line_len + 12 + margin_y,
) )
dr = ( dr = (
(actual[0]) * cell_size + cell_size + margin - 12, (actual[0]) * line_len + line_len + margin_x - 12,
(actual[1]) * cell_size + cell_size - 12 + margin, (actual[1]) * line_len + line_len - 12 + margin_y,
) )
self.put_block(ul, dr) self.put_block(ul, dr)
self.redraw_image() x0 = actual[0] * line_len + margin_x + 12
x0 = actual[0] * cell_size + margin + 12 y0 = actual[1] * line_len + margin_y + 12
y0 = actual[1] * cell_size + margin + 12 x1 = actual[0] * line_len + line_len + margin_x - 12
x1 = actual[0] * cell_size + cell_size + margin - 12 y1 = actual[1] * line_len + line_len + margin_y - 12
y1 = actual[1] * cell_size + cell_size + margin - 12
yield yield
match path[i]: match path[i]:
case "N": case "N":
@@ -146,64 +262,217 @@ class MazeMLX:
self.put_block((x0, y0), (x0 - 24, y1)) self.put_block((x0, y0), (x0 - 24, y1))
actual = (actual[0] - 1, actual[1]) actual = (actual[0] - 1, actual[1])
ul = ( ul = (
(actual[0]) * cell_size + margin + 12, (actual[0]) * line_len + margin_x + 12,
(actual[1]) * cell_size + 12 + margin, (actual[1]) * line_len + 12 + margin_y,
) )
dr = ( dr = (
(actual[0]) * cell_size + cell_size + margin - 12, (actual[0]) * line_len + line_len + margin_x - 12,
(actual[1]) * cell_size + cell_size - 12 + margin, (actual[1]) * line_len + line_len - 12 + margin_y,
) )
self.put_block(ul, dr) self.put_block(ul, dr)
self.redraw_image()
return return
def close_loop(self, _: Any): def put_start_end(self, amazing: AMazeIng):
self.mlx.mlx_loop_exit(self.mlx_ptr) """Draw highlighted blocks for the maze entry and exit.
Args:
amazing: Maze container with current maze data.
"""
entry = amazing.entry
exit = amazing.exit
maze = amazing.maze.get_maze()
if maze is None:
return
line_len, margin_x, margin_y = self.get_margin_line_len(maze)
ul = (
(entry[0] - 1) * line_len + margin_x + 3,
(entry[1] - 1) * line_len + 3 + margin_y,
)
dr = (
(entry[0] - 1) * line_len + line_len + margin_x - 3,
(entry[1] - 1) * line_len + line_len - 3 + margin_y,
)
self.put_block(ul, dr, [0xFF, 0xBF, 0x00, 0x9F])
ul = (
(exit[0] - 1) * line_len + margin_x + 3,
(exit[1] - 1) * line_len + 3 + margin_y,
)
dr = (
(exit[0] - 1) * line_len + line_len + margin_x - 3,
(exit[1] - 1) * line_len + line_len - 3 + margin_y,
)
self.put_block(ul, dr, [0x00, 0xFF, 0x40, 0x9F])
def draw_ft(self, maze: np.ndarray, color: list | None = None):
"""Draw filled cells corresponding to the reserved fully walled pattern.
Args:
maze: Maze grid to inspect.
color: Optional RGBA color list.
"""
line_len, margin_x, margin_y = self.get_margin_line_len(maze)
for y in range(len(maze)):
for x in range(len(maze[0])):
if maze[y][x].value == 15:
x0 = x * line_len + margin_x
y0 = y * line_len + margin_y
x1 = x * line_len + line_len + margin_x
y1 = y * line_len + line_len + margin_y
self.put_block((x0, y0), (x1, y1), color)
def draw_image(self, amazing: AMazeIng) -> None:
"""Main rendering callback used by the MLX loop.
Args:
amazing: Maze container to render.
"""
if self.render_maze(amazing):
if self.path_printer and self.print_path:
if self.render_path():
color = next(self.color_gen_ft)
self.draw_ft(amazing.maze.get_maze(), color)
next(self.timer_gen)
else:
self.time_gen()
self.update_maze(amazing.maze.get_maze())
self.draw_ft(amazing.maze.get_maze())
self.put_start_end(amazing)
self.redraw_image()
def shift_color(self):
"""Reset the maze color generator."""
self.color_gen = self.random_color()
def shift_color_ft(self):
"""Reset the reserved-pattern color generator."""
self.color_gen_ft = self.random_color_ft()
def time_gen(self):
"""Reset the timing generator used for animation pacing."""
self.timer_gen = self.time_generator()
def restart_maze(self, amazing: AMazeIng) -> None:
"""Restart maze generation.
Args:
amazing: Maze container providing the generation generator.
"""
self.generator = amazing.generate()
def time_generator(self) -> Any:
"""Yield regularly with a fixed delay for animation timing.
Yields:
``None`` at each step after sleeping.
"""
yield
while True:
time.sleep(0.3)
yield
def restart_path(self, amazing: AMazeIng) -> None:
"""Restart solution path animation.
Args:
amazing: Maze container providing the solution path.
"""
self.path_printer = self.put_path(amazing)
def render_path(self) -> bool:
"""Advance the path animation by one step.
Returns:
``True`` if the path animation is complete, otherwise ``False``.
"""
try:
next(self.path_printer)
time.sleep(0.03)
return False
except StopIteration:
pass
return True
def render_maze(self, amazing: AMazeIng) -> bool:
"""Advance maze generation by one step and redraw it.
Args:
amazing: Maze container being generated.
Returns:
``True`` if maze generation is complete, otherwise ``False``.
"""
try:
next(self.generator)
self.update_maze(amazing.maze.get_maze())
return False
except StopIteration:
pass
return True
def handle_key_press(self, keycode: int, amazing: AMazeIng) -> None: def handle_key_press(self, keycode: int, amazing: AMazeIng) -> None:
"""Handle keyboard input for one keycode mapping.
Args:
keycode: Key code received from MLX.
amazing: Maze container to update or render.
"""
if keycode == 49: if keycode == 49:
self.restart_maze(amazing) self.restart_maze(amazing)
self.print_path = False
if keycode == 50: if keycode == 50:
self.restart_path(amazing) self.restart_path(amazing)
self.print_path = True if self.print_path is False else False
if keycode == 51: if keycode == 51:
pass self.print_path = False
self.color = next(self.color_gen)
if keycode == 52: if keycode == 52:
self.close_loop(None) self.close_loop(None)
def handle_key_press_mteriier(
self, keycode: int, amazing: AMazeIng
) -> None:
"""Handle keyboard input for an alternative keycode mapping.
Args:
keycode: Key code received from MLX.
amazing: Maze container to update or render.
"""
if keycode == 38:
self.restart_maze(amazing)
self.print_path = False
if keycode == 233:
self.restart_path(amazing)
self.print_path = True if self.print_path is False else False
if keycode == 34:
self.print_path = False
self.color = next(self.color_gen)
if keycode == 39:
self.close_loop(None)
def start(self, amazing: AMazeIng) -> None: def start(self, amazing: AMazeIng) -> None:
"""Start the MLX rendering loop.
Args:
amazing: Maze container to generate, solve, and display.
"""
self.restart_maze(amazing) self.restart_maze(amazing)
self.restart_path(amazing) self.shift_color()
self.mlx.mlx_loop_hook(self.mlx_ptr, self.render_maze, amazing) self.shift_color_ft()
self.time_gen()
self.mlx.mlx_loop_hook(self.mlx_ptr, self.draw_image, amazing)
self.mlx.mlx_hook(self.win_ptr, 33, 0, self.close_loop, None) self.mlx.mlx_hook(self.win_ptr, 33, 0, self.close_loop, None)
self.mlx.mlx_hook( self.mlx.mlx_hook(
self.win_ptr, 2, 1 << 0, self.handle_key_press, amazing self.win_ptr, 2, 1 << 0, self.handle_key_press, amazing
) )
self.mlx.mlx_loop(self.mlx_ptr) self.mlx.mlx_loop(self.mlx_ptr)
def restart_maze(self, amazing: AMazeIng) -> None:
self.generator = amazing.generate()
def restart_path(self, amazing: AMazeIng) -> None:
self.path_printer = self.put_path(amazing)
def render_path(self):
try:
next(self.path_printer)
time.sleep(0.03)
except StopIteration:
pass
def render_maze(self, amazing: AMazeIng):
try:
next(self.generator)
self.update_maze(amazing.maze.get_maze())
# time.sleep(0.01)
except StopIteration:
if self.path_printer is not None:
self.render_path()
def main() -> None: def main() -> None:
"""Run the maze application."""
mlx = None mlx = None
try: try:
mlx = MazeMLX(1000, 1000) mlx = MazeMLX(1000, 1000)
+3 -3
View File
@@ -1,7 +1,7 @@
WIDTH=11 WIDTH=10
HEIGHT=11 HEIGHT=10
ENTRY=1,1 ENTRY=1,1
EXIT=11,11 EXIT=10,10
OUTPUT_FILE=maze.txt OUTPUT_FILE=maze.txt
PERFECT=True PERFECT=True
GENERATOR=Kruskal GENERATOR=Kruskal
+30
View File
@@ -6,6 +6,8 @@ from src.amaz_lib import Maze, MazeGenerator, MazeSolver
class AMazeIng(BaseModel): class AMazeIng(BaseModel):
"""Represent a complete maze configuration, generation, and solving setup."""
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
width: int = Field(ge=4) width: int = Field(ge=4)
@@ -20,6 +22,14 @@ class AMazeIng(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def check_entry_exit(self) -> Self: def check_entry_exit(self) -> Self:
"""Validate that entry and exit coordinates fit within maze bounds.
Returns:
The validated model instance.
Raises:
ValueError: If entry or exit coordinates exceed maze dimensions.
"""
if self.entry[0] > self.width or self.entry[1] > self.height: if self.entry[0] > self.width or self.entry[1] > self.height:
raise ValueError("Entry coordinates exceed the maze size") raise ValueError("Entry coordinates exceed the maze size")
if self.exit[0] > self.width or self.exit[1] > self.height: if self.exit[0] > self.width or self.exit[1] > self.height:
@@ -27,15 +37,35 @@ class AMazeIng(BaseModel):
return self return self
def generate(self) -> Generator[Maze, None, None]: def generate(self) -> Generator[Maze, None, None]:
"""Generate the maze step by step.
The internal maze state is updated at each generation step.
Yields:
The current maze state after each generation step.
"""
for array in self.generator.generator(self.height, self.width): for array in self.generator.generator(self.height, self.width):
self.maze.set_maze(array) self.maze.set_maze(array)
yield self.maze yield self.maze
return return
def solve_path(self) -> str: def solve_path(self) -> str:
"""Solve the current maze and return the path string.
Returns:
A string of direction letters representing the solution path.
"""
return self.solver.solve(self.maze, self.height, self.width) return self.solver.solve(self.maze, self.height, self.width)
def __str__(self) -> str: def __str__(self) -> str:
"""Return a string representation of the maze and its solution.
The output includes the maze, entry coordinates, exit coordinates, and
the computed solution path.
Returns:
A formatted string representation of the maze data.
"""
res = self.maze.__str__() res = self.maze.__str__()
res += "\n" res += "\n"
res += f"{self.entry[0]},{self.entry[1]}\n" res += f"{self.entry[0]},{self.entry[1]}\n"
+72
View File
@@ -3,50 +3,122 @@ from dataclasses import dataclass
@dataclass @dataclass
class Cell: 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: 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 self.value = value
def __str__(self) -> str: 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() return hex(self.value).removeprefix("0x").upper()
def set_value(self, value: int) -> None: 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 self.value = value
def get_value(self) -> int: def get_value(self) -> int:
"""Return the encoded value of the cell.
Returns:
The integer bitmask representing the cell walls.
"""
return self.value return self.value
def set_north(self, is_wall: bool) -> None: 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 ( if (not is_wall and self.value | 14 == 15) or (
is_wall and self.value | 14 != 15 is_wall and self.value | 14 != 15
): ):
self.value = self.value ^ (1) self.value = self.value ^ (1)
def get_north(self) -> bool: 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 return self.value & 1 == 1
def set_est(self, is_wall: bool) -> None: 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 ( if (not is_wall and self.value | 13 == 15) or (
is_wall and self.value | 13 != 15 is_wall and self.value | 13 != 15
): ):
self.value = self.value ^ (2) self.value = self.value ^ (2)
def get_est(self) -> bool: 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 return self.value & 2 == 2
def set_south(self, is_wall: bool) -> None: 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 ( if (not is_wall and self.value | 11 == 15) or (
is_wall and self.value | 11 != 15 is_wall and self.value | 11 != 15
): ):
self.value = self.value ^ (4) self.value = self.value ^ (4)
def get_south(self) -> bool: 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 return self.value & 4 == 4
def set_west(self, is_wall: bool) -> None: 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 ( if (not is_wall and self.value | 7 == 15) or (
is_wall and self.value | 7 != 15 is_wall and self.value | 7 != 15
): ):
self.value = self.value ^ (8) self.value = self.value ^ (8)
def get_west(self) -> bool: 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 return self.value & 8 == 8
+35 -5
View File
@@ -1,19 +1,41 @@
from dataclasses import dataclass from dataclasses import dataclass
from numpy.typing import NDArray
import numpy from typing import Optional, Any
@dataclass @dataclass
class Maze: class Maze:
maze: numpy.ndarray """Represent a maze as a two-dimensional array of cells."""
def get_maze(self) -> numpy.ndarray | None: 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 return self.maze
def set_maze(self, new_maze: numpy.ndarray) -> None: 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 self.maze = new_maze
def __str__(self) -> str: 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: if self.maze is None:
return "None" return "None"
res = "" res = ""
@@ -24,6 +46,14 @@ class Maze:
return res return res
def ascii_print(self) -> None: 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]: for cell in self.maze[0]:
print("_", end="") print("_", end="")
if cell.get_north(): if cell.get_north():
+246 -26
View File
@@ -6,7 +6,16 @@ import math
class MazeGenerator(ABC): class MazeGenerator(ABC):
"""Define the common interface and helpers for maze generators."""
def __init__(self, start: tuple, end: tuple, perfect: bool) -> None: def __init__(self, start: tuple, end: tuple, 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.start = (start[0] - 1, start[1] - 1)
self.end = (end[0] - 1, end[1] - 1) self.end = (end[0] - 1, end[1] - 1)
self.perfect = perfect self.perfect = perfect
@@ -14,10 +23,33 @@ class MazeGenerator(ABC):
@abstractmethod @abstractmethod
def generator( def generator(
self, height: int, width: int, seed: int | None = None self, height: int, width: int, seed: int | None = None
) -> Generator[np.ndarray, None, np.ndarray]: ... ) -> Generator[np.ndarray, None, np.ndarray]:
"""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 @staticmethod
def get_cell_ft(width: int, height: int) -> set: def get_cell_ft(width: int, height: int) -> set:
"""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() forty_two = set()
y, x = (int(height / 2), int(width / 2)) y, x = (int(height / 2), int(width / 2))
forty_two.add((y, x - 1)) forty_two.add((y, x - 1))
@@ -41,23 +73,35 @@ class MazeGenerator(ABC):
return forty_two return forty_two
@staticmethod @staticmethod
def unperfect_maze(width: int, height: int, def unperfect_maze(
maze: np.ndarray, forty_two: set | None, width: int,
prob: float = 0.1 height: int,
) -> Generator[np.ndarray, None, np.ndarray]: maze: np.ndarray,
directions = { forty_two: set | None,
"N": (0, -1), prob: float = 0.1,
"S": (0, 1), ) -> Generator[np.ndarray, None, np.ndarray]:
"W": (-1, 0), """Add extra openings to transform a perfect maze into an imperfect one.
"E": (1, 0)
}
reverse = { Random walls are removed while optionally preserving the reserved
"N": "S", ``forty_two`` area.
"S": "N",
"W": "E", Args:
"E": "W" 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 min_break = 2
while True: while True:
count = 0 count = 0
@@ -68,8 +112,7 @@ class MazeGenerator(ABC):
for direc, (dx, dy) in directions.items(): for direc, (dx, dy) in directions.items():
nx, ny = x + dx, y + dy nx, ny = x + dx, y + dy
if forty_two and ( if forty_two and (
(y, x) in forty_two (y, x) in forty_two or (ny, nx) in forty_two
or (ny, nx) in forty_two
): ):
continue continue
if not (0 <= nx < width and 0 < ny < height): if not (0 <= nx < width and 0 < ny < height):
@@ -81,9 +124,10 @@ class MazeGenerator(ABC):
cell = maze[y][x] cell = maze[y][x]
cell_n = maze[ny][nx] cell_n = maze[ny][nx]
cell = DepthFirstSearch.broken_wall(cell, direc) cell = DepthFirstSearch.broken_wall(cell, direc)
cell_n = DepthFirstSearch.broken_wall(cell_n, cell_n = DepthFirstSearch.broken_wall(
reverse[ cell_n,
direc]) reverse[direc],
)
maze[y][x] = cell maze[y][x] = cell
maze[ny][nx] = cell_n maze[ny][nx] = cell_n
yield maze yield maze
@@ -93,19 +137,45 @@ class MazeGenerator(ABC):
class Kruskal(MazeGenerator): class Kruskal(MazeGenerator):
"""Generate a maze using a Kruskal-based algorithm."""
class Set: class Set:
"""Represent a connected component of maze cells."""
def __init__(self, cells: list[int]) -> None: 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 self.cells: list[int] = cells
class Sets: class Sets:
"""Store all connected components used during generation."""
def __init__(self, sets: list[Set]) -> None: def __init__(self, sets: list[Set]) -> None:
"""Initialize the collection of connected components.
Args:
sets: List of disjoint cell sets.
"""
self.sets = sets self.sets = sets
@staticmethod @staticmethod
def walls_to_maze( def walls_to_maze(
walls: np.ndarray, height: int, width: int walls: np.ndarray, height: int, width: int
) -> np.ndarray: ) -> np.ndarray:
"""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: np.ndarray = np.array( maze: np.ndarray = np.array(
[[Cell(value=0) for _ in range(width)] for _ in range(height)] [[Cell(value=0) for _ in range(width)] for _ in range(height)]
) )
@@ -132,6 +202,15 @@ class Kruskal(MazeGenerator):
@staticmethod @staticmethod
def is_in_same_set(sets: Sets, wall: tuple[int, int]) -> bool: 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 a, b = wall
for set in sets.sets: for set in sets.sets:
if a in set.cells and b in set.cells: if a in set.cells and b in set.cells:
@@ -142,6 +221,15 @@ class Kruskal(MazeGenerator):
@staticmethod @staticmethod
def merge_sets(sets: Sets, wall: tuple[int, int]) -> None: 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 a, b = wall
base_set = None base_set = None
for i in range(len(sets.sets)): for i in range(len(sets.sets)):
@@ -163,6 +251,17 @@ class Kruskal(MazeGenerator):
wall: tuple[int, int], wall: tuple[int, int],
cells_ft: None | set[tuple[int, int]], cells_ft: None | set[tuple[int, int]],
) -> bool: ) -> 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: if cells_ft is None:
return False return False
s1 = (math.trunc(wall[0] / width), wall[0] % width) s1 = (math.trunc(wall[0] / width), wall[0] % width)
@@ -172,6 +271,19 @@ class Kruskal(MazeGenerator):
def generator( def generator(
self, height: int, width: int, seed: int | None = None self, height: int, width: int, seed: int | None = None
) -> Generator[np.ndarray, None, np.ndarray]: ) -> Generator[np.ndarray, None, np.ndarray]:
"""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 cells_ft = None
if height > 10 and width > 10: if height > 10 and width > 10:
cells_ft = self.get_cell_ft(width, height) cells_ft = self.get_cell_ft(width, height)
@@ -208,8 +320,7 @@ class Kruskal(MazeGenerator):
print(f"nb sets: {len(sets.sets)}") print(f"nb sets: {len(sets.sets)}")
maze = self.walls_to_maze(walls, height, width) maze = self.walls_to_maze(walls, height, width)
if self.perfect is False: if self.perfect is False:
gen = Kruskal.unperfect_maze(width, height, maze, gen = Kruskal.unperfect_maze(width, height, maze, cells_ft)
cells_ft)
for res in gen: for res in gen:
maze = res maze = res
yield maze yield maze
@@ -217,7 +328,16 @@ class Kruskal(MazeGenerator):
class DepthFirstSearch(MazeGenerator): class DepthFirstSearch(MazeGenerator):
"""Generate a maze using a depth-first search backtracking algorithm."""
def __init__(self, start: bool, end: bool, perfect: bool) -> None: def __init__(self, start: bool, end: bool, 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.start = (start[0] - 1, start[1] - 1)
self.end = (end[0] - 1, end[1] - 1) self.end = (end[0] - 1, end[1] - 1)
self.perfect = perfect self.perfect = perfect
@@ -226,6 +346,19 @@ class DepthFirstSearch(MazeGenerator):
def generator( def generator(
self, height: int, width: int, seed: int = None self, height: int, width: int, seed: int = None
) -> Generator[np.ndarray, None, np.ndarray]: ) -> Generator[np.ndarray, None, np.ndarray]:
"""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: if seed is not None:
np.random.seed(seed) np.random.seed(seed)
maze = self.init_maze(width, height) maze = self.init_maze(width, height)
@@ -269,8 +402,12 @@ class DepthFirstSearch(MazeGenerator):
maze[y][x] = self.broken_wall(maze[y][x], wall_r) maze[y][x] = self.broken_wall(maze[y][x], wall_r)
yield maze yield maze
if self.perfect is False: if self.perfect is False:
gen = DepthFirstSearch.unperfect_maze(width, height, maze, gen = DepthFirstSearch.unperfect_maze(
self.forty_two) width,
height,
maze,
self.forty_two,
)
for res in gen: for res in gen:
maze = res maze = res
yield maze yield maze
@@ -278,6 +415,15 @@ class DepthFirstSearch(MazeGenerator):
@staticmethod @staticmethod
def init_maze(width: int, height: int) -> np.ndarray: def init_maze(width: int, height: int) -> np.ndarray:
"""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( maze = np.array(
[[Cell(value=15) for _ in range(width)] for _ in range(height)] [[Cell(value=15) for _ in range(width)] for _ in range(height)]
) )
@@ -285,11 +431,31 @@ class DepthFirstSearch(MazeGenerator):
@staticmethod @staticmethod
def add_cell_visited(coord: tuple, path: set) -> list: def add_cell_visited(coord: tuple, path: set) -> list:
"""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) path.append(coord)
return path return path
@staticmethod @staticmethod
def random_cells(visited: np.array, coord: tuple, w_h: tuple) -> list: def random_cells(visited: np.array, coord: tuple, w_h: tuple) -> list:
"""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 = [] rand_cell = []
x, y = coord x, y = coord
width, height = w_h width, height = w_h
@@ -309,10 +475,27 @@ class DepthFirstSearch(MazeGenerator):
@staticmethod @staticmethod
def next_step(rand_cell: list) -> str: def next_step(rand_cell: list) -> str:
"""Select the next direction at random.
Args:
rand_cell: List of candidate directions.
Returns:
A randomly selected direction.
"""
return np.random.choice(rand_cell) return np.random.choice(rand_cell)
@staticmethod @staticmethod
def broken_wall(cell: Cell, wall: str) -> Cell: 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": if wall == "N":
cell.set_north(False) cell.set_north(False)
elif wall == "S": elif wall == "S":
@@ -325,16 +508,44 @@ class DepthFirstSearch(MazeGenerator):
@staticmethod @staticmethod
def next_cell(x: int, y: int, next: str) -> tuple: def next_cell(x: int, y: int, next: str) -> tuple:
"""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)} next_step = {"N": (0, -1), "S": (0, 1), "W": (-1, 0), "E": (1, 0)}
add_x, add_y = next_step[next] add_x, add_y = next_step[next]
return (x + add_x, y + add_y) return (x + add_x, y + add_y)
@staticmethod @staticmethod
def reverse_path(direction: str) -> str: 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] return {"N": "S", "S": "N", "W": "E", "E": "W"}[direction]
@staticmethod @staticmethod
def back_on_step(path: list, w_h: tuple, visited: np.ndarray) -> list: def back_on_step(path: list, w_h: tuple, visited: np.ndarray) -> list:
"""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: while path:
last = path[-1] last = path[-1]
if DepthFirstSearch.random_cells(visited, last, w_h): if DepthFirstSearch.random_cells(visited, last, w_h):
@@ -346,6 +557,15 @@ class DepthFirstSearch(MazeGenerator):
def lock_cell_ft( def lock_cell_ft(
visited: np.ndarray, forty_two: set[tuple[int]] visited: np.ndarray, forty_two: set[tuple[int]]
) -> np.ndarray: ) -> np.ndarray:
"""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] tab = [cell for cell in forty_two]
for cell in tab: for cell in tab:
visited[cell] = True visited[cell] = True
+299 -123
View File
@@ -1,171 +1,300 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from .Maze import Maze from .Maze import Maze
from typing import Any
import numpy as np import numpy as np
class MazeSolver(ABC): class MazeSolver(ABC):
"""Define the common interface for maze-solving algorithms."""
def __init__(self, start: tuple[int, int], end: tuple[int, int]) -> None: 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.start = (start[1] - 1, start[0] - 1)
self.end = (end[1] - 1, end[0] - 1) self.end = (end[1] - 1, end[0] - 1)
@abstractmethod @abstractmethod
def solve(self, maze: Maze, height: int = None, def solve(
width: int = None) -> str: ... 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): 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: 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) super().__init__(start, end)
self.path = []
def f(self, n): def h(self, n: tuple[int, int]) -> int:
def g(n: tuple[int, int]) -> int: """Compute the Manhattan distance heuristic to the goal.
res = 0
if n[0] < self.start[0]:
res += self.start[0] - n[0]
else:
res += n[0] - self.start[0]
if n[1] < self.start[1]:
res += self.start[1] - n[1]
else:
res += n[1] - self.start[1]
return res
def h(n: tuple[int, int]) -> int: Args:
res = 0 n: Coordinates of the current node.
if n[0] < self.end[0]:
res += self.end[0] - n[0]
else:
res += n[0] - self.end[0]
if n[1] < self.end[1]:
res += self.end[1] - n[1]
else:
res += n[1] - self.end[1]
return res
try: Returns:
return g(n) + h(n) The heuristic distance to the end coordinate.
except Exception: """
return 1000 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 best_path( def get_paths(
self, self,
maze: np.ndarray, maze: np.ndarray,
actual: tuple[int, int], actual: tuple[int, int],
last: str | None, close: list,
) -> dict[str, int]: ) -> list[tuple[int, int]]:
path = { """Return all reachable neighboring coordinates.
"N": (
self.f((actual[0], actual[1] - 1)) Args:
if not maze[actual[1]][actual[0]].get_north() and actual[1] > 0 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 else None
), ),
"E": ( (
self.f((actual[0] + 1, actual[1])) (actual[0] + 1, actual[1])
if not maze[actual[1]][actual[0]].get_est() if not maze[actual[1]][actual[0]].get_est()
and actual[0] < len(maze[0]) - 1 and actual[0] < len(maze[0]) - 1
and (actual[0] + 1, actual[1])
not in [n.coordinate for n in close]
else None else None
), ),
"S": ( (
self.f((actual[0], actual[1] + 1)) (actual[0], actual[1] + 1)
if not maze[actual[1]][actual[0]].get_south() if not maze[actual[1]][actual[0]].get_south()
and actual[1] < len(maze) - 1 and actual[1] < len(maze) - 1
and (actual[0], actual[1] + 1)
not in [n.coordinate for n in close]
else None else None
), ),
"W": ( (
self.f((actual[0] - 1, actual[1])) (actual[0] - 1, actual[1])
if not maze[actual[1]][actual[0]].get_west() and actual[0] > 0 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 else None
), ),
} ]
return { return [p for p in path if p is not None]
k: v
for k, v in sorted(path.items(), key=lambda item: item[0])
if v is not None and k != last
}
def get_opposit(self, dir: str) -> str: def get_path(self, maze: np.ndarray) -> list:
match dir: """Perform A* exploration until the destination is reached.
case "N":
return "S"
case "E":
return "W"
case "S":
return "N"
case "W":
return "E"
case _:
return ""
def get_next_pos( Args:
self, dir: str, actual: tuple[int, int] maze: Maze grid to solve.
) -> tuple[int, int]:
match dir:
case "N":
return (actual[0], actual[1] - 1)
case "E":
return (actual[0] + 1, actual[1])
case "S":
return (actual[0], actual[1] + 1)
case "W":
return (actual[0] - 1, actual[1])
case _:
return actual
def get_path(self, maze: np.ndarray) -> str | None: Returns:
path = [(self.start, self.best_path(maze, self.start, None))] The closed list ending with the goal node.
visited = [self.start]
while len(path) > 0 and path[-1][0] != self.end:
if len(path[-1][1]) == 0:
path.pop(-1)
if len(path) == 0:
break
k = next(iter(path[-1][1]))
path[-1][1].pop(k)
continue
while len(path[-1][1]) > 0: Raises:
next_pos = self.get_next_pos( Exception: If no path can be found.
list(path[-1][1].keys())[0], path[-1][0] """
) open: list[AStar.Node] = []
if next_pos in visited: close: list[AStar.Node] = []
k = next(iter(path[-1][1]))
path[-1][1].pop(k)
else:
break
if len(path[-1][1]) == 0:
path.pop(-1)
continue
pre = self.get_opposit(list(path[-1][1].keys())[0]) open.append(
path.append( AStar.Node(
( self.start,
next_pos, 0,
self.best_path(maze, next_pos, pre), self.h(self.start),
) self.h(self.start),
None,
) )
visited += [next_pos]
if len(path) == 0:
return None
path[-1] = (self.end, {})
return "".join(
str(list(c[1].keys())[0]) for c in path if len(c[1]) > 0
) )
def solve(self, maze: Maze, height: int = None, while len(open) > 0:
width: int = None) -> str: to_check = sorted(open, key=lambda x: x.f)[0]
res = self.get_path(maze.get_maze()) open.remove(to_check)
if res is None: close.append(to_check)
raise Exception("Path not found") 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) -> 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 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.
"""
path = self.get_path(maze.get_maze())
return self.translate(path)
class DepthFirstSearchSolver(MazeSolver): class DepthFirstSearchSolver(MazeSolver):
"""Solve a maze using depth-first search with backtracking."""
def __init__(self, start, end): def __init__(self, start, end):
"""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) super().__init__(start, end)
def solve(self, maze: Maze, height: int = None, def solve(
width: int = None) -> str: 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 = "" path_str = ""
visited = np.zeros((height, width), dtype=bool) visited = np.zeros((height, width), dtype=bool)
path = list() path = list()
@@ -179,8 +308,9 @@ class DepthFirstSearchSolver(MazeSolver):
rand_p = self.random_path(visited, coord, maze_s, h_w) rand_p = self.random_path(visited, coord, maze_s, h_w)
if not rand_p: if not rand_p:
path, move = self.back_on_step(path, visited, maze_s, h_w, path, move = self.back_on_step(
move) path, visited, maze_s, h_w, move
)
if not path: if not path:
break break
coord = path[-1] coord = path[-1]
@@ -195,8 +325,20 @@ class DepthFirstSearchSolver(MazeSolver):
return path_str return path_str
@staticmethod @staticmethod
def random_path(visited: np.ndarray, coord: tuple, def random_path(
maze: np.ndarray, h_w: tuple) -> list: visited: np.ndarray, coord: tuple, maze: np.ndarray, h_w: tuple
) -> list:
"""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 = [] random_p = []
h, w = h_w h, w = h_w
y, x = coord y, x = coord
@@ -216,11 +358,36 @@ class DepthFirstSearchSolver(MazeSolver):
@staticmethod @staticmethod
def next_path(rand_path: list) -> str: def next_path(rand_path: list) -> str:
"""Select the next move at random.
Args:
rand_path: List of available directions.
Returns:
A randomly selected direction.
"""
return np.random.choice(rand_path) return np.random.choice(rand_path)
@staticmethod @staticmethod
def back_on_step(path: list, visited: np.ndarray, def back_on_step(
maze: np.ndarray, h_w: tuple, move: list) -> list: path: list,
visited: np.ndarray,
maze: np.ndarray,
h_w: tuple,
move: list,
) -> list:
"""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: while path:
last = path[-1] last = path[-1]
if DepthFirstSearchSolver.random_path(visited, last, maze, h_w): if DepthFirstSearchSolver.random_path(visited, last, maze, h_w):
@@ -231,6 +398,15 @@ class DepthFirstSearchSolver(MazeSolver):
@staticmethod @staticmethod
def next_cell(coord: tuple, next: str) -> tuple: def next_cell(coord: tuple, next: str) -> tuple:
"""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 y, x = coord
next_step = {"N": (-1, 0), "S": (1, 0), "W": (0, -1), "E": (0, 1)} next_step = {"N": (-1, 0), "S": (1, 0), "W": (0, -1), "E": (0, 1)}
add_y, add_x = next_step[next] add_y, add_x = next_step[next]
+100 -10
View File
@@ -3,9 +3,21 @@ from src.amaz_lib.MazeSolver import AStar, DepthFirstSearchSolver
class DataMaze: class DataMaze:
"""Provide helper methods to load and validate maze configuration data."""
@staticmethod @staticmethod
def get_file_data(name_file: str) -> str: def get_file_data(name_file: str) -> str:
"""Read and return the contents of a configuration file.
Args:
name_file: Path to the configuration file.
Returns:
The file contents as a string.
Raises:
ValueError: If the file is empty.
"""
with open(name_file, "r") as file: with open(name_file, "r") as file:
data = file.read() data = file.read()
if data == "": if data == "":
@@ -14,6 +26,16 @@ class DataMaze:
@staticmethod @staticmethod
def transform_data(data: str) -> dict: def transform_data(data: str) -> dict:
"""Transform raw configuration text into a dictionary.
Each non-empty line containing ``=`` is split into a key-value pair.
Args:
data: Raw configuration text.
Returns:
A dictionary mapping configuration keys to their string values.
"""
tmp = data.split("\n") tmp = data.split("\n")
tmp2 = [value.split("=", 1) for value in tmp if "=" in value] tmp2 = [value.split("=", 1) for value in tmp if "=" in value]
data_t = {value[0]: value[1] for value in tmp2} data_t = {value[0]: value[1] for value in tmp2}
@@ -21,6 +43,14 @@ class DataMaze:
@staticmethod @staticmethod
def verif_key_data(data: dict) -> None: def verif_key_data(data: dict) -> None:
"""Validate that the configuration contains the expected keys.
Args:
data: Configuration dictionary to validate.
Raises:
KeyError: If keys are missing or unexpected keys are present.
"""
key_test = { key_test = {
"WIDTH", "WIDTH",
"HEIGHT", "HEIGHT",
@@ -42,6 +72,15 @@ class DataMaze:
@staticmethod @staticmethod
def convert_values(data: dict): def convert_values(data: dict):
"""Convert configuration values to their appropriate Python types.
Args:
data: Raw configuration dictionary with string values.
Returns:
A dictionary containing converted values and instantiated solver and
generator objects.
"""
key_int = {"WIDTH", "HEIGHT"} key_int = {"WIDTH", "HEIGHT"}
key_tuple = {"ENTRY", "EXIT"} key_tuple = {"ENTRY", "EXIT"}
key_bool = {"PERFECT"} key_bool = {"PERFECT"}
@@ -54,30 +93,62 @@ class DataMaze:
res.update({key: DataMaze.convert_bool(data[key])}) res.update({key: DataMaze.convert_bool(data[key])})
res.update({"OUTPUT_FILE": data["OUTPUT_FILE"]}) res.update({"OUTPUT_FILE": data["OUTPUT_FILE"]})
res.update( res.update(
DataMaze.get_solver_generator(data, res["ENTRY"], res["EXIT"], DataMaze.get_solver_generator(
res["PERFECT"]) data,
res["ENTRY"],
res["EXIT"],
res["PERFECT"],
)
) )
return res return res
@staticmethod @staticmethod
def get_solver_generator(data: dict, entry: tuple, exit: tuple, def get_solver_generator(
perfect: bool) -> dict: data: dict,
entry: tuple,
exit: tuple,
perfect: bool,
) -> dict:
"""Instantiate the configured maze generator and solver.
Args:
data: Raw configuration dictionary.
entry: Entry coordinates.
exit: Exit coordinates.
perfect: Whether the maze must be perfect.
Returns:
A dictionary containing initialized ``GENERATOR`` and ``SOLVER``
objects.
"""
available_generator = { available_generator = {
"Kruskal": Kruskal, "Kruskal": Kruskal,
"DFS": DepthFirstSearch, "DFS": DepthFirstSearch,
} }
available_solver = { available_solver = {"AStar": AStar, "DFS": DepthFirstSearchSolver}
"AStar": AStar,
"DFS": DepthFirstSearchSolver
}
res = {} res = {}
res["GENERATOR"] = available_generator[data["GENERATOR"]](entry, exit, res["GENERATOR"] = available_generator[data["GENERATOR"]](
perfect) entry,
exit,
perfect,
)
res["SOLVER"] = available_solver[data["SOLVER"]](entry, exit) res["SOLVER"] = available_solver[data["SOLVER"]](entry, exit)
return res return res
@staticmethod @staticmethod
def convert_tuple(data: str) -> tuple: def convert_tuple(data: str) -> tuple:
"""Convert a comma-separated coordinate string into a tuple.
Args:
data: Coordinate string in the form ``"x,y"``.
Returns:
A tuple of two integers.
Raises:
ValueError: If the coordinate string does not contain exactly two
values.
"""
data_t = data.split(",") data_t = data.split(",")
if len(data_t) != 2: if len(data_t) != 2:
raise ValueError( raise ValueError(
@@ -89,6 +160,17 @@ class DataMaze:
@staticmethod @staticmethod
def convert_bool(data: str) -> bool: def convert_bool(data: str) -> bool:
"""Convert a string to a boolean value.
Args:
data: String representation of a boolean.
Returns:
``True`` if the string is ``"True"``, otherwise ``False``.
Raises:
ValueError: If the string is neither ``"True"`` nor ``"False"``.
"""
if data != "True" and data != "False": if data != "True" and data != "False":
raise ValueError("This is not True or False") raise ValueError("This is not True or False")
if data == "True": if data == "True":
@@ -97,6 +179,14 @@ class DataMaze:
@staticmethod @staticmethod
def get_data_maze(name_file: str) -> dict: def get_data_maze(name_file: str) -> dict:
"""Load, validate, and convert maze configuration data from a file.
Args:
name_file: Path to the configuration file.
Returns:
A dictionary of validated configuration values with lowercase keys.
"""
try: try:
data_str = DataMaze.get_file_data(name_file) data_str = DataMaze.get_file_data(name_file)
data_dict = DataMaze.transform_data(data_str) data_dict = DataMaze.transform_data(data_str)
-1
View File
@@ -1,4 +1,3 @@
import pytest
from amaz_lib.Cell import Cell from amaz_lib.Cell import Cell
+1 -1
View File
@@ -1,6 +1,6 @@
from amaz_lib.Cell import Cell from amaz_lib.Cell import Cell
import numpy as np import numpy as np
from amaz_lib import AStar, Maze, MazeSolver from amaz_lib import AStar, Maze
def test_solver() -> None: def test_solver() -> None: