58 Commits

Author SHA1 Message Date
da7e 0bd43d21a5 remove con.txt 2026-04-07 11:17:45 +02:00
Maoake Teriierooiterai f5131f0e01 finish the norm 2026-04-06 18:17:45 +02:00
da7e 4d5662c248 lowercase key in config.txt allow 2026-04-04 21:22:39 +02:00
da7e e137389216 Some fix:
- entry/exit negatif
- checker if output file already exist
- config.txt commentary
- maze min size for ft logo generation
- unperfect maze compliance
2026-04-04 21:18:42 +02:00
da7e 21e9aba95f docstring to output file function 2026-04-03 18:34:18 +02:00
da7e 9fa121cdce add export file 2026-04-03 18:30:52 +02:00
da7e 04c28a851f fix Astar solver 2026-04-03 17:59:37 +02:00
da7e 6189d7f321 lint fix 2026-04-03 16:48:51 +02:00
da7e e63c2679a6 Remove test prints 2026-04-03 16:44:49 +02:00
da7e f04381568d wheel file + config 2026-04-03 16:12:36 +02:00
da7e ee4f48a5c0 add limit for height and width 2026-04-03 15:42:20 +02:00
da7e 2532a35e30 add mazegen wheel package 2026-04-03 15:10:17 +02:00
da7e 6f503bdd36 add file format checker for parsing 2026-04-03 15:00:08 +02:00
da7e 5022cfe020 add message when ft logo not display 2026-04-03 14:47:34 +02:00
Maoake Teriierooiterai 11947db62f fix the print on 42 in the maze 2026-04-03 14:20:01 +02:00
da7e 0045def73b SEED implementation 2026-04-03 13:58:41 +02:00
Maoake Teriierooiterai b6067b2045 finish the thing 2026-04-03 12:30:42 +02:00
Maoake Teriierooiterai 9c76914366 makefile update 2026-04-03 11:45:38 +02:00
Maoake Teriierooiterai b38da3fc31 need to verify everything on each files scan every line every pixel cause i like pixel its pretty beautiful 2026-04-03 11:36:40 +02:00
da7e b54e49122c README 2026-04-02 14:15:40 +02:00
maoake 53316f4e32 need to be merge 2026-04-02 12:36:10 +02:00
maoake 732cf25a57 trying a README 2026-04-01 23:20:11 +02:00
maoake 4c1955ace4 changing the import for mypy test 2026-04-01 23:00:08 +02:00
maoake 3e85cbe919 doing again the wheel 2026-04-01 22:41:11 +02:00
maoake 2edf61affa doing the wheel 2026-04-01 22:31:08 +02:00
da7e be997c5d17 fix lint + black formating + SITULITUPU 2026-04-01 18:02:38 +02:00
da7e a4d8f3fbfe Merge branch 'whl' 2026-04-01 17:44:20 +02:00
da7e 3fe46026ec Merge branch 'docstring' 2026-04-01 17:42:48 +02:00
Maoake Teriierooiterai d2f38468a4 need to be merge to the main and add some line for the makefile 2026-04-01 17:17:20 +02:00
Maoake Teriierooiterai b659871902 finish the mypy 2026-04-01 16:09:42 +02:00
Maoake Teriierooiterai c9e0cf0610 fix some mypy need to fix for the others 2026-04-01 15:25:38 +02:00
Maoake Teriierooiterai aadccfba53 finish the mypy strict 2026-04-01 15:19:46 +02:00
Maoake Teriierooiterai c7c7213fb9 fix some mypy strict on file a_maze_ing.py 2026-04-01 15:03:22 +02:00
da7e 843fe5f80c uv config for build wheel package
add clean and fclean method to Makefile
2026-04-01 14:31:11 +02:00
Maoake Teriierooiterai 03b5f9e6fd fix mypy strict on MazeSolver and Maze Generator 2026-04-01 14:12:39 +02:00
da7e 68c40be144 add(docstring): doc string on every class and functions 2026-04-01 12:34:19 +02:00
maoake ed16566677 finish to fix parsing mypy 2026-03-31 22:43:03 +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
da7e 92c6237f06 fix(astar): the actual astar wasn't the real astar algoritm 2026-03-29 15:38:40 +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
30 changed files with 2556 additions and 977 deletions
-1
View File
@@ -215,4 +215,3 @@ __marimo__/
# Streamlit # Streamlit
.streamlit/secrets.toml .streamlit/secrets.toml
test.txt test.txt
+24 -7
View File
@@ -1,3 +1,7 @@
build:
uv build --clear --wheel
cp dist/*.whl mazegen-1.0.0-py3-none-any.whl
install: install:
uv sync uv sync
uv pip install mlx-2.2-py3-none-any.whl uv pip install mlx-2.2-py3-none-any.whl
@@ -9,18 +13,29 @@ run_windows:
.venv\Scripts\python -m a_maze_ing config.txt .venv\Scripts\python -m a_maze_ing config.txt
debug: debug:
uv pdb python3 a_maze_ing.py config.txt uv run python3 -m pdb a_maze_ing.py config.txt
clean: clean:
rm -rf __pycache__ .mypy_cache .venv rm -rf */**/__pycache__ */__pycache__ __pycache__ */.mypy_cache .mypy_cache .venv dist build */**/*.egg-info */*.egg-info *.egg-info test.txt
lint: fclean: clean
rm mazegen-1.0.0-py3-none-any.whl
lint: install
uv run flake8 . --exclude=.venv uv run flake8 . --exclude=.venv
uv run mypy . --warn-return-any --warn-unused-ignores --ignore-missing-imports --disallow-untyped-defs --check-untyped-defs uv run env PYTHONPATH=src python3 -m mypy --warn-return-any --warn-unused-ignores --ignore-missing-imports --disallow-untyped-defs --check-untyped-defs -p mazegen
uv run env PYTHONPATH=src python3 -m mypy --warn-return-any --warn-unused-ignores --ignore-missing-imports --disallow-untyped-defs --check-untyped-defs -p parsing
uv run env PYTHONPATH=src python3 -m mypy --warn-return-any --warn-unused-ignores --ignore-missing-imports --disallow-untyped-defs --check-untyped-defs src/AMazeIng.py
uv run env PYTHONPATH=src python3 -m mypy --warn-return-any --warn-unused-ignores --ignore-missing-imports --disallow-untyped-defs --check-untyped-defs tests
uv run env PYTHONPATH=src python3 -m mypy --warn-return-any --warn-unused-ignores --ignore-missing-imports --disallow-untyped-defs --check-untyped-defs a_maze_ing.py
lint-strict: lint-strict: install
uv run flake8 . uv run flake8 . --exclude=.venv
uv run mypy . --strict uv run env PYTHONPATH=src python3 -m mypy --strict -p mazegen
uv run env PYTHONPATH=src python3 -m mypy --strict src/AMazeIng.py
uv run env PYTHONPATH=src python3 -m mypy --strict -p parsing
uv run env PYTHONPATH=src python3 -m mypy --strict tests
uv run env PYTHONPATH=src python3 -m mypy --strict a_maze_ing.py
run_test_parsing: run_test_parsing:
PYTHONPATH=src uv run pytest tests/test_parsing.py PYTHONPATH=src uv run pytest tests/test_parsing.py
@@ -34,3 +49,5 @@ run_test:
uv run pytest uv run pytest
mlx: mlx:
uv run python3 test.py uv run python3 test.py
.PHONY: build install run debug clean fclean lint lint-strict run_test
+614 -2
View File
@@ -1,3 +1,615 @@
The Randomized Kruskal's Algorithm This project has been created as part of the 42 curriculum by *mteriier*, *dgaillet*
The Randomized Prim's Algorithm # A-Maze-ing
## Description
A-Maze-ing is a Python project that generates, solves, exports, and displays mazes.
The program:
- reads a configuration file,
- generates a maze according to the requested parameters,
- optionally enforces a **perfect maze** property,
- solves the maze from entry to exit,
- writes the maze to an output file using the required hexadecimal wall encoding,
- and displays the maze visually through an **MLX graphical window**.
This project was designed with **code reusability** in mind.
The maze generation and solving logic is exposed through a reusable Python package named **`mazegen`**, which can be built and installed independently for use in future projects.
---
## Features
- Maze generation from a config file
- Multiple generation algorithms:
- `DFS` (depth-first search / recursive backtracking style)
- `Kruskal`
- Multiple solving algorithms:
- `AStar`
- `DFS`
- Perfect and imperfect maze support
- Maze export using hexadecimal wall encoding
- Graphical rendering with MLX
- Animated generation
- Animated solution path display
- Wall color switching
- Reserved visual **“42” pattern** using fully closed cells when the maze is large enough
- Reusable `mazegen` package
---
## Project Structure
```text
.
├── a_maze_ing.py # Main executable script and MLX display
├── config.txt # Default configuration file
├── Makefile
├── README.md
├── src/
│ ├── AMazeIng.py
│ ├── mazegen/
│ │ ├── __init__.py
│ │ ├── Cell.py
│ │ ├── Maze.py
│ │ ├── MazeGenerator.py
│ │ └── MazeSolver.py
│ └── parsing/
│ └── Parsing.py
└── tests/
```
---
## Instructions
### Requirements
- Python **3.10+**
- `uv`, `pip`
- MLX Python binding used by the project
### Installation
Using the provided `Makefile`:
```bash
make install
```
This installs project dependencies and the MLX wheel used by the graphical display.
---
## Run
```bash
make run
```
---
## Debug
```bash
make debug
```
---
## Lint
Mandatory lint target:
```bash
make lint
```
Strict lint target:
```bash
make lint-strict
```
---
## Clean
```bash
make clean
```
Full cleanup:
```bash
make fclean
```
---
## Configuration File Format
The configuration file contains one `KEY=VALUE` pair per line.
### Mandatory keys
| Key | Description | Example |
|---|---|---|
| `WIDTH` | Maze width in cells | `WIDTH=20` |
| `HEIGHT` | Maze height in cells | `HEIGHT=15` |
| `ENTRY` | Entry coordinates `(x,y)` | `ENTRY=1,1` |
| `EXIT` | Exit coordinates `(x,y)` | `EXIT=20,15` |
| `OUTPUT_FILE` | Output filename | `OUTPUT_FILE=maze.txt` |
| `PERFECT` | Perfect maze or not | `PERFECT=True` |
| `GENERATOR` | Generation algorithm | `GENERATOR=DFS` |
| `SOLVER` | Solving algorithm | `SOLVER=AStar` |
### Supported values
#### GENERATOR
- `DFS`
- `Kruskal`
#### SOLVER
- `AStar`
- `DFS`
#### PERFECT
- `True`
- `False`
### Example config
```ini
WIDTH=20
HEIGHT=15
ENTRY=1,1
EXIT=20,15
OUTPUT_FILE=maze.txt
PERFECT=True
GENERATOR=DFS
SOLVER=AStar
SEED=31766516
```
### Notes
- Coordinates are handled as tuples in the form `x,y`.
- In the current implementation, coordinates are expected to be **inside maze bounds**.
- Entry and exit must be valid cells.
- The parser validates required keys and converts values to the correct Python types.
- You can add a `SEED` value
---
## Output File Format
The generated maze is written row by row using **one hexadecimal digit per cell**.
Each cell stores wall information using this bitmask:
| Bit | Direction |
|---|---|
| `1` | North |
| `2` | East |
| `4` | South |
| `8` | West |
A bit set to `1` means the wall is **closed**.
### Example
- `3` = `0011` → north and east closed
- `A` = `1010` → east and west closed
### Output layout
```text
<maze row 1>
<maze row 2>
...
<maze row n>
<entry coordinates>
<exit coordinates>
<solution path>
```
Example:
```text
FFFF
9A63
8C47
FFFF
1,1
4,4
EESSEN
```
---
## Visual Representation
This project provides a graphical rendering through **MLX**.
The display shows:
- maze walls,
- entry cell,
- exit cell,
- optional shortest path,
- reserved “42” pattern when present.
### Controls
In the MLX window:
- `1` / mapped equivalent: regenerate maze
- `2` / mapped equivalent: show/hide path
- `3` / mapped equivalent: change wall color
- `4` / mapped equivalent: quit
The code includes two key mappings to handle platform/layout differences.
### Visual Features
- animated generation,
- animated path display,
- color cycling for walls,
- separate color cycling for the “42” cells.
---
## Maze Generation Algorithm
This project supports two generation algorithms.
### 1. Depth-First Search (DFS)
This algorithm starts from a cell and repeatedly visits an unvisited neighbour, removing walls as it advances. When it reaches a dead end, it backtracks until it finds a cell with an unvisited neighbour.
#### Why this algorithm was chosen
- simple to implement,
- naturally produces connected mazes,
- works well for animation,
- produces visually interesting long corridors,
- easy to adapt for perfect mazes.
### 2. Kruskal
This algorithm treats each cell as its own set and removes walls between cells only when it connects two different sets. This avoids cycles and guarantees connectivity.
#### Why this algorithm was included
- classic maze generation algorithm,
- good complement to DFS,
- demonstrates modularity and algorithm interchangeability,
- naturally fits the reusable package requirement.
---
## Why These Algorithms Were Chosen
We chose DFS and Kruskal because together they provide:
- two well-known and complementary approaches,
- good pedagogical value,
- simple integration into a reusable class-based architecture,
- deterministic structure when used with a seed,
- compatibility with perfect maze generation.
DFS is particularly suitable for progressive visual rendering.
Kruskal is useful to show a different construction logic based on set merging.
---
## Perfect and Imperfect Mazes
When `PERFECT=True`:
- the maze is generated as a **perfect maze**,
- there is exactly one path between any two reachable cells,
- in particular, entry and exit have a unique valid path.
When `PERFECT=False`:
- additional walls may be removed after initial generation,
- loops can appear,
- the maze remains connected,
- the solver still computes a valid path.
---
## The “42” Pattern
For sufficiently large mazes, the generator reserves a group of fully closed cells to draw a visible **“42”** pattern in the visual rendering.
### Behaviour
- the pattern is added only if the maze is large enough,
- if the maze is too small, the pattern may be omitted,
- this should be reported to the user with a console message.
### Current implementation note
The current code includes support for reserving and rendering the “42” pattern using cells with value `15` (all walls closed).
The pattern is drawn in the central area when dimensions are large enough.
---
## Error Handling
The project is designed to fail gracefully and provide clear messages for common problems such as:
- missing configuration file,
- empty file,
- missing or invalid keys,
- invalid boolean values,
- invalid coordinates,
- invalid maze dimensions,
- solving an uninitialized maze.
The parser catches several common exceptions and prints user-friendly messages before exiting.
---
## Reusable Code
The reusable part of the project is the **`mazegen`** package.
It contains:
- `Cell`: wall bitmask representation,
- `Maze`: maze container and textual/ascii rendering,
- `MazeGenerator`: abstract generator interface,
- `DepthFirstSearch`: DFS-based maze generator,
- `Kruskal`: Kruskal-based maze generator,
- `MazeSolver`: abstract solver interface,
- `AStar`: shortest-path solver,
- `DepthFirstSearchSolver`: DFS-based path solver.
This package can be built as a wheel and reused independently of the MLX application.
---
## How to Use the Reusable Module
### Basic example
```python
from mazegen import Maze
from mazegen import DepthFirstSearch, AStar
generator = DepthFirstSearch(start=(1, 1), end=(10, 10), perfect=True)
solver = AStar(start=(1, 1), end=(10, 10))
maze = Maze()
for grid in generator.generator(height=10, width=10, seed=42):
maze.set_maze(grid)
path = solver.solve(maze, height=10, width=10)
print(maze)
print(path)
```
### With Kruskal
```python
from mazegen import Maze, Kruskal, AStar
generator = Kruskal(start=(1, 1), end=(20, 15), perfect=True)
solver = AStar(start=(1, 1), end=(20, 15))
maze = Maze()
for grid in generator.generator(height=15, width=20, seed=123):
maze.set_maze(grid)
print(solver.solve(maze, height=15, width=20))
```
### Accessing the generated structure
```python
maze_array = maze.get_maze()
```
Each element of `maze_array` is a `Cell` object exposing:
- `get_north()`
- `get_est()`
- `get_south()`
- `get_west()`
- `get_value()`
### Accessing a solution
```python
solution = solver.solve(maze, height=15, width=20)
print(solution) # Example: "EESSWN..."
```
---
## Packaging
The reusable package is distributed as **`mazegen-*`**.
Example expected artifact:
```text
mazegen-1.0.0-py3-none-any.whl
```
Build with:
```bash
make build
```
This produces a wheel suitable for later installation with `pip`/`uv`.
---
## Tests
Unit tests are recommended and partially integrated through `pytest` targets in the Makefile.
Start test with:
```bash
make run_test
```
These tests are useful to validate:
- parsing,
- generation,
- solver behavior,
- edge cases.
---
## Technical Choices
### Language
- Python 3.10+
### Libraries
- `numpy` for grid storage
- `pydantic` for model validation
- `mlx` for graphical rendering
- `pytest` for tests
- `mypy` for static typing
- `flake8` for style checking
### Architecture
The project is separated into three main parts:
1. **Main application**
- parsing,
- orchestration,
- MLX rendering,
- user interaction.
2. **Domain model**
- `AMazeIng`,
- maze configuration and lifecycle.
3. **Reusable package**
- generation,
- solving,
- maze structure.
This separation makes the generation logic portable to other projects.
---
## Team and Project Management
### Team roles
- **mteriier**
- Parsing
- DFS generator / solver
- Makefile
- some pytest
- Fix of mazegen package generation
- MLX
- **dgaillet**
- AMazeIng config class
- AStar solver
- Kruskal generator
- some pytest
- mazegen package generation
- MLX
- Cell / Maze class
### Initial planning
Our initial plan was:
1. define the maze data model,
2. implement one working generation algorithm,
3. export the maze to the required format,
4. implement solving,
5. add graphical rendering,
6. package reusable code,
7. write tests and documentation.
### How planning evolved
In practice:
- the reusable package structure had to be stabilized earlier than expected,
- coordinate handling between parser, generator, solver, and renderer required extra work,
- rendering and animation took longer than planned,
- algorithm modularity made later integration easier.
### What worked well
- clean separation between generation and display,
- abstract base classes for generator and solver,
- Makefile automation,
- packaging the reusable module.
### What could be improved
- stricter normalization of coordinate conventions,
- seed support should be exposed directly from configuration,
- more tests for edge cases and invalid inputs,
### Tools used
- Git
- `uv`
- `flake8`
- `mypy`
- `pytest`
- MLX
- optionally AI assistance for docstrings, README
---
## Resources
### Documentation and references
- [NumPy Documentation](https://numpy.org/doc/)
- [Pydantic Documentation](https://docs.pydantic.dev/)
- [A* Pathfinding explanation](https://matteo-tosato7.medium.com/exploring-the-depths-solving-mazes-with-a-search-algorithm-c15253104899)
- [Kruskal generation](https://medium.com/@anushidesilva28/understanding-kruskals-algorithm-44886bf8ba8b)
### How AI was used
AI was used as an assistant for:
- improving docstrings,
- helping structure the README,
---
## Reusable Module Summary
If you only want the reusable maze engine:
1. build/install `mazegen`,
2. import a generator and a solver,
3. generate a maze,
4. solve it,
5. access the grid through `Maze.get_maze()`.
This part is intended for reuse in future Python projects.
+366 -135
View File
@@ -1,17 +1,27 @@
import os
from typing import Any from typing import Any
from src.AMazeIng import AMazeIng from numpy.typing import NDArray
from src.parsing import Parsing from AMazeIng import AMazeIng
from parsing.Parsing import DataMaze as Parsing
from mlx import Mlx from mlx import Mlx
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"
@@ -20,13 +30,25 @@ class MazeMLX:
self.buf, self.bpp, self.size_line, self.format = ( self.buf, self.bpp, self.size_line, self.format = (
self.mlx.mlx_get_data_addr(self.img_ptr) self.mlx.mlx_get_data_addr(self.img_ptr)
) )
self.path_printer = 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) -> None:
"""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,58 +62,148 @@ 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: int, y: int, color: list[Any] | 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] = 0x00 if color:
self.buf[offset + 1] = 0x00 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[Any] | None = None,
) -> None:
"""Draw a horizontal or vertical line.
def random_color() -> Any: Args:
colors = [ start: Starting pixel coordinates.
[0x00, 0x00, 0xFF, 0xFF], # red end: Ending pixel coordinates.
[0x00, 0xFF, 0xFF, 0xFF], # yellow color: Optional RGBA color list.
[0x00, 0xFF, 0x40, 0xFF], # green """
[0xFF, 0xBF, 0x00, 0xFF], # blue
[0xFF, 0x00, 0x80, 0xFF], # purple
[0xFF, 0x00, 0xFF, 0xFF], # rose
]
return np.random.choice(colors)
def put_line(self, start: tuple[int, int], end: tuple[int, int]) -> None:
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 update_maze(self, maze: np.ndarray) -> None: def put_block(
self.clear_image() self,
margin = math.trunc( ul: tuple[int, int],
math.sqrt(self.width if self.width > self.height else self.height) dr: tuple[int, int],
// 2 color: list[Any] | None = None,
) ) -> None:
line_len = math.trunc( """Draw a filled rectangular block.
(
(self.height - margin) // len(maze) Args:
if self.height > self.width ul: Upper-left corner coordinates.
else (self.width - margin) // len(maze[0]) 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: NDArray[Any]) -> 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: NDArray[Any]) -> None:
"""Render the maze walls into the image buffer.
Args:
maze: Maze grid to render.
"""
self.clear_image()
line_len, margin_x, margin_y = self.get_margin_line_len(maze)
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))
@@ -101,48 +213,40 @@ 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)
actual = amazing.entry actual = amazing.entry
actual = (actual[0] - 1, actual[1] - 1) actual = (actual[0] - 1, actual[1] - 1)
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)
self.draw_ft(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":
@@ -158,106 +262,233 @@ 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 draw_ft(self, maze: np.ndarray): def put_start_end(self, amazing: AMazeIng) -> None:
self.clear_image() """Draw highlighted blocks for the maze entry and exit.
margin = math.trunc(
math.sqrt(self.width if self.width > self.height else self.height) Args:
// 2 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,
) )
line_len = math.trunc( dr = (
( (entry[0] - 1) * line_len + line_len + margin_x - 3,
(self.height - margin) // len(maze) (entry[1] - 1) * line_len + line_len - 3 + margin_y,
if self.height > self.width
else (self.width - margin) // len(maze[0])
)
) )
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: NDArray[Any], color: list[Any] | None = 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 y in range(len(maze)):
for x in range(len(maze[0])): for x in range(len(maze[0])):
x0 = x * line_len + margin
y0 = y * line_len + margin
x1 = x * line_len + line_len + margin
y1 = y * line_len + line_len + margin
if maze[y][x].get_north():
self.put_line((x0, y0), (x1, y0))
if maze[y][x].get_est():
self.put_line((x1, y0), (x1, y1))
if maze[y][x].get_south():
self.put_line((x0, y1), (x1, y1))
if maze[y][x].get_west():
self.put_line((x0, y0), (x0, y1))
if maze[y][x].value == 15: if maze[y][x].value == 15:
self.put_block((x0, y0), (x1, y1)) 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:
maze = amazing.maze.get_maze()
"""Main rendering callback used by the MLX loop.
Args:
amazing: Maze container to render.
"""
if self.render_maze(amazing):
if self.print_path:
if self.render_path():
color = next(self.color_gen_ft)
if maze is not None:
self.draw_ft(maze, color)
next(self.timer_gen)
else:
self.time_gen()
if maze is not None:
self.update_maze(maze)
self.draw_ft(maze)
self.put_start_end(amazing)
self.redraw_image() self.redraw_image()
def close_loop(self, _: Any): def shift_color(self) -> None:
self.mlx.mlx_loop_exit(self.mlx_ptr) """Reset the maze color generator."""
self.color_gen = self.random_color()
def shift_color_ft(self) -> None:
"""Reset the reserved-pattern color generator."""
self.color_gen_ft = self.random_color_ft()
def time_gen(self) -> None:
"""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:
maze = amazing.maze.get_maze()
next(self.generator)
if maze is not None:
self.update_maze(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.mlx.mlx_loop_hook(self.mlx_ptr, self.render_maze, amazing) self.shift_color()
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:
# self.color_ft(amazing)
if self.path_printer is not None:
self.render_path()
else:
self.color_ft(amazing.maze.get_maze())
def main() -> None: def main() -> None:
"""Run the maze application."""
mlx = None mlx = None
try: try:
mlx = MazeMLX(1000, 1000) os.system("cls" if os.name == "nt" else "clear")
config = Parsing.DataMaze.get_data_maze("config.txt") config = Parsing.get_data_maze("config.txt")
amazing = AMazeIng(**config) amazing = AMazeIng(**config)
mlx = MazeMLX(1600, 2000)
mlx.start(amazing) mlx.start(amazing)
with open("test.txt", "w") as output: amazing.export_maze()
output.write(amazing.__str__())
except Exception as err: except Exception as err:
print(err) print(err)
finally: finally:
+4 -4
View File
@@ -1,8 +1,8 @@
WIDTH=15 WIDTH=15
HEIGHT=15 HEIGHT=15
ENTRY=1,1 ENTRY=2,1
EXIT=11,11 EXIT=3,5
OUTPUT_FILE=maze.txt OUTPUT_FILE=con.txt
PERFECT=False PERFECT=False
GENERATOR=DFS GENERATOR=DFS
SOLVER=DFS Solver=AStar
Binary file not shown.
-25
View File
@@ -1,25 +0,0 @@
# This script does not check for errors or malformed files.
# It only validates that neighbooring cells sharing a wall have
# both the correct encoding.
# Usage: python3 output_validator.py output_maze.txt
import sys
if len(sys.argv) != 2:
print(f"Usage: python3 {sys.argv[0]} <output_file>")
sys.exit(1)
g = []
for line in open(sys.argv[1]):
if line.strip() == '':
break
g.append([int(c, 16) for c in line.strip(' \t\n\r')])
for r in range(len(g)):
for c in range(len(g[0])):
v = g[r][c]
if not all([(r < 1 or v & 1 == (g[r-1][c] >> 2) & 1),
(c >= len(g[0])-1 or (v >> 1) & 1 == (g[r][c+1] >> 3) & 1),
(r >= len(g)-1 or (v >> 2) & 1 == g[r+1][c] & 1),
(c < 1 or (v >> 3) & 1 == (g[r][c-1] >> 1) & 1)]):
print(f'Wrong encoding for ({c},{r})')
+13 -1
View File
@@ -1,5 +1,5 @@
[project] [project]
name = "A-Maze-ing" name = "mazegen"
version = "0.1.0" version = "0.1.0"
description = "This is the way" description = "This is the way"
readme = "README.md" readme = "README.md"
@@ -20,6 +20,18 @@ dev = [
[tool.mypy] [tool.mypy]
python_version = "3.10" python_version = "3.10"
explicit_package_bases = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["src"] pythonpath = ["src"]
[build-system]
requires = ["setuptools>=78.1.0", "wheel>=0.45.1"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
+52 -4
View File
@@ -2,14 +2,18 @@ from typing import Generator
from typing_extensions import Self from typing_extensions import Self
from pydantic import BaseModel, Field, model_validator, ConfigDict from pydantic import BaseModel, Field, model_validator, ConfigDict
from src.amaz_lib import Maze, MazeGenerator, MazeSolver from mazegen 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=5, le=100)
height: int = Field(ge=4) height: int = Field(ge=5, le=100)
entry: tuple[int, int] entry: tuple[int, int]
exit: tuple[int, int] exit: tuple[int, int]
output_file: str = Field(min_length=3) output_file: str = Field(min_length=3)
@@ -17,25 +21,69 @@ class AMazeIng(BaseModel):
maze: Maze = Field(default=Maze(None)) maze: Maze = Field(default=Maze(None))
generator: MazeGenerator generator: MazeGenerator
solver: MazeSolver solver: MazeSolver
seed: int | None = Field(default=None)
@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:
raise ValueError("Exit coordinates exceed the maze size") raise ValueError("Exit coordinates exceed the maze size")
if self.entry == self.exit:
raise ValueError("Entry and Exit coordinates cant be the same")
if self.exit[0] < 1 or self.exit[1] < 1:
raise ValueError("Exit coordinates to low")
if self.entry[0] < 1 or self.entry[1] < 1:
raise ValueError("Entry coordinates to low")
if self.width < 9 or self.height < 7:
print("Height or width to low for disply forty two logo")
return self return self
def generate(self) -> Generator[Maze, None, None]: def generate(self) -> Generator[Maze, None, None]:
for array in self.generator.generator(self.height, self.width): """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, self.seed
):
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 export_maze(self) -> None:
"""Export maze, entry, exit and resolved path in output_file"""
with open(self.output_file, "w") as file:
file.write(self.__str__())
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"
-52
View File
@@ -1,52 +0,0 @@
from dataclasses import dataclass
@dataclass
class Cell:
def __init__(self, value: int) -> None:
self.value = value
def __str__(self) -> str:
return hex(self.value).removeprefix("0x").upper()
def set_value(self, value: int) -> None:
self.value = value
def get_value(self) -> int:
return self.value
def set_north(self, is_wall: bool) -> None:
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 self.value & 1 == 1
def set_est(self, is_wall: bool) -> None:
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 self.value & 2 == 2
def set_south(self, is_wall: bool) -> None:
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 self.value & 4 == 4
def set_west(self, is_wall: bool) -> None:
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 self.value & 8 == 8
-46
View File
@@ -1,46 +0,0 @@
from dataclasses import dataclass
import numpy
@dataclass
class Maze:
maze: numpy.ndarray
def get_maze(self) -> numpy.ndarray | None:
return self.maze
def set_maze(self, new_maze: numpy.ndarray) -> None:
self.maze = new_maze
def __str__(self) -> str:
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:
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()
-352
View File
@@ -1,352 +0,0 @@
from abc import ABC, abstractmethod
from typing import Generator, Set
import numpy as np
from .Cell import Cell
import math
class MazeGenerator(ABC):
def __init__(self, start: tuple, end: tuple, perfect: bool) -> None:
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[np.ndarray, None, np.ndarray]: ...
@staticmethod
def get_cell_ft(width: int, height: int) -> set:
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: np.ndarray, forty_two: set | None,
prob: float = 0.1
) -> Generator[np.ndarray, None, np.ndarray]:
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):
class Set:
def __init__(self, cells: list[int]) -> None:
self.cells: list[int] = cells
class Sets:
def __init__(self, sets: list[Set]) -> None:
self.sets = sets
@staticmethod
def walls_to_maze(
walls: np.ndarray, height: int, width: int
) -> np.ndarray:
maze: np.ndarray = 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:
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:
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:
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[np.ndarray, None, np.ndarray]:
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.Set([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):
def __init__(self, start: bool, end: bool, perfect: bool) -> None:
self.start = (start[0] - 1, start[1] - 1)
self.end = (end[0] - 1, end[1] - 1)
self.perfect = perfect
self.forty_two: set | None = None
def generator(
self, height: int, width: int, seed: int = None
) -> Generator[np.ndarray, None, np.ndarray]:
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 = 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()
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) -> np.ndarray:
maze = np.array(
[[Cell(value=15) for _ in range(width)] for _ in range(height)]
)
return maze
@staticmethod
def add_cell_visited(coord: tuple, path: set) -> list:
path.append(coord)
return path
@staticmethod
def random_cells(visited: np.array, coord: tuple, w_h: tuple) -> list:
rand_cell = []
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:
return np.random.choice(rand_cell)
@staticmethod
def broken_wall(cell: Cell, wall: str) -> 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:
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 {"N": "S", "S": "N", "W": "E", "E": "W"}[direction]
@staticmethod
def back_on_step(path: list, w_h: tuple, visited: np.ndarray) -> list:
while path:
last = path[-1]
if DepthFirstSearch.random_cells(visited, last, w_h):
break
path.pop()
return path
@staticmethod
def lock_cell_ft(
visited: np.ndarray, forty_two: set[tuple[int]]
) -> np.ndarray:
tab = [cell for cell in forty_two]
for cell in tab:
visited[cell] = True
return visited
-244
View File
@@ -1,244 +0,0 @@
from abc import ABC, abstractmethod
from .Maze import Maze
import numpy as np
class MazeSolver(ABC):
def __init__(self, start: tuple[int, int], end: tuple[int, int]) -> None:
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,
width: int = None) -> str: ...
class AStar(MazeSolver):
def __init__(self, start: tuple[int, int], end: tuple[int, int]) -> None:
super().__init__(start, end)
def f(self, n):
def g(n: tuple[int, int]) -> int:
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:
res = 0
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:
return g(n) + h(n)
except Exception:
return 1000
def best_path(
self,
maze: np.ndarray,
actual: tuple[int, int],
last: str | None,
) -> dict[str, int]:
path = {
"N": (
self.f((actual[0], actual[1] - 1))
if not maze[actual[1]][actual[0]].get_north() and actual[1] > 0
else None
),
"E": (
self.f((actual[0] + 1, actual[1]))
if not maze[actual[1]][actual[0]].get_est()
and actual[0] < len(maze[0]) - 1
else None
),
"S": (
self.f((actual[0], actual[1] + 1))
if not maze[actual[1]][actual[0]].get_south()
and actual[1] < len(maze) - 1
else None
),
"W": (
self.f((actual[0] - 1, actual[1]))
if not maze[actual[1]][actual[0]].get_west() and actual[0] > 0
else None
),
}
return {
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:
match dir:
case "N":
return "S"
case "E":
return "W"
case "S":
return "N"
case "W":
return "E"
case _:
return ""
def get_next_pos(
self, dir: str, actual: tuple[int, int]
) -> 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:
path = [(self.start, self.best_path(maze, self.start, None))]
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:
next_pos = self.get_next_pos(
list(path[-1][1].keys())[0], path[-1][0]
)
if next_pos in visited:
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])
path.append(
(
next_pos,
self.best_path(maze, next_pos, pre),
)
)
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,
width: int = None) -> str:
res = self.get_path(maze.get_maze())
if res is None:
raise Exception("Path not found")
return res
class DepthFirstSearchSolver(MazeSolver):
def __init__(self, start, end):
super().__init__(start, end)
def solve(self, maze: Maze, height: int = None,
width: int = None) -> str:
res = list()
for _ in range(50):
res.append(self.get_path(maze, height, width))
return min(res, key=lambda x: len(x))
def get_path(self, maze: Maze, height: int = None,
width: int = None) -> str:
path_str = ""
visited = np.zeros((height, width), dtype=bool)
path = list()
move = list()
maze_s = maze.get_maze()
coord = self.start
h_w = (height, width)
while coord != self.end:
visited[coord] = True
path.append(coord)
rand_p = 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: np.ndarray, coord: tuple,
maze: np.ndarray, h_w: tuple) -> list:
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:
return np.random.choice(rand_path)
@staticmethod
def back_on_step(path: list, visited: np.ndarray,
maze: np.ndarray, h_w: tuple, move: list) -> 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, next: str) -> tuple:
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)
-10
View File
@@ -1,10 +0,0 @@
from .Cell import Cell
from .Maze import Maze
from .MazeGenerator import MazeGenerator, DepthFirstSearch
from .MazeGenerator import Kruskal
from .MazeSolver import MazeSolver, AStar, DepthFirstSearchSolver
__version__ = "1.0.0"
__author__ = "us"
__all__ = ["Cell", "Maze", "MazeGenerator", "DepthFirstSearchSolver",
"MazeSolver", "AStar", "Kruskal", "DepthFirstSearch"]
+124
View File
@@ -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
+76
View File
@@ -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()
+615
View File
@@ -0,0 +1,615 @@
from abc import ABC, abstractmethod
from typing import Generator, Any
import numpy as np
from numpy.typing import NDArray
from mazegen.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[1] - 1, start[0] - 1)
self.end = (end[1] - 1, end[0] - 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.
"""
def enough_wall(cell: Cell) -> bool:
nb_wall = 0
if cell.get_est():
nb_wall += 1
if cell.get_north():
nb_wall += 1
if cell.get_west():
nb_wall += 1
if cell.get_south():
nb_wall += 1
if nb_wall == 3:
return True
return False
directions = {"N": (0, -1), "S": (0, 1), "W": (-1, 0), "E": (1, 0)}
reverse = {"N": "S", "S": "N", "W": "E", "E": "W"}
min_break = 1
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 not enough_wall(maze[y][x]):
continue
else:
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 >= 7 and width >= 9:
cells_ft = self.get_cell_ft(width, height)
if cells_ft and (self.start in cells_ft or self.end in cells_ft):
print(
"Forty two will not be display. "
"Entry or exit set in the ft logo"
)
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
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[1] - 1, start[0] - 1)
self.end = (end[1] - 1, end[0] - 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:
random.seed(seed)
maze = self.init_maze(width, height)
if width >= 9 and height >= 7:
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)
else:
print(
"Forty two will not be display. "
"Entry or exit set in the ft logo"
)
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
+430
View File
@@ -0,0 +1,430 @@
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.
"""
self.start = (start[0] - 1, start[1] - 1)
self.end = (end[0] - 1, end[1] - 1)
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,
)
)
if path == self.end:
break
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)
+18
View File
@@ -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",
]
+162 -27
View File
@@ -1,11 +1,40 @@
from src.amaz_lib.MazeGenerator import DepthFirstSearch, Kruskal from mazegen import DepthFirstSearch, Kruskal
from src.amaz_lib.MazeSolver import AStar, DepthFirstSearchSolver from mazegen import AStar, DepthFirstSearchSolver
from typing import Any
class DataMaze: class DataMaze:
"""Provide helper methods to load and validate maze configuration data."""
@staticmethod
def test_output_file(name_file: str) -> None:
try:
with open(name_file, "r"):
while True:
res = input(
f"{name_file} already exist. Data will"
" be erased. Continue ? (y/n)"
)
if res == "y":
break
elif res == "n":
raise Exception("")
except FileNotFoundError:
return
@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 == "":
@@ -13,14 +42,36 @@ class DataMaze:
return data return data
@staticmethod @staticmethod
def transform_data(data: str) -> dict: def transform_data(data: str) -> dict[str, str]:
"""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 = [
data_t = {value[0]: value[1] for value in tmp2} value.split("=", 1)
for value in tmp
if not value.startswith("#") and "=" in value
]
data_t = {value[0].upper(): value[1] for value in tmp2}
return data_t return data_t
@staticmethod @staticmethod
def verif_key_data(data: dict) -> None: def verif_key_data(data: dict[str, str]) -> 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",
@@ -31,53 +82,101 @@ class DataMaze:
"GENERATOR", "GENERATOR",
"SOLVER", "SOLVER",
} }
set_key = {key for key in data.keys()} i = 0
if len(set_key) != len(key_test): for key in data:
raise KeyError("Missing some data the len do not correspond") if key.upper() == "OUTPUT_FILE":
res_key = {key for key in set_key if key not in key_test} DataMaze.test_output_file(data[key])
if len(res_key) != 0: if key in key_test:
raise KeyError( i += 1
"Some Key " f"do not correspond the keys: {res_key}" if len(key_test) != i:
) raise Exception("Some mandatory key not provide")
@staticmethod @staticmethod
def convert_values(data: dict): def convert_values(data: dict[str, str]) -> dict[str, Any]:
"""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"}
res: dict = {} res: dict[str, Any] = {}
for key in key_int: for key in key_int:
res.update({key: int(data[key])}) res.update({key: int(data[key])})
try:
res.update({"SEED": int(data["SEED"])})
except KeyError:
pass
for key in key_tuple: for key in key_tuple:
res.update({key: DataMaze.convert_tuple(data[key])}) res.update({key: DataMaze.convert_tuple(data[key])})
for key in key_bool: for key in key_bool:
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[str, str],
available_generator = { entry: tuple[int, int],
exit: tuple[int, int],
perfect: bool,
) -> dict[str, Any]:
"""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: dict[str, Any] = {
"Kruskal": Kruskal, "Kruskal": Kruskal,
"DFS": DepthFirstSearch, "DFS": DepthFirstSearch,
} }
available_solver = { available_solver: dict[str, Any] = {
"AStar": AStar, "AStar": AStar,
"DFS": DepthFirstSearchSolver "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[int, int]:
"""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 +188,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":
@@ -96,8 +206,33 @@ class DataMaze:
return False return False
@staticmethod @staticmethod
def get_data_maze(name_file: str) -> dict: def test_file_format(file: str) -> None:
with open(file) as data_str:
for line in data_str:
if line.startswith("#"):
continue
if len(line.split("=", 1)) != 2:
raise Exception(
"config file format not respected. excpected format : "
"KEY=VALUE"
)
if not line.split("=", 1)[1] or line.split("=", 1)[1] == "\n":
raise Exception(
f"VALUE not provide for {line.split('=')[0]} key"
)
@staticmethod
def get_data_maze(name_file: str) -> dict[str, Any]:
"""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:
DataMaze.test_file_format(name_file)
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)
DataMaze.verif_key_data(data_dict) DataMaze.verif_key_data(data_dict)
-6
View File
@@ -1,6 +0,0 @@
__version__ = "1.0.0"
__author__ = "mteriier, dgaillet"
from .Parsing import DataMaze
__all__ = ["DataMaze"]
View File
+1 -2
View File
@@ -1,5 +1,4 @@
import pytest from mazegen import Cell
from amaz_lib.Cell import Cell
def test_cell_setter_getter() -> None: def test_cell_setter_getter() -> None:
+2 -2
View File
@@ -1,5 +1,5 @@
from amaz_lib.MazeGenerator import DepthFirstSearch from mazegen import DepthFirstSearch
from amaz_lib.Cell import Cell from mazegen import Cell
import numpy as np import numpy as np
+5 -3
View File
@@ -1,6 +1,6 @@
import numpy import numpy
from amaz_lib.Cell import Cell from mazegen import Cell
from amaz_lib.Maze import Maze from mazegen import Maze
def test_maze_setter_getter() -> None: def test_maze_setter_getter() -> None:
@@ -15,7 +15,9 @@ def test_maze_setter_getter() -> None:
) )
maze.set_maze(test) maze.set_maze(test)
assert numpy.array_equal(maze.get_maze(), test) is True m = maze.get_maze()
assert m is not None
assert numpy.array_equal(m, test) is True
def test_maze_str() -> None: def test_maze_str() -> None:
+1 -5
View File
@@ -1,5 +1,5 @@
import numpy import numpy
from amaz_lib.MazeGenerator import DepthFirstSearch, MazeGenerator from mazegen import DepthFirstSearch
class TestMazeGenerator: class TestMazeGenerator:
@@ -12,7 +12,3 @@ class TestMazeGenerator:
maze = output maze = output
assert maze.shape == w_h assert maze.shape == w_h
def test_gen_broken(self) -> None:
test = MazeGenerator.gen_broken_set(50, 50)
assert len(test) > 0
+2 -2
View File
@@ -1,6 +1,6 @@
from amaz_lib.Cell import Cell from mazegen import Cell
import numpy as np import numpy as np
from amaz_lib import AStar, Maze, MazeSolver from mazegen import AStar, Maze
def test_solver() -> None: def test_solver() -> None:
+17 -17
View File
@@ -4,71 +4,71 @@ import pytest
class TestParsing: class TestParsing:
def test_get_data_valid(self): def test_get_data_valid(self) -> None:
data = DataMaze.get_file_data("tests/test_txt/config_1.txt") data = DataMaze.get_file_data("tests/test_txt/config_1.txt")
assert isinstance(data, str) is True assert isinstance(data, str) is True
def test_file_error(self): def test_file_error(self) -> None:
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
DataMaze.get_file_data("tete") DataMaze.get_file_data("tete")
# def test_permission_error(self): # def test_permission_error(self) -> None:
# with pytest.raises(PermissionError): # with pytest.raises(PermissionError):
# DataMaze.get_file_data("tests/test_txt/error_1.txt") # DataMaze.get_file_data("tests/test_txt/error_1.txt")
def test_empty_file_error(self): def test_empty_file_error(self) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
DataMaze.get_file_data("tests/test_txt/error_6.txt") DataMaze.get_file_data("tests/test_txt/error_6.txt")
def test_transform_data_valid(self): def test_transform_data_valid(self) -> None:
data = DataMaze.get_file_data("tests/test_txt/config_1.txt") data = DataMaze.get_file_data("tests/test_txt/config_1.txt")
data_2 = DataMaze.transform_data(data) data_2 = DataMaze.transform_data(data)
assert isinstance(data_2, dict) assert isinstance(data_2, dict)
def test_transform__index_error(self): def test_transform__index_error(self) -> None:
with pytest.raises(IndexError): with pytest.raises(IndexError):
DataMaze.transform_data("asdasdasdasdasdasda\nasdasdas=asdasd") DataMaze.transform_data("asdasdasdasdasdasda\nasdasdas=asdasd")
def test_key_data_error(self): def test_key_data_error(self) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
data = DataMaze.get_file_data("tests/test_txt/error_8.txt") data = DataMaze.get_file_data("tests/test_txt/error_8.txt")
data2 = DataMaze.transform_data(data) data2 = DataMaze.transform_data(data)
DataMaze.verif_key_data(data2) DataMaze.verif_key_data(data2)
def test_key_data_error_2(self): def test_key_data_error_2(self) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
data = DataMaze.get_file_data("tests/test_txt/error_9.txt") data = DataMaze.get_file_data("tests/test_txt/error_9.txt")
data2 = DataMaze.transform_data(data) data2 = DataMaze.transform_data(data)
DataMaze.verif_key_data(data2) DataMaze.verif_key_data(data2)
def test_convert_int(self): def test_convert_int(self) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
data = DataMaze.get_file_data("tests/test_txt/error_2.txt") data = DataMaze.get_file_data("tests/test_txt/error_2.txt")
data2 = DataMaze.transform_data(data) data2 = DataMaze.transform_data(data)
DataMaze.convert_values(data2) DataMaze.convert_values(data2)
def test_tuple_error(self): def test_tuple_error(self) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
DataMaze.convert_tuple("0,3,5,5") DataMaze.convert_tuple("0,3,5,5")
def test_tuple_error1(self): def test_tuple_error1(self) -> None:
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
DataMaze.convert_tuple(None) DataMaze.convert_tuple("None")
def test_bool_error(self): def test_bool_error(self) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
DataMaze.convert_bool("Trueeee") DataMaze.convert_bool("Trueeee")
def test_valid_tuple(self): def test_valid_tuple(self) -> None:
assert DataMaze.convert_tuple("7534564654, 78") == (7534564654, 78) assert DataMaze.convert_tuple("7534564654, 78") == (7534564654, 78)
def test_valid_bool(self): def test_valid_bool(self) -> None:
assert DataMaze.convert_bool("False") is False assert DataMaze.convert_bool("False") is False
def test_valid_bool1(self): def test_valid_bool1(self) -> None:
assert DataMaze.convert_bool("True") is True assert DataMaze.convert_bool("True") is True
def test_data_maze(self): def test_data_maze(self) -> None:
data = DataMaze.get_data_maze("tests/test_txt/config_1.txt") data = DataMaze.get_data_maze("tests/test_txt/config_1.txt")
assert data["WIDTH"] == 200 assert data["WIDTH"] == 200
assert data["HEIGHT"] == 100 assert data["HEIGHT"] == 100
Generated
+30 -30
View File
@@ -6,36 +6,6 @@ resolution-markers = [
"python_full_version < '3.11'", "python_full_version < '3.11'",
] ]
[[package]]
name = "a-maze-ing"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pydantic" },
]
[package.dev-dependencies]
dev = [
{ name = "flake8" },
{ name = "mypy" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "numpy", specifier = ">=2.2.6" },
{ name = "pydantic", specifier = ">=2.12.5" },
]
[package.metadata.requires-dev]
dev = [
{ name = "flake8", specifier = ">=7.3.0" },
{ name = "mypy", specifier = ">=1.19.1" },
{ name = "pytest", specifier = ">=9.0.2" },
]
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
@@ -174,6 +144,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
] ]
[[package]]
name = "mazegen"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pydantic" },
]
[package.dev-dependencies]
dev = [
{ name = "flake8" },
{ name = "mypy" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "numpy", specifier = ">=2.2.6" },
{ name = "pydantic", specifier = ">=2.12.5" },
]
[package.metadata.requires-dev]
dev = [
{ name = "flake8", specifier = ">=7.3.0" },
{ name = "mypy", specifier = ">=1.19.1" },
{ name = "pytest", specifier = ">=9.0.2" },
]
[[package]] [[package]]
name = "mccabe" name = "mccabe"
version = "0.7.0" version = "0.7.0"