squared_maze 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: squared_maze
3
+ Version: 0.1.0
4
+ Summary: Maze generator and A* solver with ASCII and image rendering
5
+ Author-email: Jaime Pizarroso <jpizarroso@comillas.edu>
6
+ License: MIT
7
+ Keywords: maze,astar,generator,puzzle,visualization
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: Pillow>=9.0.0
15
+ Dynamic: license-file
16
+
17
+ # squared_maze
18
+
19
+ Tiny Python package to generate grid mazes, solve them with A* and render
20
+ ASCII and PNG visualizations. The project includes utilities to pick valid
21
+ start/end cells, force a maze to become unsolvable, or introduce additional
22
+ solutions by breaking walls.
23
+
24
+ Features
25
+ - Generate perfect mazes (recursive backtracker) as a grid where `1` is
26
+ walkable and `0` is a wall.
27
+ - Solve with A* (`src/squared_maze/solver.py`).
28
+ - Render ASCII (`grid_to_ascii`) with customizable symbols.
29
+ - Render PNG images (Pillow) with start (green), end (red), path (blue),
30
+ walls (dark gray) and floors (light gray).
31
+ - Helpers: `find_valid_cell`, `make_unsolvable`, `make_multiple_solutions`.
32
+
33
+ Quick install
34
+
35
+ This project requires Python 3.8+ and Pillow for image output. Install the
36
+ dependency into your environment:
37
+
38
+ ```bash
39
+ pip install pillow
40
+ ```
41
+
42
+ Running the example notebook
43
+
44
+ Open the example notebook `examples/maze_example.ipynb` with Jupyter or run it
45
+ in a supported environment (VS Code/Jupyter Lab). The notebook demonstrates:
46
+ - generating and solving a maze
47
+ - saving two images (with and without the path)
48
+ - creating an unsolvable variant
49
+ - creating a variant with multiple distinct solutions
50
+
51
+ If running the notebook from the repository you may need to add the project
52
+ `src` folder to `PYTHONPATH`. From the repository root you can run a small
53
+ script or open a Python REPL like this:
54
+
55
+ ```bash
56
+ python3 -c "import sys; from pathlib import Path; sys.path.insert(0, str(Path('src').resolve())); from squared_maze import generate_maze, astar, grid_to_ascii; g=generate_maze(12,20,seed=42); print(grid_to_ascii(g, None))"
57
+ ```
58
+
59
+ Basic usage (script)
60
+
61
+ ```python
62
+ from squared_maze import (
63
+ generate_maze,
64
+ astar,
65
+ grid_to_ascii,
66
+ save_images,
67
+ find_valid_cell,
68
+ )
69
+
70
+ # generate
71
+ grid = generate_maze(12, 20, seed=42)
72
+ start = find_valid_cell(grid, seed=1)
73
+ end = find_valid_cell(grid, exclude={start}, seed=2)
74
+ path = astar(grid, start, end)
75
+
76
+ print(grid_to_ascii(grid, path, start, end))
77
+ save_images(grid, path, start, end, cell_size=24, out_prefix='maze_out')
78
+ ```
79
+
80
+ API (key functions)
81
+ - `generate_maze(rows, cols, seed=None)` -> grid
82
+ - `astar(grid, start, end)` -> list of coordinates or `None`
83
+ - `grid_to_ascii(grid, path=None, start=None, end=None, ...)` -> str (customizable symbols)
84
+ - `save_images(grid, path=None, start=None, end=None, cell_size=16, out_prefix='maze')` -> (fn_no, fn_yes)
85
+ - `find_valid_cell(grid, exclude=None, seed=None)` -> (row, col)
86
+ - `make_unsolvable(grid, start, end, astar_fn, ...)` -> bool (modifies grid)
87
+ - `make_multiple_solutions(grid, start, end, astar_fn, ...)` -> bool (modifies grid)
88
+
89
+ Notes
90
+ - The generator produces a perfect maze (a spanning tree). That means there
91
+ is exactly one path between any two room cells until you deliberately
92
+ break walls (e.g. with `make_multiple_solutions`). Use `find_valid_cell`
93
+ to pick valid walkable start/end cells.
94
+ - Images are saved to the working directory. Filenames are returned by
95
+ `save_images`.
96
+
97
+ Contributing
98
+ - Small, self-contained patches are welcome. Please keep docstrings and
99
+ code PEP8-compatible and add a short test if you change core behaviour.
100
+
101
+ License
102
+ - This repository does not include an explicit license file. Add one if you
103
+ intend to publish or share the code.
@@ -0,0 +1,87 @@
1
+ # squared_maze
2
+
3
+ Tiny Python package to generate grid mazes, solve them with A* and render
4
+ ASCII and PNG visualizations. The project includes utilities to pick valid
5
+ start/end cells, force a maze to become unsolvable, or introduce additional
6
+ solutions by breaking walls.
7
+
8
+ Features
9
+ - Generate perfect mazes (recursive backtracker) as a grid where `1` is
10
+ walkable and `0` is a wall.
11
+ - Solve with A* (`src/squared_maze/solver.py`).
12
+ - Render ASCII (`grid_to_ascii`) with customizable symbols.
13
+ - Render PNG images (Pillow) with start (green), end (red), path (blue),
14
+ walls (dark gray) and floors (light gray).
15
+ - Helpers: `find_valid_cell`, `make_unsolvable`, `make_multiple_solutions`.
16
+
17
+ Quick install
18
+
19
+ This project requires Python 3.8+ and Pillow for image output. Install the
20
+ dependency into your environment:
21
+
22
+ ```bash
23
+ pip install pillow
24
+ ```
25
+
26
+ Running the example notebook
27
+
28
+ Open the example notebook `examples/maze_example.ipynb` with Jupyter or run it
29
+ in a supported environment (VS Code/Jupyter Lab). The notebook demonstrates:
30
+ - generating and solving a maze
31
+ - saving two images (with and without the path)
32
+ - creating an unsolvable variant
33
+ - creating a variant with multiple distinct solutions
34
+
35
+ If running the notebook from the repository you may need to add the project
36
+ `src` folder to `PYTHONPATH`. From the repository root you can run a small
37
+ script or open a Python REPL like this:
38
+
39
+ ```bash
40
+ python3 -c "import sys; from pathlib import Path; sys.path.insert(0, str(Path('src').resolve())); from squared_maze import generate_maze, astar, grid_to_ascii; g=generate_maze(12,20,seed=42); print(grid_to_ascii(g, None))"
41
+ ```
42
+
43
+ Basic usage (script)
44
+
45
+ ```python
46
+ from squared_maze import (
47
+ generate_maze,
48
+ astar,
49
+ grid_to_ascii,
50
+ save_images,
51
+ find_valid_cell,
52
+ )
53
+
54
+ # generate
55
+ grid = generate_maze(12, 20, seed=42)
56
+ start = find_valid_cell(grid, seed=1)
57
+ end = find_valid_cell(grid, exclude={start}, seed=2)
58
+ path = astar(grid, start, end)
59
+
60
+ print(grid_to_ascii(grid, path, start, end))
61
+ save_images(grid, path, start, end, cell_size=24, out_prefix='maze_out')
62
+ ```
63
+
64
+ API (key functions)
65
+ - `generate_maze(rows, cols, seed=None)` -> grid
66
+ - `astar(grid, start, end)` -> list of coordinates or `None`
67
+ - `grid_to_ascii(grid, path=None, start=None, end=None, ...)` -> str (customizable symbols)
68
+ - `save_images(grid, path=None, start=None, end=None, cell_size=16, out_prefix='maze')` -> (fn_no, fn_yes)
69
+ - `find_valid_cell(grid, exclude=None, seed=None)` -> (row, col)
70
+ - `make_unsolvable(grid, start, end, astar_fn, ...)` -> bool (modifies grid)
71
+ - `make_multiple_solutions(grid, start, end, astar_fn, ...)` -> bool (modifies grid)
72
+
73
+ Notes
74
+ - The generator produces a perfect maze (a spanning tree). That means there
75
+ is exactly one path between any two room cells until you deliberately
76
+ break walls (e.g. with `make_multiple_solutions`). Use `find_valid_cell`
77
+ to pick valid walkable start/end cells.
78
+ - Images are saved to the working directory. Filenames are returned by
79
+ `save_images`.
80
+
81
+ Contributing
82
+ - Small, self-contained patches are welcome. Please keep docstrings and
83
+ code PEP8-compatible and add a short test if you change core behaviour.
84
+
85
+ License
86
+ - This repository does not include an explicit license file. Add one if you
87
+ intend to publish or share the code.
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "squared_maze"
3
+ version = "0.1.0"
4
+ description = "Maze generator and A* solver with ASCII and image rendering"
5
+ readme = "README.md"
6
+ requires-python = ">=3.8"
7
+ license = { text = "MIT" }
8
+ authors = [ { name = "Jaime Pizarroso", email = "jpizarroso@comillas.edu" } ]
9
+ keywords = ["maze", "astar", "generator", "puzzle", "visualization"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Operating System :: OS Independent",
14
+ ]
15
+ dependencies = [
16
+ "Pillow>=9.0.0"
17
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ """Top-level package for maze generator and solver.
2
+
3
+ Expose main helpers: generate_maze, solve_maze, render helpers.
4
+ """
5
+ from .generator import generate_maze, find_valid_cell, make_multiple_solutions, make_unsolvable
6
+ from .solver import astar
7
+ from .render import grid_to_ascii, save_images
8
+
9
+ __all__ = [
10
+ "generate_maze",
11
+ "find_valid_cell",
12
+ "make_multiple_solutions",
13
+ "make_unsolvable",
14
+ "astar",
15
+ "grid_to_ascii",
16
+ "save_images",
17
+ ]
@@ -0,0 +1,293 @@
1
+ """Maze generation utilities.
2
+
3
+ This module implements a randomized depth-first search (recursive backtracker)
4
+ to carve a perfect maze into a grid that includes walls as cells. The
5
+ public function `generate_maze` accepts the number of logical rows and columns
6
+ and returns a grid of size (2*rows+1) x (2*cols+1) where 1 indicates a
7
+ walkable cell and 0 indicates a wall.
8
+
9
+ The grid layout uses odd indices for rooms/cells and even indices for walls.
10
+ This makes it simple to carve passages by stepping two cells at a time and
11
+ clearing the intermediate wall.
12
+
13
+ All functions follow Google style docstrings and minimal external deps.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import random
18
+ from typing import List, Optional, Tuple, Set, Callable
19
+
20
+
21
+ def _make_grid(rows: int, cols: int) -> List[List[int]]:
22
+ """Create a grid initialized with walls (0).
23
+
24
+ Args:
25
+ rows: number of logical rows (rooms).
26
+ cols: number of logical columns (rooms).
27
+
28
+ Returns:
29
+ A list of lists representing the grid with shape (2*rows+1) x (2*cols+1).
30
+ """
31
+ height = 2 * rows + 1
32
+ width = 2 * cols + 1
33
+ return [[0 for _ in range(width)] for _ in range(height)]
34
+
35
+
36
+ def generate_maze(rows: int, cols: int, seed: Optional[int] = None) -> List[List[int]]:
37
+ """Generate a perfect maze using recursive backtracker.
38
+
39
+ The returned grid is (2*rows+1) x (2*cols+1) with:
40
+ - 1: walkable cell
41
+ - 0: wall
42
+
43
+ Args:
44
+ rows: number of logical rows (rooms).
45
+ cols: number of logical columns (rooms).
46
+ seed: optional random seed for reproducibility.
47
+
48
+ Returns:
49
+ A 2D grid of ints (1 walkable, 0 wall).
50
+
51
+ Raises:
52
+ ValueError: if rows or cols are less than 1.
53
+ """
54
+ if rows < 1 or cols < 1:
55
+ raise ValueError("rows and cols must be >= 1")
56
+
57
+ rng = random.Random(seed)
58
+ grid = _make_grid(rows, cols)
59
+
60
+ # Helper to convert room coordinates (r,c) in [0,rows) to grid coords
61
+ def to_grid(rc: Tuple[int, int]) -> Tuple[int, int]:
62
+ r, c = rc
63
+ return 2 * r + 1, 2 * c + 1
64
+
65
+ visited = [[False for _ in range(cols)] for _ in range(rows)]
66
+
67
+ # Neighbor deltas in room coordinates (4-connectivity)
68
+ deltas = [(0, 1), (1, 0), (0, -1), (-1, 0)]
69
+
70
+ def carve(r: int, c: int) -> None:
71
+ """Recursively carve passages starting from room (r,c)."""
72
+ visited[r][c] = True
73
+ gr, gc = to_grid((r, c))
74
+ grid[gr][gc] = 1
75
+
76
+ # randomize neighbor order
77
+ rng.shuffle(deltas)
78
+ for dr, dc in deltas:
79
+ nr, nc = r + dr, c + dc
80
+ if 0 <= nr < rows and 0 <= nc < cols and not visited[nr][nc]:
81
+ # remove wall between (r,c) and (nr,nc)
82
+ gr2, gc2 = to_grid((nr, nc))
83
+ wall_r, wall_c = (gr + gr2) // 2, (gc + gc2) // 2
84
+ grid[wall_r][wall_c] = 1
85
+ carve(nr, nc)
86
+
87
+ carve(0, 0)
88
+
89
+ return grid
90
+
91
+
92
+ def find_valid_cell(grid: List[List[int]], exclude: Optional[Set[Tuple[int, int]]] = None,
93
+ seed: Optional[int] = None) -> Tuple[int, int]:
94
+ """Return a random valid (walkable) cell coordinate from the grid.
95
+
96
+ The function scans the provided `grid` (where 1 means walkable and 0 means
97
+ wall) and returns a randomly chosen coordinate (row, col) such that the
98
+ cell is walkable and not in the optional `exclude` set.
99
+
100
+ Args:
101
+ grid: 2D grid produced by :func:`generate_maze` where 1 is walkable.
102
+ exclude: optional set of coordinates to avoid (for example the start
103
+ when choosing an end cell).
104
+ seed: optional integer seed for reproducible selection. If omitted a
105
+ default random source is used.
106
+
107
+ Returns:
108
+ A tuple (row, col) pointing to a walkable cell in the grid.
109
+
110
+ Raises:
111
+ ValueError: if no valid cell can be found (for example a grid with no
112
+ walkable cells or all walkable cells are excluded).
113
+ """
114
+ rng = random.Random(seed)
115
+ rows = len(grid)
116
+ if rows == 0:
117
+ raise ValueError("grid must be non-empty")
118
+ cols = len(grid[0])
119
+
120
+ exclude = exclude or set()
121
+
122
+ # collect all walkable cells not in exclude
123
+ candidates: List[Tuple[int, int]] = [
124
+ (r, c)
125
+ for r in range(rows)
126
+ for c in range(cols)
127
+ if grid[r][c] == 1 and (r, c) not in exclude
128
+ ]
129
+
130
+ if not candidates:
131
+ raise ValueError("no valid walkable cells available to choose from")
132
+
133
+ return rng.choice(candidates)
134
+
135
+
136
+ def make_multiple_solutions(
137
+ grid: List[List[int]],
138
+ start: Tuple[int, int],
139
+ end: Tuple[int, int],
140
+ astar_fn: Callable,
141
+ max_tries: int = 200,
142
+ seed: Optional[int] = None,
143
+ ) -> bool:
144
+ """Modify ``grid`` by breaking walls until the maze has multiple solutions.
145
+
146
+ This function attempts to create an alternate path between ``start`` and
147
+ ``end`` by turning wall cells (0) into walkable cells (1). After each
148
+ candidate wall removal it calls ``astar_fn(grid, start, end)`` to check if
149
+ the returned path differs from the original one. The grid is modified in
150
+ place and the function returns True on success.
151
+
152
+ Args:
153
+ grid: 2D grid where 1 is walkable and 0 is wall.
154
+ start: start coordinate (row, col).
155
+ end: end coordinate (row, col).
156
+ astar_fn: function implementing A* search with signature
157
+ ``astar_fn(grid, start, end) -> Optional[List[Tuple[int,int]]]``.
158
+ max_tries: maximum number of candidate walls to try.
159
+ seed: optional seed for reproducibility.
160
+
161
+ Returns:
162
+ True if the grid was modified to produce multiple distinct solutions
163
+ between start and end, False otherwise.
164
+
165
+ Raises:
166
+ ValueError: if start or end are not walkable.
167
+ """
168
+ rng = random.Random(seed)
169
+
170
+ if grid[start[0]][start[1]] != 1 or grid[end[0]][end[1]] != 1:
171
+ raise ValueError("start and end must be walkable cells (grid value 1)")
172
+
173
+ orig_path = astar_fn(grid, start, end)
174
+ if not orig_path:
175
+ # nothing to make multiple if they are not connected
176
+ return False
177
+
178
+ rows = len(grid)
179
+ cols = len(grid[0])
180
+
181
+ # candidate wall cells that when removed connect two or more walkable neighbors
182
+ candidates: List[Tuple[int, int]] = []
183
+ for r in range(rows):
184
+ for c in range(cols):
185
+ if grid[r][c] != 0:
186
+ continue
187
+ neighs = 0
188
+ for dr, dc in ((0, 1), (1, 0), (0, -1), (-1, 0)):
189
+ nr, nc = r + dr, c + dc
190
+ if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
191
+ neighs += 1
192
+ if neighs >= 2:
193
+ candidates.append((r, c))
194
+
195
+ rng.shuffle(candidates)
196
+
197
+ tries = 0
198
+ for (r, c) in candidates:
199
+ if tries >= max_tries:
200
+ break
201
+ tries += 1
202
+ # try removing this wall
203
+ grid[r][c] = 1
204
+ new_path = astar_fn(grid, start, end)
205
+ if new_path and new_path != orig_path:
206
+ return True
207
+ # revert
208
+ grid[r][c] = 0
209
+
210
+ return False
211
+
212
+
213
+ def make_unsolvable(
214
+ grid: List[List[int]],
215
+ start: Tuple[int, int],
216
+ end: Tuple[int, int],
217
+ astar_fn: Callable,
218
+ seed: Optional[int] = None,
219
+ max_tries: int = 100,
220
+ ) -> bool:
221
+ """Modify ``grid`` to make the maze unsolvable between ``start`` and
222
+ ``end``.
223
+
224
+ The function finds an existing path between ``start`` and ``end`` and
225
+ then blocks one or more intermediate cells along that path (not start or
226
+ end) to disconnect the endpoints. Returns True if the grid was modified
227
+ and the two points are no longer connected.
228
+
229
+ Args:
230
+ grid: 2D grid where 1 is walkable and 0 is wall.
231
+ start: start coordinate (row, col).
232
+ end: end coordinate (row, col).
233
+ astar_fn: function implementing A* search with signature
234
+ ``astar_fn(grid, start, end) -> Optional[List[Tuple[int,int]]]``.
235
+ seed: optional seed for reproducibility.
236
+ max_tries: maximum attempts to block different intermediate cells.
237
+
238
+ Returns:
239
+ True if the grid was modified so that no path exists between start and end,
240
+ False if unsuccessful.
241
+
242
+ Raises:
243
+ ValueError: if start or end are not walkable.
244
+ """
245
+ rng = random.Random(seed)
246
+
247
+ if grid[start[0]][start[1]] != 1 or grid[end[0]][end[1]] != 1:
248
+ raise ValueError("start and end must be walkable cells (grid value 1)")
249
+
250
+ path = astar_fn(grid, start, end)
251
+ if not path:
252
+ # already unsolvable
253
+ return True
254
+
255
+ # consider intermediate path cells (exclude start/end)
256
+ intermediates = [p for p in path[1:-1]]
257
+ if not intermediates:
258
+ # direct neighbors — blocking one neighbor should work though
259
+ intermediates = [path[1]] if len(path) > 1 else []
260
+
261
+ rng.shuffle(intermediates)
262
+ tries = 0
263
+ for cell in intermediates:
264
+ if tries >= max_tries:
265
+ break
266
+ tries += 1
267
+ r, c = cell
268
+ saved = grid[r][c]
269
+ grid[r][c] = 0
270
+ if not astar_fn(grid, start, end):
271
+ return True
272
+ # revert and try next
273
+ grid[r][c] = saved
274
+
275
+ # as a fallback, try blocking additional nearby walkable cells
276
+ rows = len(grid)
277
+ cols = len(grid[0])
278
+ all_walkables = [(r, c) for r in range(rows) for c in range(cols) if grid[r][c] == 1 and (r, c) not in (start, end)]
279
+ rng.shuffle(all_walkables)
280
+ for (r, c) in all_walkables[:max_tries]:
281
+ saved = grid[r][c]
282
+ grid[r][c] = 0
283
+ if not astar_fn(grid, start, end):
284
+ return True
285
+ grid[r][c] = saved
286
+
287
+ return False
288
+
289
+
290
+ if __name__ == "__main__":
291
+ g = generate_maze(5, 10, seed=1)
292
+ for row in g:
293
+ print(''.join(['.' if c else '#' for c in row]))
@@ -0,0 +1,157 @@
1
+ """Rendering utilities for the maze: ASCII and images.
2
+
3
+ The image functions use Pillow to create a visualization of the grid. Walls are
4
+ dark gray, walkable light gray, start green, end red, and path cells blue.
5
+ Thin black separators are drawn between cells.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import List, Optional, Tuple
10
+
11
+ from PIL import Image, ImageDraw
12
+
13
+ Coord = Tuple[int, int]
14
+
15
+
16
+ def grid_to_ascii(
17
+ grid: List[List[int]],
18
+ path: Optional[List[Coord]] = None,
19
+ start: Optional[Coord] = None,
20
+ end: Optional[Coord] = None,
21
+ start_sym: str = "S",
22
+ end_sym: str = "E",
23
+ wall_sym: str = "█",
24
+ path_sym: str = "*",
25
+ walkable_sym: str = ".",
26
+ ) -> str:
27
+ """Return an ASCII representation of the maze using customizable symbols.
28
+
29
+ The function renders the grid into a multiline string. By default the
30
+ following symbols are used:
31
+ - start: ``S``
32
+ - end: ``E``
33
+ - wall: ``█``
34
+ - path (non start/end cells): ``*``
35
+ - walkable cells: ``.``
36
+
37
+ Args:
38
+ grid: 2D grid where 1 is walkable and 0 is wall.
39
+ path: optional list of coordinates that form the solution path.
40
+ start: optional start coordinate to mark with ``start_sym``.
41
+ end: optional end coordinate to mark with ``end_sym``.
42
+ start_sym: symbol used for the start cell. Defaults to ``'S'``.
43
+ end_sym: symbol used for the end cell. Defaults to ``'E'``.
44
+ wall_sym: symbol used for walls. Defaults to ``'█'``.
45
+ path_sym: symbol used for path cells (excluding start/end). Defaults to ``'*'``.
46
+ walkable_sym: symbol used for ordinary walkable cells. Defaults to ``'.'``.
47
+
48
+ Returns:
49
+ A multiline string with characters representing the maze.
50
+ """
51
+
52
+ path_set = set(path) if path else set()
53
+ lines: List[str] = []
54
+ for r, row in enumerate(grid):
55
+ line_chars: List[str] = []
56
+ for c, cell in enumerate(row):
57
+ coord = (r, c)
58
+ if start is not None and coord == start:
59
+ line_chars.append(start_sym)
60
+ elif end is not None and coord == end:
61
+ line_chars.append(end_sym)
62
+ elif coord in path_set and coord != start and coord != end:
63
+ line_chars.append(path_sym)
64
+ elif cell == 0:
65
+ line_chars.append(wall_sym)
66
+ else:
67
+ line_chars.append(walkable_sym)
68
+ lines.append("".join(line_chars))
69
+ return "\n".join(lines)
70
+
71
+
72
+ def save_images(grid: List[List[int]], path: Optional[List[Coord]] = None,
73
+ start: Optional[Coord] = None, end: Optional[Coord] = None,
74
+ cell_size: int = 16, out_prefix: str = "maze") -> Tuple[str, str]:
75
+ """Save two PNG images: one without the path and one including the path.
76
+
77
+ Args:
78
+ grid: 2D grid where 1 is walkable and 0 is wall.
79
+ path: optional solution path.
80
+ start: optional start coordinate.
81
+ end: optional end coordinate.
82
+ cell_size: size in pixels of each cell.
83
+ out_prefix: prefix for output filenames.
84
+
85
+ Returns:
86
+ Tuple of filenames (without_path_png, with_path_png).
87
+ """
88
+ rows = len(grid)
89
+ cols = len(grid[0])
90
+ # line thickness 1 pixel between cells
91
+ img_w = cols * cell_size + (cols + 1)
92
+ img_h = rows * cell_size + (rows + 1)
93
+
94
+ def draw(with_path: bool) -> Image.Image:
95
+ img = Image.new("RGB", (img_w, img_h), color=(0, 0, 0))
96
+ draw = ImageDraw.Draw(img)
97
+
98
+ wall_color = (40, 40, 40) # very dark gray
99
+ floor_color = (211, 211, 211) # lightgray
100
+ start_color = (0, 200, 0)
101
+ end_color = (200, 0, 0)
102
+ path_color = (0, 0, 200)
103
+
104
+ def cell_bbox(r: int, c: int):
105
+ x0 = c * cell_size + (c + 1)
106
+ y0 = r * cell_size + (r + 1)
107
+ x1 = x0 + cell_size - 1
108
+ y1 = y0 + cell_size - 1
109
+ return x0, y0, x1, y1
110
+
111
+ path_set = set(path) if path else set()
112
+
113
+ for r in range(rows):
114
+ for c in range(cols):
115
+ bbox = cell_bbox(r, c)
116
+ if grid[r][c] == 0:
117
+ draw.rectangle(bbox, fill=wall_color)
118
+ else:
119
+ draw.rectangle(bbox, fill=floor_color)
120
+
121
+ # draw path on top if requested
122
+ if with_path and path:
123
+ for (r, c) in path:
124
+ if (r, c) == start or (r, c) == end:
125
+ continue
126
+ bbox = cell_bbox(r, c)
127
+ draw.rectangle(bbox, fill=path_color)
128
+
129
+ # draw start and end
130
+ if start:
131
+ draw.rectangle(cell_bbox(*start), fill=start_color)
132
+ if end:
133
+ draw.rectangle(cell_bbox(*end), fill=end_color)
134
+
135
+ # thin black separators already present as background; optionally draw outlines
136
+ return img
137
+
138
+ img_no = draw(False)
139
+ img_yes = draw(True)
140
+
141
+ fn_no = f"{out_prefix}.png"
142
+ fn_yes = f"{out_prefix}_path.png"
143
+ img_no.save(fn_no)
144
+ img_yes.save(fn_yes)
145
+ return fn_no, fn_yes
146
+
147
+
148
+ if __name__ == "__main__":
149
+ from .generator import generate_maze
150
+ from .solver import astar
151
+
152
+ g = generate_maze(10, 16, seed=1)
153
+ start = (1, 1)
154
+ end = (len(g) - 2, len(g[0]) - 2)
155
+ p = astar(g, start, end)
156
+ print(grid_to_ascii(g, p, start, end))
157
+ save_images(g, p, start, end, cell_size=12, out_prefix="demo_maze")
@@ -0,0 +1,90 @@
1
+ """A* path finding for the maze grid.
2
+
3
+ This module implements a simple A* search over the grid produced by
4
+ `generate_maze`. It expects a grid where 1 means walkable and 0 means wall.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import heapq
9
+ from typing import List, Tuple, Optional, Dict
10
+
11
+
12
+ Coord = Tuple[int, int]
13
+
14
+
15
+ def _neighbors(grid: List[List[int]], node: Coord) -> List[Coord]:
16
+ rows = len(grid)
17
+ cols = len(grid[0])
18
+ r, c = node
19
+ nbrs = []
20
+ for dr, dc in ((0, 1), (1, 0), (0, -1), (-1, 0)):
21
+ nr, nc = r + dr, c + dc
22
+ if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
23
+ nbrs.append((nr, nc))
24
+ return nbrs
25
+
26
+
27
+ def _heuristic(a: Coord, b: Coord) -> int:
28
+ # Manhattan distance
29
+ return abs(a[0] - b[0]) + abs(a[1] - b[1])
30
+
31
+
32
+ def astar(grid: List[List[int]], start: Coord, goal: Coord) -> Optional[List[Coord]]:
33
+ """Find a path from start to goal using A*.
34
+
35
+ Args:
36
+ grid: 2D grid where 1 is walkable and 0 is wall.
37
+ start: (row, col) start coordinate.
38
+ goal: (row, col) goal coordinate.
39
+
40
+ Returns:
41
+ A list of coordinates from start to goal (inclusive) if a path exists,
42
+ otherwise None.
43
+ """
44
+ if grid[start[0]][start[1]] != 1 or grid[goal[0]][goal[1]] != 1:
45
+ return None
46
+
47
+ open_set: List[Tuple[int, Coord]] = []
48
+ heapq.heappush(open_set, (0, start))
49
+
50
+ came_from: Dict[Coord, Coord] = {}
51
+ gscore: Dict[Coord, int] = {start: 0}
52
+ fscore: Dict[Coord, int] = {start: _heuristic(start, goal)}
53
+
54
+ closed = set()
55
+
56
+ while open_set:
57
+ _, current = heapq.heappop(open_set)
58
+ if current == goal:
59
+ # reconstruct path
60
+ path = [current]
61
+ while path[-1] != start:
62
+ path.append(came_from[path[-1]])
63
+ path.reverse()
64
+ return path
65
+
66
+ closed.add(current)
67
+
68
+ for nbr in _neighbors(grid, current):
69
+ if nbr in closed:
70
+ continue
71
+ tentative_g = gscore[current] + 1
72
+ if tentative_g < gscore.get(nbr, 1_000_000):
73
+ came_from[nbr] = current
74
+ gscore[nbr] = tentative_g
75
+ f = tentative_g + _heuristic(nbr, goal)
76
+ fscore[nbr] = f
77
+ heapq.heappush(open_set, (f, nbr))
78
+
79
+ return None
80
+
81
+
82
+ if __name__ == "__main__":
83
+ # quick smoke test
84
+ from .generator import generate_maze
85
+
86
+ g = generate_maze(5, 8, seed=2)
87
+ start = (1, 1)
88
+ goal = (len(g) - 2, len(g[0]) - 2)
89
+ p = astar(g, start, goal)
90
+ print("Path length:", len(p) if p else None)
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: squared_maze
3
+ Version: 0.1.0
4
+ Summary: Maze generator and A* solver with ASCII and image rendering
5
+ Author-email: Jaime Pizarroso <jpizarroso@comillas.edu>
6
+ License: MIT
7
+ Keywords: maze,astar,generator,puzzle,visualization
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: Pillow>=9.0.0
15
+ Dynamic: license-file
16
+
17
+ # squared_maze
18
+
19
+ Tiny Python package to generate grid mazes, solve them with A* and render
20
+ ASCII and PNG visualizations. The project includes utilities to pick valid
21
+ start/end cells, force a maze to become unsolvable, or introduce additional
22
+ solutions by breaking walls.
23
+
24
+ Features
25
+ - Generate perfect mazes (recursive backtracker) as a grid where `1` is
26
+ walkable and `0` is a wall.
27
+ - Solve with A* (`src/squared_maze/solver.py`).
28
+ - Render ASCII (`grid_to_ascii`) with customizable symbols.
29
+ - Render PNG images (Pillow) with start (green), end (red), path (blue),
30
+ walls (dark gray) and floors (light gray).
31
+ - Helpers: `find_valid_cell`, `make_unsolvable`, `make_multiple_solutions`.
32
+
33
+ Quick install
34
+
35
+ This project requires Python 3.8+ and Pillow for image output. Install the
36
+ dependency into your environment:
37
+
38
+ ```bash
39
+ pip install pillow
40
+ ```
41
+
42
+ Running the example notebook
43
+
44
+ Open the example notebook `examples/maze_example.ipynb` with Jupyter or run it
45
+ in a supported environment (VS Code/Jupyter Lab). The notebook demonstrates:
46
+ - generating and solving a maze
47
+ - saving two images (with and without the path)
48
+ - creating an unsolvable variant
49
+ - creating a variant with multiple distinct solutions
50
+
51
+ If running the notebook from the repository you may need to add the project
52
+ `src` folder to `PYTHONPATH`. From the repository root you can run a small
53
+ script or open a Python REPL like this:
54
+
55
+ ```bash
56
+ python3 -c "import sys; from pathlib import Path; sys.path.insert(0, str(Path('src').resolve())); from squared_maze import generate_maze, astar, grid_to_ascii; g=generate_maze(12,20,seed=42); print(grid_to_ascii(g, None))"
57
+ ```
58
+
59
+ Basic usage (script)
60
+
61
+ ```python
62
+ from squared_maze import (
63
+ generate_maze,
64
+ astar,
65
+ grid_to_ascii,
66
+ save_images,
67
+ find_valid_cell,
68
+ )
69
+
70
+ # generate
71
+ grid = generate_maze(12, 20, seed=42)
72
+ start = find_valid_cell(grid, seed=1)
73
+ end = find_valid_cell(grid, exclude={start}, seed=2)
74
+ path = astar(grid, start, end)
75
+
76
+ print(grid_to_ascii(grid, path, start, end))
77
+ save_images(grid, path, start, end, cell_size=24, out_prefix='maze_out')
78
+ ```
79
+
80
+ API (key functions)
81
+ - `generate_maze(rows, cols, seed=None)` -> grid
82
+ - `astar(grid, start, end)` -> list of coordinates or `None`
83
+ - `grid_to_ascii(grid, path=None, start=None, end=None, ...)` -> str (customizable symbols)
84
+ - `save_images(grid, path=None, start=None, end=None, cell_size=16, out_prefix='maze')` -> (fn_no, fn_yes)
85
+ - `find_valid_cell(grid, exclude=None, seed=None)` -> (row, col)
86
+ - `make_unsolvable(grid, start, end, astar_fn, ...)` -> bool (modifies grid)
87
+ - `make_multiple_solutions(grid, start, end, astar_fn, ...)` -> bool (modifies grid)
88
+
89
+ Notes
90
+ - The generator produces a perfect maze (a spanning tree). That means there
91
+ is exactly one path between any two room cells until you deliberately
92
+ break walls (e.g. with `make_multiple_solutions`). Use `find_valid_cell`
93
+ to pick valid walkable start/end cells.
94
+ - Images are saved to the working directory. Filenames are returned by
95
+ `save_images`.
96
+
97
+ Contributing
98
+ - Small, self-contained patches are welcome. Please keep docstrings and
99
+ code PEP8-compatible and add a short test if you change core behaviour.
100
+
101
+ License
102
+ - This repository does not include an explicit license file. Add one if you
103
+ intend to publish or share the code.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/squared_maze/__init__.py
5
+ src/squared_maze/generator.py
6
+ src/squared_maze/render.py
7
+ src/squared_maze/solver.py
8
+ src/squared_maze.egg-info/PKG-INFO
9
+ src/squared_maze.egg-info/SOURCES.txt
10
+ src/squared_maze.egg-info/dependency_links.txt
11
+ src/squared_maze.egg-info/requires.txt
12
+ src/squared_maze.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ Pillow>=9.0.0
@@ -0,0 +1 @@
1
+ squared_maze